Merge branch 'develop'
This commit is contained in:
commit
9477772ace
138 changed files with 2799 additions and 1592 deletions
10
CHANGELOG.md
10
CHANGELOG.md
|
@ -3,6 +3,16 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v1.4.7] - 2021-08-06
|
||||
### Added
|
||||
- Map
|
||||
- Viewer: action to copy to clipboard
|
||||
- integration with Android global search (Samsung Finder etc.)
|
||||
|
||||
### Fixed
|
||||
- auto album identification and naming
|
||||
- opening HEIC images from downloads content URI on Android R+
|
||||
|
||||
## [v1.4.6] - 2021-07-22
|
||||
### Added
|
||||
- Albums / Countries / Tags: multiple selection
|
||||
|
|
|
@ -6,6 +6,8 @@ plugins {
|
|||
id 'com.google.firebase.crashlytics'
|
||||
}
|
||||
|
||||
def appId = "deckers.thibault.aves"
|
||||
|
||||
// Flutter properties
|
||||
|
||||
def localProperties = new Properties()
|
||||
|
@ -52,13 +54,18 @@ android {
|
|||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "deckers.thibault.aves"
|
||||
applicationId appId
|
||||
// minSdkVersion constraints:
|
||||
// - Flutter & other plugins: 16
|
||||
// - google_maps_flutter v2.0.5: 20
|
||||
// - Aves native: 19
|
||||
minSdkVersion 20
|
||||
targetSdkVersion 30
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
manifestPlaceholders = [googleApiKey: keystoreProperties['googleApiKey']]
|
||||
multiDexEnabled true
|
||||
resValue 'string', 'search_provider', "${appId}.search_provider"
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
|
@ -73,9 +80,11 @@ android {
|
|||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix ".debug"
|
||||
resValue 'string', 'search_provider', "${appId}.debug.search_provider"
|
||||
}
|
||||
profile {
|
||||
applicationIdSuffix ".profile"
|
||||
resValue 'string', 'search_provider', "${appId}.profile.search_provider"
|
||||
}
|
||||
release {
|
||||
// specify architectures, to specifically exclude native libs for x86,
|
||||
|
|
|
@ -24,7 +24,7 @@
|
|||
tools:ignore="ScopedStorage" />
|
||||
<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" />
|
||||
|
||||
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
|
||||
|
@ -38,26 +38,21 @@
|
|||
</queries>
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
<!-- TODO TLAD Android 12 https://developer.android.com/about/versions/12/behavior-changes-12#exported -->
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
tools:targetApi="lollipop">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:exported="true"
|
||||
android:hardwareAccelerated="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/NormalTheme"
|
||||
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>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
|
@ -65,6 +60,7 @@
|
|||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="image/*" />
|
||||
<data android:mimeType="video/*" />
|
||||
<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/video" />
|
||||
</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>
|
||||
|
||||
<!-- file provider to share files having a file:// URI -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:authorities="${applicationId}.file_provider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
|
@ -120,6 +131,12 @@
|
|||
android:resource="@xml/provider_paths" />
|
||||
</provider>
|
||||
|
||||
<provider
|
||||
android:name=".SearchSuggestionsProvider"
|
||||
android:authorities="@string/search_provider"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedContentProvider" />
|
||||
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="${googleApiKey}" />
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package deckers.thibault.aves
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.SearchManager
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
@ -29,10 +31,23 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Log.i(LOG_TAG, "onCreate intent=$intent")
|
||||
// StrictMode.setThreadPolicy(
|
||||
// StrictMode.ThreadPolicy.Builder()
|
||||
// .detectAll()
|
||||
// .penaltyLog()
|
||||
// .build()
|
||||
// )
|
||||
// StrictMode.setVmPolicy(
|
||||
// StrictMode.VmPolicy.Builder()
|
||||
// .detectAll()
|
||||
// .penaltyLog()
|
||||
// .build()
|
||||
// )
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val messenger = flutterEngine!!.dartExecutor.binaryMessenger
|
||||
|
||||
// dart -> platform -> dart
|
||||
MethodChannel(messenger, AppAdapterHandler.CHANNEL).setMethodCallHandler(AppAdapterHandler(this))
|
||||
MethodChannel(messenger, AppShortcutHandler.CHANNEL).setMethodCallHandler(AppShortcutHandler(this))
|
||||
MethodChannel(messenger, DebugHandler.CHANNEL).setMethodCallHandler(DebugHandler(this))
|
||||
|
@ -40,18 +55,20 @@ class MainActivity : FlutterActivity() {
|
|||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, ImageFileHandler.CHANNEL).setMethodCallHandler(ImageFileHandler(this))
|
||||
MethodChannel(messenger, GeocodingHandler.CHANNEL).setMethodCallHandler(GeocodingHandler(this))
|
||||
MethodChannel(messenger, GlobalSearchHandler.CHANNEL).setMethodCallHandler(GlobalSearchHandler(this))
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataHandler.CHANNEL).setMethodCallHandler(MetadataHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
MethodChannel(messenger, TimeHandler.CHANNEL).setMethodCallHandler(TimeHandler())
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(WindowHandler(this))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, ImageOpStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageOpStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, MediaStoreStreamHandler.CHANNEL).setStreamHandlerFactory { args -> MediaStoreStreamHandler(this, args) }
|
||||
StreamsChannel(messenger, StorageAccessStreamHandler.CHANNEL).setStreamHandlerFactory { args -> StorageAccessStreamHandler(this, args) }
|
||||
|
||||
// Media Store change monitoring
|
||||
// change monitoring: platform -> dart
|
||||
mediaStoreChangeStreamHandler = MediaStoreChangeStreamHandler(this).apply {
|
||||
EventChannel(messenger, MediaStoreChangeStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
|
@ -60,9 +77,11 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
|
||||
// intent handling
|
||||
// notification: platform -> dart
|
||||
intentStreamHandler = IntentStreamHandler().apply {
|
||||
EventChannel(messenger, IntentStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
// detail fetch: dart -> platform
|
||||
intentDataMap = extractIntentData(intent)
|
||||
MethodChannel(messenger, VIEWER_CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
|
@ -74,6 +93,11 @@ class MainActivity : FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
// notification: platform -> dart
|
||||
errorStreamHandler = ErrorStreamHandler().apply {
|
||||
EventChannel(messenger, ErrorStreamHandler.CHANNEL).setStreamHandler(this)
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
|
||||
setupShortcuts()
|
||||
}
|
||||
|
@ -93,7 +117,14 @@ class MainActivity : FlutterActivity() {
|
|||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
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
|
||||
if (resultCode != RESULT_OK || treeUri == null) {
|
||||
onPermissionResult(requestCode, null)
|
||||
|
@ -109,17 +140,13 @@ class MainActivity : FlutterActivity() {
|
|||
// resume pending action
|
||||
onPermissionResult(requestCode, treeUri)
|
||||
}
|
||||
DELETE_PERMISSION_REQUEST -> {
|
||||
|
||||
private fun onDeletePermissionResult(resultCode: Int) {
|
||||
// delete permission may be requested on Android 10+ only
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
MediaStoreImageProvider.pendingDeleteCompleter?.complete(resultCode == RESULT_OK)
|
||||
}
|
||||
}
|
||||
CREATE_FILE_REQUEST, OPEN_FILE_REQUEST, SELECT_DIRECTORY_REQUEST -> {
|
||||
onPermissionResult(requestCode, data?.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractIntentData(intent: Intent?): MutableMap<String, Any?> {
|
||||
when (intent?.action) {
|
||||
|
@ -146,6 +173,20 @@ class MainActivity : FlutterActivity() {
|
|||
"mimeType" to intent.type,
|
||||
)
|
||||
}
|
||||
Intent.ACTION_SEARCH -> {
|
||||
val viewUri = intent.dataString
|
||||
return if (viewUri != null) hashMapOf(
|
||||
"action" to "view",
|
||||
"uri" to viewUri,
|
||||
"mimeType" to intent.getStringExtra(SearchManager.EXTRA_DATA_KEY),
|
||||
) else hashMapOf(
|
||||
"action" to "search",
|
||||
"query" to intent.getStringExtra(SearchManager.QUERY),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Log.w(LOG_TAG, "unhandled intent action=${intent?.action}")
|
||||
}
|
||||
}
|
||||
return HashMap()
|
||||
}
|
||||
|
@ -211,6 +252,10 @@ class MainActivity : FlutterActivity() {
|
|||
handler.onDenied()
|
||||
}
|
||||
}
|
||||
|
||||
var errorStreamHandler: ErrorStreamHandler? = null
|
||||
|
||||
fun notifyError(error: String) = errorStreamHandler?.notifyError(error)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,8 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.*
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
|
@ -12,7 +10,7 @@ import com.bumptech.glide.Glide
|
|||
import com.bumptech.glide.load.DecodeFormat
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.BitmapUtils.getBytes
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
|
@ -31,34 +29,13 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getPackages" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getPackages) }
|
||||
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getAppIcon) }
|
||||
"edit" -> {
|
||||
val title = call.argument<String>("title")
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
val mimeType = call.argument<String>("mimeType")
|
||||
result.success(edit(title, uri, mimeType))
|
||||
}
|
||||
"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))
|
||||
}
|
||||
"getAppIcon" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getAppIcon) }
|
||||
"copyToClipboard" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::copyToClipboard) }
|
||||
"edit" -> safe(call, result, ::edit)
|
||||
"open" -> safe(call, result, ::open)
|
||||
"openMap" -> safe(call, result, ::openMap)
|
||||
"setAs" -> safe(call, result, ::setAs)
|
||||
"share" -> safe(call, result, ::share)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
@ -156,59 +133,110 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
|
||||
private fun edit(title: String?, uri: Uri?, mimeType: String?): Boolean {
|
||||
uri ?: return false
|
||||
private fun copyToClipboard(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<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)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
|
||||
.setDataAndType(getShareableUri(uri), mimeType)
|
||||
return safeStartActivityChooser(title, intent)
|
||||
val started = safeStartActivityChooser(title, intent)
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
||||
private fun open(title: String?, uri: Uri?, mimeType: String?): Boolean {
|
||||
uri ?: return false
|
||||
private fun open(call: MethodCall, result: MethodChannel.Result) {
|
||||
val title = call.argument<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)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setDataAndType(getShareableUri(uri), mimeType)
|
||||
return safeStartActivityChooser(title, intent)
|
||||
val started = safeStartActivityChooser(title, intent)
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
||||
private fun openMap(geoUri: Uri?): Boolean {
|
||||
geoUri ?: return false
|
||||
private fun openMap(call: MethodCall, result: MethodChannel.Result) {
|
||||
val geoUri = call.argument<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)
|
||||
return safeStartActivity(intent)
|
||||
val started = safeStartActivity(intent)
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
||||
private fun setAs(title: String?, uri: Uri?, mimeType: String?): Boolean {
|
||||
uri ?: return false
|
||||
private fun setAs(call: MethodCall, result: MethodChannel.Result) {
|
||||
val title = call.argument<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)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setDataAndType(getShareableUri(uri), mimeType)
|
||||
return safeStartActivityChooser(title, intent)
|
||||
val started = safeStartActivityChooser(title, intent)
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
||||
private fun shareSingle(title: String?, uri: Uri, mimeType: String): Boolean {
|
||||
val intent = Intent(Intent.ACTION_SEND)
|
||||
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.setType(mimeType)
|
||||
.putExtra(Intent.EXTRA_STREAM, getShareableUri(uri))
|
||||
return safeStartActivityChooser(title, intent)
|
||||
private fun share(call: MethodCall, result: MethodChannel.Result) {
|
||||
val title = call.argument<String>("title")
|
||||
val urisByMimeType = call.argument<Map<String, List<String>>>("urisByMimeType")
|
||||
if (urisByMimeType == null) {
|
||||
result.error("setAs-args", "failed because of missing arguments", null)
|
||||
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 mimeTypes = urisByMimeType.keys.toTypedArray()
|
||||
|
||||
// simplify share intent for a single item, as some apps can handle one item but not more
|
||||
if (uriList.size == 1) {
|
||||
return shareSingle(title, uriList.first(), mimeTypes.first())
|
||||
}
|
||||
val started = if (uriList.size == 1) {
|
||||
val uri = uriList.first()
|
||||
val mimeType = mimeTypes.first()
|
||||
|
||||
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 = "*/*"
|
||||
if (mimeTypes.size == 1) {
|
||||
// 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)
|
||||
.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uriList)
|
||||
.setType(mimeType)
|
||||
return safeStartActivityChooser(title, intent)
|
||||
safeStartActivityChooser(title, intent)
|
||||
}
|
||||
|
||||
result.success(started)
|
||||
}
|
||||
|
||||
private fun safeStartActivity(intent: Intent): Boolean {
|
||||
|
@ -255,7 +286,7 @@ class AppAdapterHandler(private val context: Context) : MethodCallHandler {
|
|||
return when (uri.scheme?.lowercase(Locale.ROOT)) {
|
||||
ContentResolver.SCHEME_FILE -> {
|
||||
uri.path?.let { path ->
|
||||
val authority = "${context.applicationContext.packageName}.fileprovider"
|
||||
val authority = "${context.applicationContext.packageName}.file_provider"
|
||||
FileProvider.getUriForFile(context, authority, File(path))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ import androidx.core.content.pm.ShortcutManagerCompat
|
|||
import androidx.core.graphics.drawable.IconCompat
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.R
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.utils.BitmapUtils.centerSquareCrop
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
@ -20,23 +21,31 @@ import java.util.*
|
|||
class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"canPin" -> result.success(canPin())
|
||||
"pin" -> {
|
||||
GlobalScope.launch(Dispatchers.IO) { pin(call) }
|
||||
result.success(null)
|
||||
}
|
||||
"canPin" -> safe(call, result, ::canPin)
|
||||
"pin" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::pin) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canPin() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
|
||||
private fun isSupported() = ShortcutManagerCompat.isRequestPinShortcutSupported(context)
|
||||
|
||||
private fun pin(call: MethodCall) {
|
||||
if (!canPin()) return
|
||||
private fun canPin(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(isSupported())
|
||||
}
|
||||
|
||||
val label = call.argument<String>("label") ?: return
|
||||
private fun pin(call: MethodCall, result: MethodChannel.Result) {
|
||||
val label = call.argument<String>("label")
|
||||
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
|
||||
if (iconBytes?.isNotEmpty() == true) {
|
||||
|
@ -62,6 +71,8 @@ class AppShortcutHandler(private val context: Context) : MethodCallHandler {
|
|||
.setIntent(intent)
|
||||
.build()
|
||||
ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
|
||||
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
@ -8,24 +9,42 @@ import kotlinx.coroutines.launch
|
|||
import kotlin.reflect.KSuspendFunction2
|
||||
|
||||
// ensure `result` methods are called on the main looper thread
|
||||
class Coresult internal constructor(private val methodResult: MethodChannel.Result) : MethodChannel.Result {
|
||||
class Coresult internal constructor(private val call: MethodCall, private val methodResult: MethodChannel.Result) : MethodChannel.Result {
|
||||
private val mainScope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
override fun success(result: Any?) {
|
||||
mainScope.launch { methodResult.success(result) }
|
||||
mainScope.launch {
|
||||
try {
|
||||
methodResult.success(result)
|
||||
} catch (e: Exception) {
|
||||
MainActivity.notifyError("failed to reply success for method=${call.method}, result=$result, exception=$e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
mainScope.launch { methodResult.error(errorCode, errorMessage, errorDetails) }
|
||||
mainScope.launch {
|
||||
try {
|
||||
methodResult.error(errorCode, errorMessage, errorDetails)
|
||||
} catch (e: Exception) {
|
||||
MainActivity.notifyError("failed to reply error for method=${call.method}, errorCode=$errorCode, errorMessage=$errorMessage, errorDetails=$errorDetails, exception=$e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
mainScope.launch { methodResult.notImplemented() }
|
||||
mainScope.launch {
|
||||
try {
|
||||
methodResult.notImplemented()
|
||||
} catch (e: Exception) {
|
||||
MainActivity.notifyError("failed to reply notImplemented for method=${call.method}, exception=$e")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun safe(call: MethodCall, result: MethodChannel.Result, function: (call: MethodCall, result: MethodChannel.Result) -> Unit) {
|
||||
val res = Coresult(result)
|
||||
val res = Coresult(call, result)
|
||||
try {
|
||||
function(call, res)
|
||||
} catch (e: Exception) {
|
||||
|
@ -33,12 +52,12 @@ class Coresult internal constructor(private val methodResult: MethodChannel.Resu
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun safesus(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
|
||||
val res = Coresult(result)
|
||||
suspend fun safeSuspend(call: MethodCall, result: MethodChannel.Result, function: KSuspendFunction2<MethodCall, MethodChannel.Result, Unit>) {
|
||||
val res = Coresult(call, result)
|
||||
try {
|
||||
function(call, res)
|
||||
} catch (e: Exception) {
|
||||
res.error("safe-exception", e.message, e.stackTraceToString())
|
||||
res.error("safeSuspend-exception", e.message, e.stackTraceToString())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
|
@ -37,8 +38,15 @@ import java.util.*
|
|||
class DebugHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getContextDirs" -> result.success(getContextDirs())
|
||||
"getEnv" -> result.success(System.getenv())
|
||||
"crash" -> Handler(Looper.getMainLooper()).postDelayed({ throw TestException() }, 50)
|
||||
"exception" -> throw TestException()
|
||||
"safeException" -> safe(call, result) { _, _ -> throw TestException() }
|
||||
"exceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { throw TestException() }
|
||||
"safeExceptionInCoroutine" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result) { _, _ -> throw TestException() } }
|
||||
|
||||
"getContextDirs" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContextDirs) }
|
||||
"getEnv" -> safe(call, result, ::getEnv)
|
||||
|
||||
"getBitmapFactoryInfo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getBitmapFactoryInfo) }
|
||||
"getContentResolverMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getContentResolverMetadata) }
|
||||
"getExifInterfaceMetadata" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getExifInterfaceMetadata) }
|
||||
|
@ -49,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,
|
||||
"filesDir" to context.filesDir,
|
||||
"obbDir" to context.obbDir,
|
||||
|
@ -68,6 +77,13 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}.mapValues { it.value?.path }
|
||||
|
||||
result.success(dirs)
|
||||
}
|
||||
|
||||
private fun getEnv(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(System.getenv())
|
||||
}
|
||||
|
||||
private fun getBitmapFactoryInfo(call: MethodCall, result: MethodChannel.Result) {
|
||||
val uri = call.argument<String>("uri")?.let { Uri.parse(it) }
|
||||
if (uri == null) {
|
||||
|
@ -105,7 +121,7 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
var contentUri: Uri = uri
|
||||
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
|
||||
if (StorageUtils.isMediaStoreContentUri(uri)) {
|
||||
uri.tryParseId()?.let { id ->
|
||||
contentUri = when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
|
@ -317,4 +333,6 @@ class DebugHandler(private val context: Context) : MethodCallHandler {
|
|||
private val LOG_TAG = LogUtils.createTag<DebugHandler>()
|
||||
const val CHANNEL = "deckers.thibault/aves/debug"
|
||||
}
|
||||
|
||||
class TestException internal constructor() : RuntimeException("oops")
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.os.Build
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -8,17 +9,18 @@ import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
|||
class DeviceHandler : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getPerformanceClass" -> result.success(getPerformanceClass())
|
||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getPerformanceClass(): Int {
|
||||
private fun getPerformanceClass(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
// TODO TLAD uncomment when the future is here
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
// return Build.VERSION.MEDIA_PERFORMANCE_CLASS
|
||||
// result.success(Build.VERSION.MEDIA_PERFORMANCE_CLASS)
|
||||
// return
|
||||
// }
|
||||
return Build.VERSION.SDK_INT
|
||||
result.success(Build.VERSION.SDK_INT)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -12,7 +12,7 @@ import com.drew.imaging.ImageMetadataReader
|
|||
import com.drew.metadata.file.FileTypeDirectory
|
||||
import com.drew.metadata.xmp.XmpDirectory
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.metadata.Metadata
|
||||
import deckers.thibault.aves.metadata.MetadataExtractorHelper.getSafeString
|
||||
import deckers.thibault.aves.metadata.MultiPage
|
||||
|
@ -44,7 +44,7 @@ import java.util.*
|
|||
class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getExifThumbnails) }
|
||||
"getExifThumbnails" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getExifThumbnails) }
|
||||
"extractMotionPhotoVideo" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractMotionPhotoVideo) }
|
||||
"extractVideoEmbeddedPicture" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractVideoEmbeddedPicture) }
|
||||
"extractXmpDataProp" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::extractXmpDataProp) }
|
||||
|
@ -193,7 +193,7 @@ class EmbeddedDataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
}
|
||||
}
|
||||
val authority = "${context.applicationContext.packageName}.fileprovider"
|
||||
val authority = "${context.applicationContext.packageName}.file_provider"
|
||||
val uri = if (displayName != null) {
|
||||
// add extension to ease type identification when sharing this content
|
||||
val displayNameWithExtension = if (extension == null || displayName.endsWith(extension, ignoreCase = true)) {
|
||||
|
|
|
@ -2,6 +2,7 @@ package deckers.thibault.aves.channel.calls
|
|||
|
||||
import android.content.Context
|
||||
import android.location.Geocoder
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -18,7 +19,7 @@ class GeocodingHandler(private val context: Context) : MethodCallHandler {
|
|||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getAddress" -> GlobalScope.launch(Dispatchers.IO) { Coresult.safe(call, result, ::getAddress) }
|
||||
"getAddress" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getAddress) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -5,7 +5,7 @@ import android.graphics.Rect
|
|||
import android.net.Uri
|
||||
import com.bumptech.glide.Glide
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safesus
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
|
||||
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
|
||||
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
|
||||
|
@ -32,10 +32,10 @@ class ImageFileHandler(private val activity: Activity) : MethodCallHandler {
|
|||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getEntry" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getEntry) }
|
||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getThumbnail) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::getRegion) }
|
||||
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::captureFrame) }
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { safesus(call, result, ::rename) }
|
||||
"getThumbnail" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getThumbnail) }
|
||||
"getRegion" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::getRegion) }
|
||||
"captureFrame" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::captureFrame) }
|
||||
"rename" -> GlobalScope.launch(Dispatchers.IO) { safeSuspend(call, result, ::rename) }
|
||||
"rotate" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::rotate) }
|
||||
"flip" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::flip) }
|
||||
"clearSizedThumbnailDiskCache" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::clearSizedThumbnailDiskCache) }
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
|
@ -692,7 +691,7 @@ class MetadataHandler(private val context: Context) : MethodCallHandler {
|
|||
}
|
||||
|
||||
var contentUri: Uri = uri
|
||||
if (uri.scheme == ContentResolver.SCHEME_CONTENT && MediaStore.AUTHORITY.equals(uri.host, ignoreCase = true)) {
|
||||
if (StorageUtils.isMediaStoreContentUri(uri)) {
|
||||
uri.tryParseId()?.let { id ->
|
||||
contentUri = when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
|
|
|
@ -23,13 +23,13 @@ import java.util.*
|
|||
class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getStorageVolumes" -> safe(call, result, ::getStorageVolumes)
|
||||
"getFreeSpace" -> safe(call, result, ::getFreeSpace)
|
||||
"getGrantedDirectories" -> safe(call, result, ::getGrantedDirectories)
|
||||
"getInaccessibleDirectories" -> safe(call, result, ::getInaccessibleDirectories)
|
||||
"getRestrictedDirectories" -> safe(call, result, ::getRestrictedDirectories)
|
||||
"getStorageVolumes" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getStorageVolumes) }
|
||||
"getFreeSpace" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getFreeSpace) }
|
||||
"getGrantedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getGrantedDirectories) }
|
||||
"getInaccessibleDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getInaccessibleDirectories) }
|
||||
"getRestrictedDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::getRestrictedDirectories) }
|
||||
"revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess)
|
||||
"deleteEmptyDirectories" -> safe(call, result, ::deleteEmptyDirectories)
|
||||
"deleteEmptyDirectories" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::deleteEmptyDirectories) }
|
||||
"scanFile" -> GlobalScope.launch(Dispatchers.IO) { safe(call, result, ::scanFile) }
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -38,7 +38,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
private fun getStorageVolumes(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
val volumes = ArrayList<Map<String, Any>>()
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
val sm = context.getSystemService(StorageManager::class.java)
|
||||
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
@ -8,11 +9,15 @@ import java.util.*
|
|||
class TimeHandler : MethodCallHandler {
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getDefaultTimeZone" -> result.success(TimeZone.getDefault().id)
|
||||
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getDefaultTimeZone(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(TimeZone.getDefault().id)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/time"
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
|||
"keepScreenOn" -> safe(call, result, ::keepScreenOn)
|
||||
"isRotationLocked" -> safe(call, result, ::isRotationLocked)
|
||||
"requestOrientation" -> safe(call, result, ::requestOrientation)
|
||||
"canSetCutoutMode" -> result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
"canSetCutoutMode" -> safe(call, result, ::canSetCutoutMode)
|
||||
"setCutoutMode" -> safe(call, result, ::setCutoutMode)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
|
@ -60,6 +60,10 @@ class WindowHandler(private val activity: Activity) : MethodCallHandler {
|
|||
result.success(true)
|
||||
}
|
||||
|
||||
private fun canSetCutoutMode(@Suppress("UNUSED_PARAMETER") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||
}
|
||||
|
||||
private fun setCutoutMode(call: MethodCall, result: MethodChannel.Result) {
|
||||
val use = call.argument<Boolean>("use")
|
||||
if (use == null) {
|
||||
|
|
|
@ -24,6 +24,7 @@ import deckers.thibault.aves.utils.MimeTypes.isHeic
|
|||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterContentResolverThumbnail
|
||||
import deckers.thibault.aves.utils.MimeTypes.needRotationAfterGlide
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
|
@ -130,7 +131,7 @@ class ThumbnailFetcher internal constructor(
|
|||
svgFetch -> SvgThumbnail(context, uri)
|
||||
tiffFetch -> TiffImage(context, uri, pageId)
|
||||
multiTrackFetch -> MultiTrackImage(context, uri, pageId)
|
||||
else -> uri
|
||||
else -> StorageUtils.getGlideSafeUri(uri, mimeType)
|
||||
}
|
||||
Glide.with(context)
|
||||
.asBitmap()
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -120,7 +120,7 @@ class ImageByteStreamHandler(private val activity: Activity, private val argumen
|
|||
} else if (mimeType == MimeTypes.TIFF) {
|
||||
TiffImage(activity, uri, pageId)
|
||||
} else {
|
||||
uri
|
||||
StorageUtils.getGlideSafeUri(uri, mimeType)
|
||||
}
|
||||
|
||||
val target = Glide.with(activity)
|
||||
|
|
|
@ -39,7 +39,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
handler = Handler(Looper.getMainLooper())
|
||||
|
||||
when (op) {
|
||||
"requestVolumeAccess" -> requestVolumeAccess()
|
||||
"requestVolumeAccess" -> GlobalScope.launch(Dispatchers.IO) { requestVolumeAccess() }
|
||||
"createFile" -> GlobalScope.launch(Dispatchers.IO) { createFile() }
|
||||
"openFile" -> GlobalScope.launch(Dispatchers.IO) { openFile() }
|
||||
"selectDirectory" -> GlobalScope.launch(Dispatchers.IO) { selectDirectory() }
|
||||
|
@ -83,6 +83,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
putExtra(Intent.EXTRA_TITLE, name)
|
||||
}
|
||||
MainActivity.pendingResultHandlers[MainActivity.CREATE_FILE_REQUEST] = PendingResultHandler(null, { uri ->
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
activity.contentResolver.openOutputStream(uri)?.use { output ->
|
||||
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)
|
||||
}
|
||||
endOfStream()
|
||||
}
|
||||
}, {
|
||||
success(null)
|
||||
endOfStream()
|
||||
|
@ -115,6 +117,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
type = mimeType
|
||||
}
|
||||
MainActivity.pendingResultHandlers[MainActivity.OPEN_FILE_REQUEST] = PendingResultHandler(null, { uri ->
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
activity.contentResolver.openInputStream(uri)?.use { input ->
|
||||
val buffer = ByteArray(BUFFER_SIZE)
|
||||
var len: Int
|
||||
|
@ -123,6 +126,7 @@ class StorageAccessStreamHandler(private val activity: Activity, arguments: Any?
|
|||
}
|
||||
endOfStream()
|
||||
}
|
||||
}
|
||||
}, {
|
||||
success(ByteArray(0))
|
||||
endOfStream()
|
||||
|
|
|
@ -142,7 +142,7 @@ abstract class ImageProvider {
|
|||
} else if (sourceMimeType == MimeTypes.TIFF) {
|
||||
TiffImage(context, sourceUri, pageId)
|
||||
} else {
|
||||
sourceUri
|
||||
StorageUtils.getGlideSafeUri(sourceUri, sourceMimeType)
|
||||
}
|
||||
|
||||
// request a fresh image with the highest quality format
|
||||
|
|
|
@ -2,18 +2,17 @@ package deckers.thibault.aves.model.provider
|
|||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import java.util.*
|
||||
|
||||
object ImageProviderFactory {
|
||||
fun getProvider(uri: Uri): ImageProvider? {
|
||||
return when (uri.scheme?.lowercase(Locale.ROOT)) {
|
||||
ContentResolver.SCHEME_CONTENT -> {
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to Media Store's "authority"
|
||||
return when (uri.host?.lowercase(Locale.ROOT)) {
|
||||
MediaStore.AUTHORITY -> MediaStoreImageProvider()
|
||||
else -> ContentImageProvider()
|
||||
if (StorageUtils.isMediaStoreContentUri(uri)) {
|
||||
MediaStoreImageProvider()
|
||||
} else {
|
||||
ContentImageProvider()
|
||||
}
|
||||
}
|
||||
ContentResolver.SCHEME_FILE -> FileImageProvider()
|
||||
|
|
|
@ -25,7 +25,7 @@ object PermissionManager {
|
|||
|
||||
var intent: Intent? = null
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val sm = activity.getSystemService(StorageManager::class.java)
|
||||
val sm = activity.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
|
||||
intent = sm?.getStorageVolume(File(path))?.createOpenDocumentTreeIntent()
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ package deckers.thibault.aves.utils
|
|||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.MediaMetadataRetriever
|
||||
|
@ -15,7 +16,10 @@ import android.text.TextUtils
|
|||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.utils.MimeTypes.isImage
|
||||
import deckers.thibault.aves.utils.MimeTypes.isVideo
|
||||
import deckers.thibault.aves.utils.PermissionManager.getGrantedDirForPath
|
||||
import deckers.thibault.aves.utils.UriUtils.tryParseId
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.InputStream
|
||||
|
@ -183,8 +187,8 @@ object StorageUtils {
|
|||
// /storage/10F9-3F13/Pictures/ -> 10F9-3F13
|
||||
private fun getVolumeUuidForTreeUri(context: Context, anyPath: String): String? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||
sm.getStorageVolume(File(anyPath))?.let { volume ->
|
||||
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
|
||||
sm?.getStorageVolume(File(anyPath))?.let { volume ->
|
||||
if (volume.isPrimary) {
|
||||
return "primary"
|
||||
}
|
||||
|
@ -193,7 +197,6 @@ object StorageUtils {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fallback for <N
|
||||
getVolumePath(context, anyPath)?.let { volumePath ->
|
||||
|
@ -218,7 +221,8 @@ object StorageUtils {
|
|||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
context.getSystemService(StorageManager::class.java)?.let { sm ->
|
||||
val sm = context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager
|
||||
if (sm != null) {
|
||||
for (volumePath in getVolumePaths(context)) {
|
||||
try {
|
||||
val volume = sm.getStorageVolume(File(volumePath))
|
||||
|
@ -395,7 +399,7 @@ object StorageUtils {
|
|||
return !onPrimaryVolume
|
||||
}
|
||||
|
||||
private fun isMediaStoreContentUri(uri: Uri?): Boolean {
|
||||
fun isMediaStoreContentUri(uri: Uri?): Boolean {
|
||||
uri ?: return false
|
||||
// a URI's authority is [userinfo@]host[:port]
|
||||
// but we only want the host when comparing to Media Store's "authority"
|
||||
|
@ -407,7 +411,7 @@ object StorageUtils {
|
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
val path = uri.path
|
||||
path ?: return uri
|
||||
// from Android R, accessing the original URI for a file media content yields a `SecurityException`
|
||||
// from Android R, accessing the original URI for a `file` or `downloads` media content yields a `SecurityException`
|
||||
if (path.startsWith("/external/images/") || path.startsWith("/external/video/")) {
|
||||
// "Caller must hold ACCESS_MEDIA_LOCATION permission to access original"
|
||||
if (context.checkSelfPermission(Manifest.permission.ACCESS_MEDIA_LOCATION) == PackageManager.PERMISSION_GRANTED) {
|
||||
|
@ -418,6 +422,24 @@ object StorageUtils {
|
|||
return uri
|
||||
}
|
||||
|
||||
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
|
||||
// to work around a bug from Android Q where metadata redaction corrupts HEIC images.
|
||||
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
|
||||
// for some content URIs (e.g. `content://media/external_primary/downloads/...`)
|
||||
// so we build a typical `images` or `videos` content URI from the original content ID.
|
||||
fun getGlideSafeUri(uri: Uri, mimeType: String): Uri {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isMediaStoreContentUri(uri)) {
|
||||
uri.tryParseId()?.let { id ->
|
||||
return when {
|
||||
isImage(mimeType) -> ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
isVideo(mimeType) -> ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
|
||||
else -> uri
|
||||
}
|
||||
}
|
||||
}
|
||||
return uri
|
||||
}
|
||||
|
||||
fun openInputStream(context: Context, uri: Uri): InputStream? {
|
||||
val effectiveUri = getOriginalUri(context, uri)
|
||||
return try {
|
||||
|
|
8
android/app/src/main/res/xml/searchable.xml
Normal file
8
android/app/src/main/res/xml/searchable.xml
Normal 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" />
|
|
@ -1,12 +1,12 @@
|
|||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.5.20'
|
||||
ext.kotlin_version = '1.5.21'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.2.2'
|
||||
classpath 'com.android.tools.build:gradle:7.0.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.google.gms:google-services:4.3.8'
|
||||
classpath 'com.google.firebase:firebase-crashlytics-gradle:2.7.1'
|
||||
|
|
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-all.zip
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -49,23 +50,18 @@ class AppIconImage extends ImageProvider<AppIconImageKey> {
|
|||
}
|
||||
}
|
||||
|
||||
class AppIconImageKey {
|
||||
@immutable
|
||||
class AppIconImageKey extends Equatable {
|
||||
final String packageName;
|
||||
final double size;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [packageName, size, scale];
|
||||
|
||||
const AppIconImageKey({
|
||||
required this.packageName,
|
||||
required this.size,
|
||||
this.scale = 1.0,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is AppIconImageKey && other.packageName == packageName && other.size == size && other.scale == scale;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(packageName, size, scale);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
|||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -62,7 +63,8 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
|
|||
void pause() => imageFileService.cancelRegion(key);
|
||||
}
|
||||
|
||||
class RegionProviderKey {
|
||||
@immutable
|
||||
class RegionProviderKey extends Equatable {
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
final String uri, mimeType;
|
||||
|
@ -72,6 +74,9 @@ class RegionProviderKey {
|
|||
final Rectangle<int> region;
|
||||
final Size imageSize;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, sampleSize, region, imageSize];
|
||||
|
||||
const RegionProviderKey({
|
||||
required this.uri,
|
||||
required this.mimeType,
|
||||
|
@ -83,24 +88,6 @@ class RegionProviderKey {
|
|||
required this.imageSize,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is RegionProviderKey && other.uri == uri && other.mimeType == mimeType && other.pageId == pageId && other.rotationDegrees == rotationDegrees && other.isFlipped == isFlipped && other.sampleSize == sampleSize && other.region == region && other.imageSize == imageSize;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(
|
||||
uri,
|
||||
mimeType,
|
||||
pageId,
|
||||
rotationDegrees,
|
||||
isFlipped,
|
||||
sampleSize,
|
||||
region,
|
||||
imageSize,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, sampleSize=$sampleSize, region=$region, imageSize=$imageSize}';
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:ui' as ui show Codec;
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -63,7 +64,8 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
|
|||
void pause() => imageFileService.cancelThumbnail(key);
|
||||
}
|
||||
|
||||
class ThumbnailProviderKey {
|
||||
@immutable
|
||||
class ThumbnailProviderKey extends Equatable {
|
||||
// do not store the entry as it is, because the key should be constant
|
||||
// but the entry attributes may change over time
|
||||
final String uri, mimeType;
|
||||
|
@ -73,6 +75,9 @@ class ThumbnailProviderKey {
|
|||
final int dateModifiedSecs;
|
||||
final double extent;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [uri, pageId, dateModifiedSecs, extent];
|
||||
|
||||
const ThumbnailProviderKey({
|
||||
required this.uri,
|
||||
required this.mimeType,
|
||||
|
@ -83,20 +88,6 @@ class ThumbnailProviderKey {
|
|||
this.extent = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ThumbnailProviderKey && other.uri == uri && other.pageId == pageId && other.dateModifiedSecs == dateModifiedSecs && other.extent == extent;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(
|
||||
uri,
|
||||
pageId,
|
||||
dateModifiedSecs,
|
||||
extent,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, pageId=$pageId, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, dateModifiedSecs=$dateModifiedSecs, extent=$extent}';
|
||||
}
|
||||
|
|
|
@ -3,15 +3,20 @@ import 'dart:ui' as ui show Codec;
|
|||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class UriImage extends ImageProvider<UriImage> {
|
||||
@immutable
|
||||
class UriImage extends ImageProvider<UriImage> with EquatableMixin {
|
||||
final String uri, mimeType;
|
||||
final int? pageId, rotationDegrees, expectedContentLength;
|
||||
final bool isFlipped;
|
||||
final double scale;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [uri, pageId, rotationDegrees, isFlipped, scale];
|
||||
|
||||
const UriImage({
|
||||
required this.uri,
|
||||
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
|
||||
String toString() => '$runtimeType#${shortHash(this)}{uri=$uri, mimeType=$mimeType, rotationDegrees=$rotationDegrees, isFlipped=$isFlipped, pageId=$pageId, scale=$scale}';
|
||||
}
|
||||
|
|
|
@ -62,6 +62,8 @@
|
|||
"chipActionCreateAlbum": "Create album",
|
||||
"@chipActionCreateAlbum": {},
|
||||
|
||||
"entryActionCopyToClipboard": "Copy to clipboard",
|
||||
"@entryActionCopyToClipboard": {},
|
||||
"entryActionDelete": "Delete",
|
||||
"@entryActionDelete": {},
|
||||
"entryActionExport": "Export",
|
||||
|
@ -319,6 +321,8 @@
|
|||
"@menuActionSort": {},
|
||||
"menuActionGroup": "Group",
|
||||
"@menuActionGroup": {},
|
||||
"menuActionMap": "Map",
|
||||
"@menuActionMap": {},
|
||||
"menuActionStats": "Stats",
|
||||
"@menuActionStats": {},
|
||||
|
||||
|
@ -704,6 +708,9 @@
|
|||
"settingsCoordinateFormatTitle": "Coordinate Format",
|
||||
"@settingsCoordinateFormatTitle": {},
|
||||
|
||||
"mapPageTitle": "Map",
|
||||
"@mapPageTitle": {},
|
||||
|
||||
"statsPageTitle": "Stats",
|
||||
"@statsPageTitle": {},
|
||||
"statsImage": "{count, plural, =1{image} other{images}}",
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
"chipActionSetCover": "대표 이미지 변경",
|
||||
"chipActionCreateAlbum": "앨범 만들기",
|
||||
|
||||
"entryActionCopyToClipboard": "클립보드에 복사",
|
||||
"entryActionDelete": "삭제",
|
||||
"entryActionExport": "내보내기",
|
||||
"entryActionInfo": "상세정보",
|
||||
|
@ -146,6 +147,7 @@
|
|||
|
||||
"menuActionSort": "정렬",
|
||||
"menuActionGroup": "묶음",
|
||||
"menuActionMap": "지도",
|
||||
"menuActionStats": "통계",
|
||||
|
||||
"aboutPageTitle": "앱 정보",
|
||||
|
@ -337,6 +339,8 @@
|
|||
"settingsCoordinateFormatTile": "좌표 표현",
|
||||
"settingsCoordinateFormatTitle": "좌표 표현",
|
||||
|
||||
"mapPageTitle": "지도",
|
||||
|
||||
"statsPageTitle": "통계",
|
||||
"statsImage": "{count, plural, other{사진}}",
|
||||
"statsVideo": "{count, plural, other{동영상}}",
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:isolate';
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/widgets/aves_app.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
void main() {
|
||||
|
@ -20,12 +20,11 @@ void main() {
|
|||
//
|
||||
// flutter run --profile --trace-skia
|
||||
|
||||
Isolate.current.addErrorListener(RawReceivePort((pair) async {
|
||||
initPlatformServices();
|
||||
|
||||
Isolate.current.addErrorListener(RawReceivePort((pair) {
|
||||
final List<dynamic> errorAndStacktrace = pair;
|
||||
await FirebaseCrashlytics.instance.recordError(
|
||||
errorAndStacktrace.first,
|
||||
errorAndStacktrace.last,
|
||||
);
|
||||
reportService.recordError(errorAndStacktrace.first, errorAndStacktrace.last);
|
||||
}).sendPort);
|
||||
|
||||
runApp(const AvesApp());
|
||||
|
|
|
@ -6,6 +6,7 @@ enum ChipSetAction {
|
|||
// general
|
||||
sort,
|
||||
group,
|
||||
map,
|
||||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
|
@ -35,6 +36,8 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return context.l10n.collectionActionSelectAll;
|
||||
case ChipSetAction.selectNone:
|
||||
return context.l10n.collectionActionSelectNone;
|
||||
case ChipSetAction.map:
|
||||
return context.l10n.menuActionMap;
|
||||
case ChipSetAction.stats:
|
||||
return context.l10n.menuActionStats;
|
||||
case ChipSetAction.createAlbum:
|
||||
|
@ -68,6 +71,8 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
case ChipSetAction.selectAll:
|
||||
case ChipSetAction.selectNone:
|
||||
return null;
|
||||
case ChipSetAction.map:
|
||||
return AIcons.map;
|
||||
case ChipSetAction.stats:
|
||||
return AIcons.stats;
|
||||
case ChipSetAction.createAlbum:
|
||||
|
|
|
@ -5,6 +5,7 @@ enum CollectionAction {
|
|||
select,
|
||||
selectAll,
|
||||
selectNone,
|
||||
map,
|
||||
stats,
|
||||
// apply to entry set
|
||||
copy,
|
||||
|
|
|
@ -19,6 +19,7 @@ enum EntryAction {
|
|||
// motion photo,
|
||||
viewMotionPhotoVideo,
|
||||
// external
|
||||
copyToClipboard,
|
||||
edit,
|
||||
open,
|
||||
openMap,
|
||||
|
@ -42,6 +43,7 @@ class EntryActions {
|
|||
EntryAction.delete,
|
||||
EntryAction.rename,
|
||||
EntryAction.export,
|
||||
EntryAction.copyToClipboard,
|
||||
EntryAction.print,
|
||||
EntryAction.viewSource,
|
||||
EntryAction.viewMotionPhotoVideo,
|
||||
|
@ -68,6 +70,8 @@ extension ExtraEntryAction on EntryAction {
|
|||
case EntryAction.toggleFavourite:
|
||||
// different data depending on toggle state
|
||||
return context.l10n.entryActionAddFavourite;
|
||||
case EntryAction.copyToClipboard:
|
||||
return context.l10n.entryActionCopyToClipboard;
|
||||
case EntryAction.delete:
|
||||
return context.l10n.entryActionDelete;
|
||||
case EntryAction.export:
|
||||
|
@ -116,6 +120,8 @@ extension ExtraEntryAction on EntryAction {
|
|||
case EntryAction.toggleFavourite:
|
||||
// different data depending on toggle state
|
||||
return AIcons.favourite;
|
||||
case EntryAction.copyToClipboard:
|
||||
return AIcons.clipboard;
|
||||
case EntryAction.delete:
|
||||
return AIcons.delete;
|
||||
case EntryAction.export:
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:aves/model/filters/album.dart';
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -77,10 +78,13 @@ class Covers with ChangeNotifier {
|
|||
}
|
||||
|
||||
@immutable
|
||||
class CoverRow {
|
||||
class CoverRow extends Equatable {
|
||||
final CollectionFilter filter;
|
||||
final int contentId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [filter, contentId];
|
||||
|
||||
const CoverRow({
|
||||
required this.filter,
|
||||
required this.contentId,
|
||||
|
@ -99,16 +103,4 @@ class CoverRow {
|
|||
'filter': filter.toJson(),
|
||||
'contentId': contentId,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is CoverRow && other.filter == filter && other.contentId == contentId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(filter, contentId);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{filter=$filter, contentId=$contentId}';
|
||||
}
|
||||
|
|
|
@ -13,7 +13,6 @@ import 'package:aves/services/service_policy.dart';
|
|||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/services/svg_metadata_service.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:country_code/country_code.dart';
|
||||
|
@ -382,13 +381,6 @@ class AvesEntry {
|
|||
|
||||
LatLng? get latLng => hasGps ? LatLng(_catalogMetadata!.latitude!, _catalogMetadata!.longitude!) : null;
|
||||
|
||||
String? get geoUri {
|
||||
if (!hasGps) return null;
|
||||
final latitude = roundToPrecision(_catalogMetadata!.latitude!, decimals: 6);
|
||||
final longitude = roundToPrecision(_catalogMetadata!.longitude!, decimals: 6);
|
||||
return 'geo:$latitude,$longitude?q=$latitude,$longitude';
|
||||
}
|
||||
|
||||
List<String>? _xmpSubjects;
|
||||
|
||||
List<String> get xmpSubjects {
|
||||
|
|
|
@ -10,6 +10,8 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension ExtraAvesEntry on AvesEntry {
|
||||
bool isThumbnailReady({double extent = 0}) => _isReady(_getThumbnailProviderKey(extent));
|
||||
|
||||
ThumbnailProvider getThumbnail({double extent = 0}) {
|
||||
return ThumbnailProvider(_getThumbnailProviderKey(extent));
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
final Favourites favourites = Favourites._private();
|
||||
|
@ -62,10 +62,13 @@ class Favourites with ChangeNotifier {
|
|||
}
|
||||
|
||||
@immutable
|
||||
class FavouriteRow {
|
||||
class FavouriteRow extends Equatable {
|
||||
final int contentId;
|
||||
final String path;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [contentId, path];
|
||||
|
||||
const FavouriteRow({
|
||||
required this.contentId,
|
||||
required this.path,
|
||||
|
@ -82,16 +85,4 @@ class FavouriteRow {
|
|||
'contentId': contentId,
|
||||
'path': path,
|
||||
};
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is FavouriteRow && other.contentId == contentId && other.path == path;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(contentId, path);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{contentId=$contentId, path=$path}';
|
||||
}
|
||||
|
|
|
@ -16,6 +16,9 @@ class AlbumFilter extends CollectionFilter {
|
|||
final String album;
|
||||
final String? displayName;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [album];
|
||||
|
||||
const AlbumFilter(this.album, this.displayName);
|
||||
|
||||
AlbumFilter.fromMap(Map<String, dynamic> json)
|
||||
|
@ -78,16 +81,4 @@ class AlbumFilter extends CollectionFilter {
|
|||
// key `album-{path}` is expected by test driver
|
||||
@override
|
||||
String get key => '$type-$album';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is AlbumFilter && other.album == album;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, album);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{album=$album}';
|
||||
}
|
||||
|
|
|
@ -10,6 +10,9 @@ class FavouriteFilter extends CollectionFilter {
|
|||
|
||||
static const instance = FavouriteFilter._private();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
|
||||
const FavouriteFilter._private();
|
||||
|
||||
@override
|
||||
|
@ -37,13 +40,4 @@ class FavouriteFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
String get key => type;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is FavouriteFilter;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => type.hashCode;
|
||||
}
|
||||
|
|
|
@ -11,10 +11,12 @@ import 'package:aves/model/filters/tag.dart';
|
|||
import 'package:aves/model/filters/type.dart';
|
||||
import 'package:aves/utils/color_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
abstract class CollectionFilter implements Comparable<CollectionFilter> {
|
||||
@immutable
|
||||
abstract class CollectionFilter extends Equatable implements Comparable<CollectionFilter> {
|
||||
static const List<String> categoryOrder = [
|
||||
QueryFilter.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 AvesEntry? entry;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [filter, entry?.uri];
|
||||
|
||||
const FilterGridItem(this.filter, this.entry);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is FilterGridItem && other.filter == filter && other.entry == entry;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(filter, entry);
|
||||
}
|
||||
|
||||
typedef EntryFilter = bool Function(AvesEntry);
|
||||
|
|
|
@ -2,7 +2,6 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class LocationFilter extends CollectionFilter {
|
||||
|
@ -10,14 +9,17 @@ class LocationFilter extends CollectionFilter {
|
|||
static const locationSeparator = ';';
|
||||
|
||||
final LocationLevel level;
|
||||
String _location;
|
||||
String? _countryCode;
|
||||
late EntryFilter _test;
|
||||
late final String _location;
|
||||
late final String? _countryCode;
|
||||
late final EntryFilter _test;
|
||||
|
||||
LocationFilter(this.level, this._location) {
|
||||
final split = _location.split(locationSeparator);
|
||||
if (split.isNotEmpty) _location = split[0];
|
||||
if (split.length > 1) _countryCode = split[1];
|
||||
@override
|
||||
List<Object?> get props => [level, _location, _countryCode];
|
||||
|
||||
LocationFilter(this.level, String location) {
|
||||
final split = location.split(locationSeparator);
|
||||
_location = split.isNotEmpty ? split[0] : location;
|
||||
_countryCode = split.length > 1 ? split[1] : null;
|
||||
|
||||
if (_location.isEmpty) {
|
||||
_test = (entry) => !entry.hasGps;
|
||||
|
@ -75,18 +77,6 @@ class LocationFilter extends CollectionFilter {
|
|||
@override
|
||||
String get key => '$type-$level-$_location';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is LocationFilter && other.level == level && other._location == _location;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, level, _location);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{level=$level, location=$_location}';
|
||||
|
||||
// U+0041 Latin Capital letter A
|
||||
// U+1F1E6 🇦 REGIONAL INDICATOR SYMBOL LETTER A
|
||||
static const _countryCodeToFlagDiff = 0x1F1E6 - 0x0041;
|
||||
|
|
|
@ -3,20 +3,22 @@ import 'package:aves/ref/mime_types.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/mime_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class MimeFilter extends CollectionFilter {
|
||||
static const type = 'mime';
|
||||
|
||||
final String mime;
|
||||
late EntryFilter _test;
|
||||
late String _label;
|
||||
late IconData _icon;
|
||||
late final EntryFilter _test;
|
||||
late final String _label;
|
||||
late final IconData _icon;
|
||||
|
||||
static final image = MimeFilter(MimeTypes.anyImage);
|
||||
static final video = MimeFilter(MimeTypes.anyVideo);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [mime];
|
||||
|
||||
MimeFilter(this.mime) {
|
||||
IconData? icon;
|
||||
var lowMime = mime.toLowerCase();
|
||||
|
@ -73,16 +75,4 @@ class MimeFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
String get key => '$type-$mime';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is MimeFilter && other.mime == mime;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, mime);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{mime=$mime}';
|
||||
}
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class PathFilter extends CollectionFilter {
|
||||
static const type = 'path';
|
||||
|
||||
final String path;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [path];
|
||||
|
||||
const PathFilter(this.path);
|
||||
|
||||
PathFilter.fromMap(Map<String, dynamic> json)
|
||||
|
@ -31,16 +32,4 @@ class PathFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
String get key => '$type-$path';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is PathFilter && other.path == path;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, path);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{path=$path}';
|
||||
}
|
||||
|
|
|
@ -12,7 +12,10 @@ class QueryFilter extends CollectionFilter {
|
|||
|
||||
final String query;
|
||||
final bool colorful;
|
||||
late EntryFilter _test;
|
||||
late final EntryFilter _test;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [query];
|
||||
|
||||
QueryFilter(this.query, {this.colorful = true}) {
|
||||
var upQuery = query.toUpperCase();
|
||||
|
@ -63,16 +66,4 @@ class QueryFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
String get key => '$type-$query';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is QueryFilter && other.query == query;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, query);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{query=$query}';
|
||||
}
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TagFilter extends CollectionFilter {
|
||||
static const type = 'tag';
|
||||
|
||||
final String tag;
|
||||
late EntryFilter _test;
|
||||
late final EntryFilter _test;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [tag];
|
||||
|
||||
TagFilter(this.tag) {
|
||||
if (tag.isEmpty) {
|
||||
|
@ -49,16 +51,4 @@ class TagFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
String get key => '$type-$tag';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is TagFilter && other.tag == tag;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, tag);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{tag=$tag}';
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class TypeFilter extends CollectionFilter {
|
||||
|
@ -14,8 +13,8 @@ class TypeFilter extends CollectionFilter {
|
|||
static const _sphericalVideo = 'spherical_video'; // subset of videos
|
||||
|
||||
final String itemType;
|
||||
late EntryFilter _test;
|
||||
late IconData _icon;
|
||||
late final EntryFilter _test;
|
||||
late final IconData _icon;
|
||||
|
||||
static final animated = TypeFilter._private(_animated);
|
||||
static final geotiff = TypeFilter._private(_geotiff);
|
||||
|
@ -23,12 +22,19 @@ class TypeFilter extends CollectionFilter {
|
|||
static final panorama = TypeFilter._private(_panorama);
|
||||
static final sphericalVideo = TypeFilter._private(_sphericalVideo);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [itemType];
|
||||
|
||||
TypeFilter._private(this.itemType) {
|
||||
switch (itemType) {
|
||||
case _animated:
|
||||
_test = (entry) => entry.isAnimated;
|
||||
_icon = AIcons.animated;
|
||||
break;
|
||||
case _geotiff:
|
||||
_test = (entry) => entry.isGeotiff;
|
||||
_icon = AIcons.geo;
|
||||
break;
|
||||
case _motionPhoto:
|
||||
_test = (entry) => entry.isMotionPhoto;
|
||||
_icon = AIcons.motionPhoto;
|
||||
|
@ -41,10 +47,6 @@ class TypeFilter extends CollectionFilter {
|
|||
_test = (entry) => entry.isVideo && entry.is360;
|
||||
_icon = AIcons.threeSixty;
|
||||
break;
|
||||
case _geotiff:
|
||||
_test = (entry) => entry.isGeotiff;
|
||||
_icon = AIcons.geo;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -91,16 +93,4 @@ class TypeFilter extends CollectionFilter {
|
|||
|
||||
@override
|
||||
String get key => '$type-$itemType';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is TypeFilter && other.itemType == itemType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(type, itemType);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{itemType=$itemType}';
|
||||
}
|
||||
|
|
|
@ -30,6 +30,8 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> updateEntryId(int oldId, AvesEntry entry);
|
||||
|
||||
Future<Set<AvesEntry>> searchEntries(String query, {int? limit});
|
||||
|
||||
// date taken
|
||||
|
||||
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
|
||||
|
||||
@override
|
||||
|
@ -284,7 +299,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
await batch.commit(noResult: true);
|
||||
debugPrint('$runtimeType saveMetadata complete in ${stopwatch.elapsed.inMilliseconds}ms for ${metadataEntries.length} entries');
|
||||
} catch (error, stack) {
|
||||
debugPrint('$runtimeType failed to save metadata with exception=$error\n$stack');
|
||||
debugPrint('$runtimeType failed to save metadata with error=$error\n$stack');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,6 @@ import 'package:aves/services/services.dart';
|
|||
import 'package:aves/utils/pedantic.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
@ -120,7 +119,7 @@ class Settings extends ChangeNotifier {
|
|||
// to allow settings customization without Firebase context (e.g. before a Flutter Driver test)
|
||||
Future<void> initFirebase() async {
|
||||
await Firebase.app().setAutomaticDataCollectionEnabled(isCrashlyticsEnabled);
|
||||
await FirebaseCrashlytics.instance.setCrashlyticsCollectionEnabled(isCrashlyticsEnabled);
|
||||
await reportService.setCollectionEnabled(isCrashlyticsEnabled);
|
||||
}
|
||||
|
||||
Future<void> reset({required bool includeInternalKeys}) async {
|
||||
|
|
|
@ -29,7 +29,8 @@ mixin AlbumMixin on SourceBase {
|
|||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||
|
||||
String getAlbumDisplayName(BuildContext? context, String dirPath) {
|
||||
assert(!dirPath.endsWith(pContext.separator));
|
||||
final separator = pContext.separator;
|
||||
assert(!dirPath.endsWith(separator));
|
||||
|
||||
if (context != null) {
|
||||
final type = androidFileUtils.getAlbumType(dirPath);
|
||||
|
@ -52,8 +53,9 @@ mixin AlbumMixin on SourceBase {
|
|||
String unique(String dirPath, Set<String?> others) {
|
||||
final parts = pContext.split(dirPath);
|
||||
for (var i = parts.length - 1; i > 0; i--) {
|
||||
final testName = pContext.joinAll(['', ...parts.skip(i)]);
|
||||
if (others.every((item) => !item!.endsWith(testName))) return testName;
|
||||
final name = pContext.joinAll(['', ...parts.skip(i)]);
|
||||
final testName = '$separator$name';
|
||||
if (others.every((item) => !item!.endsWith(testName))) return name;
|
||||
}
|
||||
return dirPath;
|
||||
}
|
||||
|
|
|
@ -1,41 +1,25 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class SectionKey {
|
||||
const SectionKey();
|
||||
}
|
||||
|
||||
class EntryAlbumSectionKey extends SectionKey {
|
||||
class EntryAlbumSectionKey extends SectionKey with EquatableMixin {
|
||||
final String? directory;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [directory];
|
||||
|
||||
const EntryAlbumSectionKey(this.directory);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is EntryAlbumSectionKey && other.directory == directory;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => directory.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{directory=$directory}';
|
||||
}
|
||||
|
||||
class EntryDateSectionKey extends SectionKey {
|
||||
class EntryDateSectionKey extends SectionKey with EquatableMixin {
|
||||
final DateTime? date;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [date];
|
||||
|
||||
const EntryDateSectionKey(this.date);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is EntryDateSectionKey && other.date == date;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => date.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{date=$date}';
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/math_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class AndroidAppService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/app');
|
||||
|
@ -20,7 +22,7 @@ class AndroidAppService {
|
|||
}
|
||||
return packages;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getPackages failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getPackages', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -33,11 +35,24 @@ class AndroidAppService {
|
|||
});
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getAppIcon failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getAppIcon', e);
|
||||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
||||
static Future<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 {
|
||||
try {
|
||||
final result = await platform.invokeMethod('edit', <String, dynamic>{
|
||||
|
@ -46,7 +61,7 @@ class AndroidAppService {
|
|||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('edit failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('edit', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -59,19 +74,23 @@ class AndroidAppService {
|
|||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('open failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('open', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<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 {
|
||||
final result = await platform.invokeMethod('openMap', <String, dynamic>{
|
||||
'geoUri': geoUri,
|
||||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('openMap failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('openMap', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -84,7 +103,7 @@ class AndroidAppService {
|
|||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('setAs failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('setAs', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -99,7 +118,7 @@ class AndroidAppService {
|
|||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('shareEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('shareEntries', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -113,7 +132,7 @@ class AndroidAppService {
|
|||
});
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('shareSingle failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('shareSingle', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
@ -1,17 +1,57 @@
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class AndroidDebugService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/debug');
|
||||
|
||||
static Future<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 {
|
||||
try {
|
||||
final result = await platform.invokeMethod('getContextDirs');
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContextDirs failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getContextDirs', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -21,7 +61,7 @@ class AndroidDebugService {
|
|||
final result = await platform.invokeMethod('getEnv');
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getEnv failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getEnv', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -34,7 +74,7 @@ class AndroidDebugService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getBitmapFactoryInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getBitmapFactoryInfo', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -48,7 +88,7 @@ class AndroidDebugService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContentResolverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getContentResolverMetadata', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -63,7 +103,7 @@ class AndroidDebugService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getExifInterfaceMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getExifInterfaceMetadata', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -76,7 +116,7 @@ class AndroidDebugService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMediaMetadataRetrieverMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getMediaMetadataRetrieverMetadata', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -91,7 +131,7 @@ class AndroidDebugService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMetadataExtractorSummary failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getMetadataExtractorSummary', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -105,7 +145,7 @@ class AndroidDebugService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getTiffStructure failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getTiffStructure', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -24,7 +24,7 @@ class AppShortcutService {
|
|||
return result;
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('canPin failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('canPin', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -50,7 +50,7 @@ class AppShortcutService {
|
|||
'filters': filters.map((filter) => filter.toJson()).toList(),
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('pin failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('pin', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class DeviceService {
|
||||
static const platform = MethodChannel('deckers.thibault/aves/device');
|
||||
|
@ -11,7 +10,7 @@ class DeviceService {
|
|||
final result = await platform.invokeMethod('getPerformanceClass');
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getPerformanceClass failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getPerformanceClass', e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class EmbeddedDataService {
|
||||
|
@ -27,7 +27,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
});
|
||||
if (result != null) return (result as List).cast<Uint8List>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getExifThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getExifThumbnail', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('extractMotionPhotoVideo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('extractMotionPhotoVideo', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -57,7 +57,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('extractVideoEmbeddedPicture failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('extractVideoEmbeddedPicture', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -75,7 +75,7 @@ class PlatformEmbeddedDataService implements EmbeddedDataService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('extractXmpDataProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('extractXmpDataProp', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class GeocodingService {
|
||||
|
@ -21,7 +21,7 @@ class GeocodingService {
|
|||
});
|
||||
return (result as List).cast<Map>().map((map) => Address.fromMap(map)).toList();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getAddress failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getAddress', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
|
65
lib/services/global_search.dart
Normal file
65
lib/services/global_search.dart
Normal 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;
|
||||
}
|
|
@ -1,14 +1,15 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/image_op_events.dart';
|
||||
import 'package:aves/services/output_buffer.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
abstract class ImageFileService {
|
||||
|
@ -124,7 +125,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
}) as Map;
|
||||
return AvesEntry.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getEntry failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getEntry', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -188,7 +189,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getImage failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
reportService.recordChannelError('getImage', e);
|
||||
}
|
||||
return Future.sync(() => Uint8List(0));
|
||||
}
|
||||
|
@ -223,7 +224,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
});
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getRegion failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getRegion', e);
|
||||
}
|
||||
return Uint8List(0);
|
||||
},
|
||||
|
@ -260,7 +261,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
});
|
||||
if (result != null) return result as Uint8List;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getThumbnail failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getThumbnail', e);
|
||||
}
|
||||
return Uint8List(0);
|
||||
},
|
||||
|
@ -274,7 +275,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
try {
|
||||
return platform.invokeMethod('clearSizedThumbnailDiskCache');
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('clearSizedThumbnailDiskCache failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('clearSizedThumbnailDiskCache', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -295,7 +296,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'entries': entries.map(_toPlatformEntryMap).toList(),
|
||||
}).map((event) => ImageOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('delete failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
reportService.recordChannelError('delete', e);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -314,7 +315,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'destinationPath': destinationAlbum,
|
||||
}).map((event) => MoveOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('move failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
reportService.recordChannelError('move', e);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -333,7 +334,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
'destinationPath': destinationAlbum,
|
||||
}).map((event) => ExportOpEvent.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('export failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
reportService.recordChannelError('export', e);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
@ -356,7 +357,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('captureFrame failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('captureFrame', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -371,7 +372,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('rename failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('rename', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -386,7 +387,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('rotate failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('rotate', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -400,7 +401,7 @@ class PlatformImageFileService implements ImageFileService {
|
|||
});
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('flip failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('flip', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
|
|
@ -1,11 +1,14 @@
|
|||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class ImageOpEvent {
|
||||
class ImageOpEvent extends Equatable {
|
||||
final bool success;
|
||||
final String uri;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [success, uri];
|
||||
|
||||
const ImageOpEvent({
|
||||
required this.success,
|
||||
required this.uri,
|
||||
|
@ -17,18 +20,6 @@ class ImageOpEvent {
|
|||
uri: map['uri'],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ImageOpEvent && other.success == success && other.uri == uri;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(success, uri);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri}';
|
||||
}
|
||||
|
||||
class MoveOpEvent extends ImageOpEvent {
|
||||
|
@ -55,6 +46,9 @@ class MoveOpEvent extends ImageOpEvent {
|
|||
class ExportOpEvent extends MoveOpEvent {
|
||||
final int? pageId;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [success, uri, pageId];
|
||||
|
||||
const ExportOpEvent({required bool success, required String uri, this.pageId, required Map newFields})
|
||||
: super(
|
||||
success: success,
|
||||
|
@ -70,16 +64,4 @@ class ExportOpEvent extends MoveOpEvent {
|
|||
newFields: map['newFields'] ?? {},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ExportOpEvent && other.success == success && other.uri == uri && other.pageId == pageId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(success, uri, pageId);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{success=$success, uri=$uri, pageId=$pageId, newFields=$newFields}';
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:streams_channel/streams_channel.dart';
|
||||
|
||||
abstract class MediaStoreService {
|
||||
|
@ -26,7 +26,7 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
});
|
||||
return (result as List).cast<int>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('checkObsoleteContentIds failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('checkObsoleteContentIds', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -39,7 +39,7 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
});
|
||||
return (result as List).cast<int>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('checkObsoletePaths failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('checkObsoletePaths', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -51,7 +51,7 @@ class PlatformMediaStoreService implements MediaStoreService {
|
|||
'knownEntries': knownEntries,
|
||||
}).map((event) => AvesEntry.fromMap(event));
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getEntries failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
reportService.recordChannelError('getEntries', e);
|
||||
return Stream.error(e);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import 'package:aves/model/metadata.dart';
|
|||
import 'package:aves/model/multipage.dart';
|
||||
import 'package:aves/model/panorama.dart';
|
||||
import 'package:aves/services/service_policy.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class MetadataService {
|
||||
|
@ -36,7 +36,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
});
|
||||
if (result != null) return result as Map;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getAllMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getAllMetadata', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -66,7 +66,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
result['contentId'] = entry.contentId;
|
||||
return CatalogMetadata.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getCatalogMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getCatalogMetadata', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -92,7 +92,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
}) as Map;
|
||||
return OverlayMetadata.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getOverlayMetadata failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getOverlayMetadata', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -114,7 +114,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
}
|
||||
return MultiPageInfo.fromPageMaps(entry, pageMaps);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getMultiPageInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getMultiPageInfo', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -132,7 +132,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
}) as Map;
|
||||
return PanoramaInfo.fromMap(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('PanoramaInfo failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('PanoramaInfo', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -146,7 +146,7 @@ class PlatformMetadataService implements MetadataService {
|
|||
'prop': prop,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getContentResolverProp failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getContentResolverProp', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
55
lib/services/report_service.dart
Normal file
55
lib/services/report_service.dart
Normal 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);
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import 'package:aves/services/embedded_data_service.dart';
|
|||
import 'package:aves/services/image_file_service.dart';
|
||||
import 'package:aves/services/media_store_service.dart';
|
||||
import 'package:aves/services/metadata_service.dart';
|
||||
import 'package:aves/services/report_service.dart';
|
||||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:aves/services/time_service.dart';
|
||||
import 'package:aves/services/window_service.dart';
|
||||
|
@ -20,6 +21,7 @@ final EmbeddedDataService embeddedDataService = getIt<EmbeddedDataService>();
|
|||
final ImageFileService imageFileService = getIt<ImageFileService>();
|
||||
final MediaStoreService mediaStoreService = getIt<MediaStoreService>();
|
||||
final MetadataService metadataService = getIt<MetadataService>();
|
||||
final ReportService reportService = getIt<ReportService>();
|
||||
final StorageService storageService = getIt<StorageService>();
|
||||
final TimeService timeService = getIt<TimeService>();
|
||||
final WindowService windowService = getIt<WindowService>();
|
||||
|
@ -33,6 +35,7 @@ void initPlatformServices() {
|
|||
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
|
||||
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
|
||||
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
|
||||
getIt.registerLazySingleton<ReportService>(() => CrashlyticsReportService());
|
||||
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
|
||||
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
|
||||
getIt.registerLazySingleton<WindowService>(() => PlatformWindowService());
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:aves/services/output_buffer.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -47,7 +48,7 @@ class PlatformStorageService implements StorageService {
|
|||
final result = await platform.invokeMethod('getStorageVolumes');
|
||||
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getStorageVolumes failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getStorageVolumes', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -60,7 +61,7 @@ class PlatformStorageService implements StorageService {
|
|||
});
|
||||
return result as int?;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getFreeSpace failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getFreeSpace', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -71,7 +72,7 @@ class PlatformStorageService implements StorageService {
|
|||
final result = await platform.invokeMethod('getGrantedDirectories');
|
||||
return (result as List).cast<String>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getGrantedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getGrantedDirectories', e);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
@ -83,7 +84,7 @@ class PlatformStorageService implements StorageService {
|
|||
'path': path,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('revokeDirectoryAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('revokeDirectoryAccess', e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -98,7 +99,7 @@ class PlatformStorageService implements StorageService {
|
|||
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getInaccessibleDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getInaccessibleDirectories', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -111,7 +112,7 @@ class PlatformStorageService implements StorageService {
|
|||
return (result as List).cast<Map>().map(VolumeRelativeDirectory.fromMap).toSet();
|
||||
}
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getRestrictedDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('getRestrictedDirectories', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -134,7 +135,7 @@ class PlatformStorageService implements StorageService {
|
|||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('requestVolumeAccess failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('requestVolumeAccess', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -148,7 +149,7 @@ class PlatformStorageService implements StorageService {
|
|||
});
|
||||
if (result != null) return result as int;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('deleteEmptyDirectories failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('deleteEmptyDirectories', e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
@ -164,7 +165,7 @@ class PlatformStorageService implements StorageService {
|
|||
});
|
||||
if (result != null) return Uri.tryParse(result);
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('scanFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('scanFile', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
@ -172,7 +173,7 @@ class PlatformStorageService implements StorageService {
|
|||
@override
|
||||
Future<bool?> createFile(String name, String mimeType, Uint8List bytes) async {
|
||||
try {
|
||||
final completer = Completer<bool>();
|
||||
final completer = Completer<bool?>();
|
||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'createFile',
|
||||
'name': name,
|
||||
|
@ -188,7 +189,7 @@ class PlatformStorageService implements StorageService {
|
|||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('createFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('createFile', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -215,7 +216,7 @@ class PlatformStorageService implements StorageService {
|
|||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('openFile failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('openFile', e);
|
||||
}
|
||||
return Uint8List(0);
|
||||
}
|
||||
|
@ -223,7 +224,7 @@ class PlatformStorageService implements StorageService {
|
|||
@override
|
||||
Future<String?> selectDirectory() async {
|
||||
try {
|
||||
final completer = Completer<String>();
|
||||
final completer = Completer<String?>();
|
||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||
'op': 'selectDirectory',
|
||||
}).listen(
|
||||
|
@ -236,7 +237,7 @@ class PlatformStorageService implements StorageService {
|
|||
);
|
||||
return completer.future;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('selectDirectory failed with code=${e.code}, exception=${e.message}, details=${e.details}}');
|
||||
await reportService.recordChannelError('selectDirectory', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class TimeService {
|
||||
|
@ -13,7 +13,7 @@ class PlatformTimeService implements TimeService {
|
|||
try {
|
||||
return await platform.invokeMethod('getDefaultTimeZone');
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getDefaultTimeZone failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getDefaultTimeZone', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class ViewerService {
|
||||
|
@ -10,7 +10,7 @@ class ViewerService {
|
|||
final result = await platform.invokeMethod('getIntentData');
|
||||
if (result != null) return (result as Map).cast<String, dynamic>();
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('getIntentData failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('getIntentData', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ class ViewerService {
|
|||
'uri': uri,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('pick failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('pick', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
|
@ -24,7 +24,7 @@ class PlatformWindowService implements WindowService {
|
|||
'on': on,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('keepScreenOn failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('keepScreenOn', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -34,7 +34,7 @@ class PlatformWindowService implements WindowService {
|
|||
final result = await platform.invokeMethod('isRotationLocked');
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('isRotationLocked failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('isRotationLocked', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ class PlatformWindowService implements WindowService {
|
|||
'orientation': orientationCode,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('requestOrientation failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('requestOrientation', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -72,7 +72,7 @@ class PlatformWindowService implements WindowService {
|
|||
final result = await platform.invokeMethod('canSetCutoutMode');
|
||||
if (result != null) return result as bool;
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('canSetCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('canSetCutoutMode', e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
@ -84,7 +84,7 @@ class PlatformWindowService implements WindowService {
|
|||
'use': use,
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
debugPrint('setCutoutMode failed with code=${e.code}, exception=${e.message}, details=${e.details}');
|
||||
await reportService.recordChannelError('setCutoutMode', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ import 'package:flutter/scheduler.dart';
|
|||
class Durations {
|
||||
// Flutter animations (with margin)
|
||||
static const popupMenuAnimation = Duration(milliseconds: 300 + 10); // ref `_kMenuDuration` used in `_PopupMenuRoute`
|
||||
// page transition duration also available via `ModalRoute.of(context)!.transitionDuration * timeDilation`
|
||||
static const pageTransitionAnimation = Duration(milliseconds: 300 + 10); // ref `transitionDuration` used in `MaterialRouteTransitionMixin`
|
||||
static const dialogTransitionAnimation = Duration(milliseconds: 150 + 10); // ref `transitionDuration` used in `DialogRoute`
|
||||
static const drawerTransitionAnimation = Duration(milliseconds: 246 + 10); // ref `_kBaseSettleDuration` used in `DrawerControllerState`
|
||||
static const toggleableTransitionAnimation = Duration(milliseconds: 200 + 10); // ref `_kToggleDuration` used in `ToggleableStateMixin`
|
||||
|
|
|
@ -36,6 +36,7 @@ class AIcons {
|
|||
static const IconData skip10 = Icons.forward_10_outlined;
|
||||
static const IconData captureFrame = Icons.screenshot_outlined;
|
||||
static const IconData clear = Icons.clear_outlined;
|
||||
static const IconData clipboard = Icons.content_copy_outlined;
|
||||
static const IconData createAlbum = Icons.add_circle_outline;
|
||||
static const IconData debug = Icons.whatshot_outlined;
|
||||
static const IconData delete = Icons.delete_outlined;
|
||||
|
@ -49,6 +50,7 @@ class AIcons {
|
|||
static const IconData import = MdiIcons.fileImportOutline;
|
||||
static const IconData info = Icons.info_outlined;
|
||||
static const IconData layers = Icons.layers_outlined;
|
||||
static const IconData map = Icons.map_outlined;
|
||||
static const IconData newTier = Icons.fiber_new_outlined;
|
||||
static const IconData openOutside = Icons.open_in_new_outlined;
|
||||
static const IconData pin = Icons.push_pin_outlined;
|
||||
|
|
|
@ -3,13 +3,14 @@ import 'package:aves/services/services.dart';
|
|||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||
|
||||
class AndroidFileUtils {
|
||||
late String primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath;
|
||||
late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, videoCapturesPath;
|
||||
Set<StorageVolume> storageVolumes = {};
|
||||
Set<Package> _packages = {};
|
||||
List<String> _potentialAppDirs = [];
|
||||
|
@ -21,9 +22,10 @@ class AndroidFileUtils {
|
|||
AndroidFileUtils._private();
|
||||
|
||||
Future<void> init() async {
|
||||
separator = pContext.separator;
|
||||
storageVolumes = await storageService.getStorageVolumes();
|
||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? '/';
|
||||
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator;
|
||||
// standard
|
||||
dcimPath = pContext.join(primaryStorage, 'DCIM');
|
||||
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||
|
@ -38,11 +40,11 @@ class AndroidFileUtils {
|
|||
appNameChangeNotifier.notifyListeners();
|
||||
}
|
||||
|
||||
bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('Camera') || path.endsWith('100ANDRO'));
|
||||
bool isCameraPath(String path) => path.startsWith(dcimPath) && (path.endsWith('${separator}Camera') || path.endsWith('${separator}100ANDRO'));
|
||||
|
||||
bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('Screenshots');
|
||||
bool isScreenshotsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(picturesPath)) && path.endsWith('${separator}Screenshots');
|
||||
|
||||
bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('Screen recordings') || path.endsWith('ScreenRecords'));
|
||||
bool isScreenRecordingsPath(String path) => (path.startsWith(dcimPath) || path.startsWith(moviesPath)) && (path.endsWith('${separator}Screen recordings') || path.endsWith('${separator}ScreenRecords'));
|
||||
|
||||
bool isVideoCapturesPath(String path) => path == videoCapturesPath;
|
||||
|
||||
|
@ -53,7 +55,7 @@ class AndroidFileUtils {
|
|||
final volume = storageVolumes.firstWhereOrNull((v) => path.startsWith(v.path));
|
||||
// storage volume path includes trailing '/', but argument path may or may not,
|
||||
// which is an issue when the path is at the root
|
||||
return volume != null || path.endsWith('/') ? volume : getStorageVolume('$path/');
|
||||
return volume != null || path.endsWith(separator) ? volume : getStorageVolume('$path$separator');
|
||||
}
|
||||
|
||||
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
|
||||
|
@ -155,9 +157,12 @@ class StorageVolume {
|
|||
}
|
||||
|
||||
@immutable
|
||||
class VolumeRelativeDirectory {
|
||||
class VolumeRelativeDirectory extends Equatable {
|
||||
final String volumePath, relativeDir;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [volumePath, relativeDir];
|
||||
|
||||
const VolumeRelativeDirectory({
|
||||
required this.volumePath,
|
||||
required this.relativeDir,
|
||||
|
@ -187,13 +192,4 @@ class VolumeRelativeDirectory {
|
|||
final volume = androidFileUtils.storageVolumes.firstWhereOrNull((volume) => volume.path == volumePath);
|
||||
return volume?.getDescription(context) ?? volumePath;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is VolumeRelativeDirectory && other.volumePath == volumePath && other.relativeDir == relativeDir;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(volumePath, relativeDir);
|
||||
}
|
||||
|
|
|
@ -44,7 +44,6 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'AndroidSVG',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/BigBadaboom/androidsvg/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/BigBadaboom/androidsvg',
|
||||
),
|
||||
Dependency(
|
||||
|
@ -56,19 +55,16 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'CWAC-Document',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/commonsguy/cwac-document/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/commonsguy/cwac-document',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Glide',
|
||||
license: 'Apache 2.0, BSD 2-Clause',
|
||||
licenseUrl: 'https://github.com/bumptech/glide/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/bumptech/glide',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Metadata Extractor',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/drewnoakes/metadata-extractor/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/drewnoakes/metadata-extractor',
|
||||
),
|
||||
];
|
||||
|
@ -77,49 +73,44 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'Connectivity Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/LICENSE',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/connectivity_plus/connectivity_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/connectivity_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'FlutterFire (Core, Crashlytics)',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/FirebaseExtended/flutterfire/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/FirebaseExtended/flutterfire',
|
||||
),
|
||||
Dependency(
|
||||
name: 'fijkplayer (Aves fork)',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/deckerst/fijkplayer/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/deckerst/fijkplayer',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Google API Availability',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/Baseflow/flutter-google-api-availability/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/Baseflow/flutter-google-api-availability',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Google Maps for Flutter',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Package Info Plus',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus/LICENSE',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/plus_plugins/blob/main/packages/package_info_plus/package_info_plus/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/plus_plugins/tree/main/packages/package_info_plus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Permission Handler',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/Baseflow/flutter-permission-handler/blob/develop/permission_handler/LICENSE',
|
||||
sourceUrl: 'https://github.com/Baseflow/flutter-permission-handler',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Printing',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
||||
),
|
||||
Dependency(
|
||||
|
@ -130,21 +121,19 @@ class Constants {
|
|||
),
|
||||
Dependency(
|
||||
name: 'sqflite',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/tekartik/sqflite/blob/master/sqflite/LICENSE',
|
||||
license: 'BSD 2-Clause',
|
||||
sourceUrl: 'https://github.com/tekartik/sqflite',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Streams Channel (Aves fork)',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/deckerst/aves_streams_channel/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/deckerst/aves_streams_channel',
|
||||
),
|
||||
Dependency(
|
||||
name: 'URL Launcher',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher',
|
||||
),
|
||||
];
|
||||
|
||||
|
@ -152,37 +141,36 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'Charts',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/google/charts/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/google/charts',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Custom rounded rectangle border',
|
||||
license: 'MIT',
|
||||
sourceUrl: 'https://github.com/lekanbar/custom_rounded_rectangle_border',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Decorated Icon',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/benPesso/flutter_decorated_icon/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/benPesso/flutter_decorated_icon',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Expansion Tile Card (Aves fork)',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/deckerst/expansion_tile_card/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/deckerst/expansion_tile_card',
|
||||
),
|
||||
Dependency(
|
||||
name: 'FlexColorPicker',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/rydmike/flex_color_picker/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/rydmike/flex_color_picker',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Highlight',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/git-touch/highlight/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/git-touch/highlight',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Map',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/fleaflet/flutter_map/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/fleaflet/flutter_map',
|
||||
),
|
||||
Dependency(
|
||||
|
@ -194,19 +182,16 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'Flutter Staggered Animations',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/mobiten/flutter_staggered_animations/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/mobiten/flutter_staggered_animations',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Material Design Icons Flutter',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/ziofat/material_design_icons_flutter/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/ziofat/material_design_icons_flutter',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Overlay Support',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/boyan01/overlay_support/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/boyan01/overlay_support',
|
||||
),
|
||||
Dependency(
|
||||
|
@ -218,19 +203,16 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'Panorama',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/zesage/panorama/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/zesage/panorama',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Percent Indicator',
|
||||
license: 'BSD 2-Clause',
|
||||
licenseUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator/',
|
||||
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Provider',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/rrousselGit/provider/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/rrousselGit/provider',
|
||||
),
|
||||
];
|
||||
|
@ -239,21 +221,28 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'Collection',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/dart-lang/collection/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/dart-lang/collection',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Country Code',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/denixport/dart.country/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/denixport/dart.country',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Equatable',
|
||||
license: 'MIT',
|
||||
sourceUrl: 'https://github.com/felangel/equatable',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Event Bus',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/marcojakob/dart-event-bus/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/marcojakob/dart-event-bus',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Fluster',
|
||||
license: 'MIT',
|
||||
sourceUrl: 'https://github.com/alfonsocejudo/fluster',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Lints',
|
||||
license: 'BSD 3-Clause',
|
||||
|
@ -263,49 +252,41 @@ class Constants {
|
|||
Dependency(
|
||||
name: 'Get It',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/fluttercommunity/get_it/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/fluttercommunity/get_it',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Github',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/SpinlockLabs/github.dart/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/SpinlockLabs/github.dart',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Intl',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/dart-lang/intl/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/dart-lang/intl',
|
||||
),
|
||||
Dependency(
|
||||
name: 'LatLong2',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/jifalops/dart-latlong/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/jifalops/dart-latlong',
|
||||
),
|
||||
Dependency(
|
||||
name: 'PDF for Dart and Flutter',
|
||||
license: 'Apache 2.0',
|
||||
licenseUrl: 'https://github.com/DavBfr/dart_pdf/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/DavBfr/dart_pdf',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Tuple',
|
||||
license: 'BSD 2-Clause',
|
||||
licenseUrl: 'https://github.com/dart-lang/tuple/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/dart-lang/tuple',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Version',
|
||||
license: 'BSD 3-Clause',
|
||||
licenseUrl: 'https://github.com/dartninja/version/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/dartninja/version',
|
||||
),
|
||||
Dependency(
|
||||
name: 'XML',
|
||||
license: 'MIT',
|
||||
licenseUrl: 'https://github.com/renggli/dart-xml/blob/master/LICENSE',
|
||||
sourceUrl: 'https://github.com/renggli/dart-xml',
|
||||
),
|
||||
];
|
||||
|
@ -320,7 +301,7 @@ class Dependency {
|
|||
const Dependency({
|
||||
required this.name,
|
||||
required this.license,
|
||||
required this.licenseUrl,
|
||||
String? licenseUrl,
|
||||
required this.sourceUrl,
|
||||
});
|
||||
}) : licenseUrl = licenseUrl ?? '$sourceUrl/blob/master/LICENSE';
|
||||
}
|
||||
|
|
|
@ -15,8 +15,8 @@ import 'package:aves/widgets/common/extensions/build_context.dart';
|
|||
import 'package:aves/widgets/common/providers/highlight_info_provider.dart';
|
||||
import 'package:aves/widgets/home_page.dart';
|
||||
import 'package:aves/widgets/welcome_page.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
@ -43,6 +43,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
List<NavigatorObserver> _navigatorObservers = [];
|
||||
final EventChannel _mediaStoreChangeChannel = const EventChannel('deckers.thibault/aves/mediastorechange');
|
||||
final EventChannel _newIntentChannel = const EventChannel('deckers.thibault/aves/intent');
|
||||
final EventChannel _errorChannel = const EventChannel('deckers.thibault/aves/error');
|
||||
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
|
||||
Widget getFirstPage({Map? intentData}) => settings.hasAcceptedTerms ? HomePage(intentData: intentData) : const WelcomePage();
|
||||
|
@ -50,10 +51,11 @@ class _AvesAppState extends State<AvesApp> {
|
|||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initPlatformServices();
|
||||
EquatableConfig.stringify = true;
|
||||
_appSetup = _setup();
|
||||
_mediaStoreChangeChannel.receiveBroadcastStream().listen((event) => _onMediaStoreChange(event as String?));
|
||||
_newIntentChannel.receiveBroadcastStream().listen((event) => _onNewIntent(event as Map?));
|
||||
_errorChannel.receiveBroadcastStream().listen((event) => _onError(event as String?));
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -122,18 +124,17 @@ class _AvesAppState extends State<AvesApp> {
|
|||
|
||||
Future<void> _setup() async {
|
||||
await Firebase.initializeApp().then((app) {
|
||||
final crashlytics = FirebaseCrashlytics.instance;
|
||||
FlutterError.onError = crashlytics.recordFlutterError;
|
||||
crashlytics.setCustomKey('locales', window.locales.join(', '));
|
||||
FlutterError.onError = reportService.recordFlutterError;
|
||||
final now = DateTime.now();
|
||||
crashlytics.setCustomKey('timezone', '${now.timeZoneName} (${now.timeZoneOffset})');
|
||||
crashlytics.setCustomKey(
|
||||
'build_mode',
|
||||
kReleaseMode
|
||||
reportService.setCustomKeys({
|
||||
'locales': window.locales.join(', '),
|
||||
'time_zone': '${now.timeZoneName} (${now.timeZoneOffset})',
|
||||
'build_mode': kReleaseMode
|
||||
? 'release'
|
||||
: kProfileMode
|
||||
? 'profile'
|
||||
: 'debug');
|
||||
: 'debug',
|
||||
});
|
||||
});
|
||||
await settings.init();
|
||||
await settings.initFirebase();
|
||||
|
@ -148,7 +149,7 @@ class _AvesAppState extends State<AvesApp> {
|
|||
// do not reset when relaunching the app
|
||||
if (appModeNotifier.value == AppMode.main && (intentData == null || intentData.isEmpty == true)) return;
|
||||
|
||||
FirebaseCrashlytics.instance.log('New intent');
|
||||
reportService.log('New intent');
|
||||
_navigatorKey.currentState!.pushReplacement(DirectMaterialPageRoute(
|
||||
settings: const RouteSettings(name: HomePage.routeName),
|
||||
builder: (_) => getFirstPage(intentData: intentData),
|
||||
|
@ -169,4 +170,6 @@ class _AvesAppState extends State<AvesApp> {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onError(String? error) => reportService.recordError(error, null);
|
||||
}
|
||||
|
|
|
@ -22,9 +22,10 @@ import 'package:aves/widgets/common/basic/menu_row.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:aves/widgets/map/map_page.dart';
|
||||
import 'package:aves/widgets/search/search_button.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:aves/widgets/stats/stats.dart';
|
||||
import 'package:aves/widgets/stats/stats_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
@ -212,6 +213,11 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.collectionActionSelect, icon: AIcons.select),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.map,
|
||||
enabled: isNotEmpty,
|
||||
child: MenuRow(text: context.l10n.menuActionMap, icon: AIcons.map),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: CollectionAction.stats,
|
||||
enabled: isNotEmpty,
|
||||
|
@ -292,6 +298,9 @@ class _CollectionAppBarState extends State<CollectionAppBar> with SingleTickerPr
|
|||
case CollectionAction.selectNone:
|
||||
context.read<Selection<AvesEntry>>().clearSelection();
|
||||
break;
|
||||
case CollectionAction.map:
|
||||
_goToMap();
|
||||
break;
|
||||
case CollectionAction.stats:
|
||||
_goToStats();
|
||||
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() {
|
||||
Navigator.push(
|
||||
context,
|
||||
|
|
|
@ -19,6 +19,7 @@ import 'package:provider/provider.dart';
|
|||
class ThumbnailImage extends StatefulWidget {
|
||||
final AvesEntry entry;
|
||||
final double extent;
|
||||
final bool progressive;
|
||||
final BoxFit? fit;
|
||||
final bool showLoadingBackground;
|
||||
final ValueNotifier<bool>? cancellableNotifier;
|
||||
|
@ -28,6 +29,7 @@ class ThumbnailImage extends StatefulWidget {
|
|||
Key? key,
|
||||
required this.entry,
|
||||
required this.extent,
|
||||
this.progressive = true,
|
||||
this.fit,
|
||||
this.showLoadingBackground = true,
|
||||
this.cancellableNotifier,
|
||||
|
@ -93,7 +95,7 @@ class _ThumbnailImageState extends State<ThumbnailImage> {
|
|||
_lastException = null;
|
||||
_providers.clear();
|
||||
_providers.addAll([
|
||||
if (!entry.isSvg)
|
||||
if (widget.progressive && !entry.isSvg)
|
||||
_ConditionalImageProvider(
|
||||
ScrollAwareImageProvider(
|
||||
context: _scrollAwareContext,
|
||||
|
|
|
@ -72,6 +72,9 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
|||
|
||||
Stream<T> get opStream => widget.opStream;
|
||||
|
||||
static const radius = 160.0;
|
||||
static const strokeWidth = 16.0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
@ -104,6 +107,7 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progressColor = Theme.of(context).accentColor;
|
||||
return AbsorbPointer(
|
||||
child: StreamBuilder<T>(
|
||||
stream: opStream,
|
||||
|
@ -124,20 +128,34 @@ class _ReportOverlayState<T> extends State<ReportOverlay<T>> with SingleTickerPr
|
|||
),
|
||||
),
|
||||
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,
|
||||
lineWidth: 16,
|
||||
radius: 160,
|
||||
lineWidth: strokeWidth,
|
||||
radius: radius,
|
||||
backgroundColor: Colors.white24,
|
||||
progressColor: Theme.of(context).accentColor,
|
||||
progressColor: progressColor,
|
||||
animation: true,
|
||||
center: Text(NumberFormat.percentPattern().format(percent)),
|
||||
animateFromLastPercent: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:aves/services/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class CrashlyticsRouteTracker extends NavigatorObserver {
|
||||
@override
|
||||
void didPush(Route<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
|
||||
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
|
||||
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
|
||||
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}';
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:math';
|
|||
import 'package:aves/model/source/section_keys.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
@ -221,13 +222,17 @@ class SectionedListLayout<T> {
|
|||
String toString() => '$runtimeType#${shortHash(this)}{sectionCount=${sections.length} columnCount=$columnCount, tileExtent=$tileExtent}';
|
||||
}
|
||||
|
||||
class SectionLayout {
|
||||
@immutable
|
||||
class SectionLayout extends Equatable {
|
||||
final SectionKey sectionKey;
|
||||
final int firstIndex, lastIndex, bodyFirstIndex;
|
||||
final double minOffset, maxOffset, bodyMinOffset;
|
||||
final double headerExtent, tileExtent, spacing, mainAxisStride;
|
||||
final IndexedWidgetBuilder builder;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing];
|
||||
|
||||
const SectionLayout({
|
||||
required this.sectionKey,
|
||||
required this.firstIndex,
|
||||
|
@ -263,15 +268,6 @@ class SectionLayout {
|
|||
if (scrollOffset < 0) return firstIndex;
|
||||
return bodyFirstIndex + (scrollOffset / mainAxisStride).ceil() - 1;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SectionLayout && runtimeType == other.runtimeType && sectionKey == other.sectionKey && firstIndex == other.firstIndex && lastIndex == other.lastIndex && minOffset == other.minOffset && maxOffset == other.maxOffset && headerExtent == other.headerExtent && tileExtent == other.tileExtent && spacing == other.spacing;
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(sectionKey, firstIndex, lastIndex, minOffset, maxOffset, headerExtent, tileExtent, spacing);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{sectionKey=$sectionKey, firstIndex=$firstIndex, lastIndex=$lastIndex, minOffset=$minOffset, maxOffset=$maxOffset, headerExtent=$headerExtent, tileExtent=$tileExtent, spacing=$spacing}';
|
||||
}
|
||||
|
||||
class _GridRow extends MultiChildRenderObjectWidget {
|
||||
|
|
|
@ -1,28 +1,22 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class MagnifierState {
|
||||
const MagnifierState({
|
||||
required this.position,
|
||||
required this.scale,
|
||||
required this.source,
|
||||
});
|
||||
|
||||
class MagnifierState extends Equatable {
|
||||
final Offset position;
|
||||
final double? scale;
|
||||
final ChangeSource source;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is MagnifierState && runtimeType == other.runtimeType && position == other.position && scale == other.scale;
|
||||
List<Object?> get props => [position, scale, source];
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(position, scale, source);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{position: $position, scale: $scale, source: $source}';
|
||||
const MagnifierState({
|
||||
required this.position,
|
||||
required this.scale,
|
||||
required this.source,
|
||||
});
|
||||
}
|
||||
|
||||
enum ChangeSource { internal, gesture, animation }
|
||||
|
|
|
@ -6,6 +6,7 @@ import 'package:aves/widgets/common/magnifier/magnifier.dart';
|
|||
import 'package:aves/widgets/common/magnifier/pan/corner_hit_detector.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_boundaries.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/state.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Internal widget in which controls all animations lifecycle, core responses
|
||||
|
@ -276,17 +277,21 @@ class _MagnifierCoreState extends State<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(
|
||||
this.subjectSize,
|
||||
this.basePosition,
|
||||
this.applyScale,
|
||||
);
|
||||
|
||||
final Size subjectSize;
|
||||
final Alignment basePosition;
|
||||
final bool applyScale;
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final childWidth = applyScale ? subjectSize.width : childSize.width;
|
||||
|
@ -309,10 +314,4 @@ class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
|
|||
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
|
||||
return oldDelegate != this;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is _CenterWithOriginalSizeDelegate && runtimeType == other.runtimeType && subjectSize == other.subjectSize && basePosition == other.basePosition && applyScale == other.applyScale;
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(subjectSize, basePosition, applyScale);
|
||||
}
|
||||
|
|
|
@ -2,17 +2,22 @@ import 'dart:ui';
|
|||
|
||||
import 'package:aves/widgets/common/magnifier/controller/controller.dart';
|
||||
import 'package:aves/widgets/common/magnifier/scale/scale_level.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Internal class to wrap custom scale boundaries (min, max and initial)
|
||||
/// Also, stores values regarding the two sizes: the container and the child.
|
||||
class ScaleBoundaries {
|
||||
@immutable
|
||||
class ScaleBoundaries extends Equatable {
|
||||
final ScaleLevel _minScale;
|
||||
final ScaleLevel _maxScale;
|
||||
final ScaleLevel _initialScale;
|
||||
final Size viewportSize;
|
||||
final Size childSize;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [_minScale, _maxScale, _initialScale, viewportSize, childSize];
|
||||
|
||||
const ScaleBoundaries({
|
||||
required ScaleLevel minScale,
|
||||
required ScaleLevel maxScale,
|
||||
|
@ -57,13 +62,4 @@ class ScaleBoundaries {
|
|||
Offset childToStatePosition(double scale, Offset childPosition) {
|
||||
return (_childCenter - childPosition) * scale;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ScaleBoundaries && runtimeType == other.runtimeType && _minScale == other._minScale && _maxScale == other._maxScale && _initialScale == other._initialScale && viewportSize == other.viewportSize && childSize == other.childSize;
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(_minScale, _maxScale, _initialScale, viewportSize, childSize);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{viewportSize=$viewportSize, childSize=$childSize, initialScale=$initialScale, minScale=$minScale, maxScale=$maxScale}';
|
||||
}
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
|
||||
class ScaleLevel {
|
||||
@immutable
|
||||
class ScaleLevel extends Equatable {
|
||||
final ScaleReference ref;
|
||||
final double factor;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [ref, factor];
|
||||
|
||||
const ScaleLevel({
|
||||
this.ref = ScaleReference.absolute,
|
||||
this.factor = 1.0,
|
||||
|
@ -15,18 +20,6 @@ class ScaleLevel {
|
|||
static double scaleForContained(Size containerSize, Size childSize) => min(containerSize.width / childSize.width, containerSize.height / childSize.height);
|
||||
|
||||
static double scaleForCovering(Size containerSize, Size childSize) => max(containerSize.width / childSize.width, containerSize.height / childSize.height);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{ref=$ref, factor=$factor}';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is ScaleLevel && other.ref == ref && other.factor == factor;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(ref, factor);
|
||||
}
|
||||
|
||||
enum ScaleReference { absolute, contained, covered }
|
||||
|
|
|
@ -1,29 +1,24 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:aves/widgets/common/magnifier/controller/state.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
@immutable
|
||||
class ScaleStateChange {
|
||||
const ScaleStateChange({
|
||||
required this.state,
|
||||
required this.source,
|
||||
this.childFocalPoint,
|
||||
});
|
||||
|
||||
class ScaleStateChange extends Equatable {
|
||||
final ScaleState state;
|
||||
final ChangeSource source;
|
||||
final Offset? childFocalPoint;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ScaleStateChange && runtimeType == other.runtimeType && state == other.state && childFocalPoint == other.childFocalPoint;
|
||||
List<Object?> get props => [state, source, childFocalPoint];
|
||||
|
||||
@override
|
||||
int get hashCode => hashValues(state, source, childFocalPoint);
|
||||
|
||||
@override
|
||||
String toString() => '$runtimeType#${shortHash(this)}{scaleState: $state, source: $source, childFocalPoint: $childFocalPoint}';
|
||||
const ScaleStateChange({
|
||||
required this.state,
|
||||
required this.source,
|
||||
this.childFocalPoint,
|
||||
});
|
||||
}
|
||||
|
||||
enum ScaleState {
|
||||
|
|
47
lib/widgets/common/map/attribution.dart
Normal file
47
lib/widgets/common/map/attribution.dart
Normal 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);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -13,61 +13,18 @@ import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
|||
import 'package:aves/widgets/viewer/overlay/common.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class MapDecorator extends StatelessWidget {
|
||||
final Widget? child;
|
||||
|
||||
static const mapBorderRadius = BorderRadius.all(Radius.circular(24)); // to match button circles
|
||||
static const mapBackground = Color(0xFFDBD5D3);
|
||||
static const mapLoadingGrid = Color(0xFFC4BEBB);
|
||||
|
||||
const MapDecorator({
|
||||
Key? key,
|
||||
this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onScaleStart: (details) {
|
||||
// absorb scale gesture here to prevent scrolling
|
||||
// and triggering by mistake a move to the image page above
|
||||
},
|
||||
child: ClipRRect(
|
||||
borderRadius: mapBorderRadius,
|
||||
child: Container(
|
||||
color: mapBackground,
|
||||
height: 200,
|
||||
child: Stack(
|
||||
children: [
|
||||
const GridPaper(
|
||||
color: mapLoadingGrid,
|
||||
interval: 10,
|
||||
divisions: 1,
|
||||
subdivisions: 1,
|
||||
child: CustomPaint(
|
||||
size: Size.infinite,
|
||||
),
|
||||
),
|
||||
if (child != null) child!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MapButtonPanel extends StatelessWidget {
|
||||
final String geoUri;
|
||||
final void Function(double amount) zoomBy;
|
||||
final LatLng latLng;
|
||||
final Future<void> Function(double amount)? zoomBy;
|
||||
|
||||
static const double padding = 4;
|
||||
|
||||
const MapButtonPanel({
|
||||
Key? key,
|
||||
required this.geoUri,
|
||||
required this.zoomBy,
|
||||
required this.latLng,
|
||||
this.zoomBy,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -86,7 +43,7 @@ class MapButtonPanel extends StatelessWidget {
|
|||
children: [
|
||||
MapOverlayButton(
|
||||
icon: AIcons.openOutside,
|
||||
onPressed: () => AndroidAppService.openMap(geoUri).then((success) {
|
||||
onPressed: () => AndroidAppService.openMap(latLng).then((success) {
|
||||
if (!success) showNoMatchingAppDialog(context);
|
||||
}),
|
||||
tooltip: context.l10n.entryActionOpenMap,
|
||||
|
@ -120,13 +77,13 @@ class MapButtonPanel extends StatelessWidget {
|
|||
const Spacer(),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.zoomIn,
|
||||
onPressed: () => zoomBy(1),
|
||||
onPressed: zoomBy != null ? () => zoomBy!(1) : null,
|
||||
tooltip: context.l10n.viewerInfoMapZoomInTooltip,
|
||||
),
|
||||
const SizedBox(height: padding),
|
||||
MapOverlayButton(
|
||||
icon: AIcons.zoomOut,
|
||||
onPressed: () => zoomBy(-1),
|
||||
onPressed: zoomBy != null ? () => zoomBy!(-1) : null,
|
||||
tooltip: context.l10n.viewerInfoMapZoomOutTooltip,
|
||||
),
|
||||
],
|
||||
|
@ -141,7 +98,7 @@ class MapButtonPanel extends StatelessWidget {
|
|||
class MapOverlayButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String tooltip;
|
||||
final VoidCallback onPressed;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const MapOverlayButton({
|
||||
Key? key,
|
48
lib/widgets/common/map/decorator.dart
Normal file
48
lib/widgets/common/map/decorator.dart
Normal 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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
41
lib/widgets/common/map/geo_entry.dart
Normal file
41
lib/widgets/common/map/geo_entry.dart
Normal 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}';
|
||||
}
|
202
lib/widgets/common/map/geo_map.dart
Normal file
202
lib/widgets/common/map/geo_map.dart
Normal 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);
|
224
lib/widgets/common/map/google/map.dart
Normal file
224
lib/widgets/common/map/google/map.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
121
lib/widgets/common/map/google/marker_generator.dart
Normal file
121
lib/widgets/common/map/google/marker_generator.dart
Normal 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}';
|
||||
}
|
16
lib/widgets/common/map/latlng_tween.dart
Normal file
16
lib/widgets/common/map/latlng_tween.dart
Normal 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);
|
||||
}
|
14
lib/widgets/common/map/latlng_utils.dart
Normal file
14
lib/widgets/common/map/latlng_utils.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
202
lib/widgets/common/map/leaflet/map.dart
Normal file
202
lib/widgets/common/map/leaflet/map.dart
Normal 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();
|
||||
}
|
||||
}
|
|
@ -1,12 +1,11 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:aves/widgets/common/basic/outlined_text.dart';
|
||||
import 'package:aves/widgets/common/map/leaflet/scalebar_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
|
||||
import 'scalebar_utils.dart' as util;
|
||||
|
||||
class ScaleLayerOptions extends LayerOptions {
|
||||
final Widget Function(double width, String distance) builder;
|
||||
|
||||
|
@ -24,6 +23,7 @@ class ScaleLayerOptions extends LayerOptions {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO TLAD [map] scale bar should not rotate together with map layer
|
||||
class ScaleLayerWidget extends StatelessWidget {
|
||||
final ScaleLayerOptions options;
|
||||
|
||||
|
@ -86,7 +86,7 @@ class ScaleLayer extends StatelessWidget {
|
|||
: 2);
|
||||
final distance = scale[max(0, min(20, level))].toDouble();
|
||||
final start = map.project(center);
|
||||
final targetPoint = util.calculateEndingGlobalCoordinates(center, 90, distance);
|
||||
final targetPoint = ScaleBarUtils.calculateEndingGlobalCoordinates(center, 90, distance);
|
||||
final end = map.project(targetPoint);
|
||||
final displayDistance = distance > 999 ? '${(distance / 1000).toStringAsFixed(0)} km' : '${distance.toStringAsFixed(0)} m';
|
||||
final width = end.x - (start.x as double);
|
119
lib/widgets/common/map/leaflet/scalebar_utils.dart
Normal file
119
lib/widgets/common/map/leaflet/scalebar_utils.dart
Normal 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
Loading…
Reference in a new issue