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