#114 vaults
This commit is contained in:
parent
1a76edb288
commit
bc6d75e928
100 changed files with 2978 additions and 651 deletions
|
@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Added
|
||||
|
||||
- Vaults
|
||||
- Viewer: overlay details expand/collapse on tap
|
||||
- Viewer: export actions available as quick actions
|
||||
- Slideshow: added settings quick action
|
||||
|
@ -14,6 +15,7 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
### Changed
|
||||
|
||||
- disabling the recycle bin will delete forever items in it
|
||||
- remember pin status of albums becoming empty
|
||||
- upgraded Flutter to stable v3.7.3
|
||||
|
||||
|
|
|
@ -18,6 +18,9 @@ if (localPropertiesFile.exists()) {
|
|||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
def flutterRoot = localProperties.getProperty('flutter.sdk')
|
||||
if (flutterRoot == null) {
|
||||
throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
|
||||
}
|
||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||
|
||||
// Keys
|
||||
|
@ -181,10 +184,11 @@ dependencies {
|
|||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
|
||||
|
||||
implementation 'androidx.core:core-ktx:1.9.0'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.5'
|
||||
implementation 'androidx.exifinterface:exifinterface:1.3.6'
|
||||
implementation 'androidx.lifecycle:lifecycle-process:2.5.1'
|
||||
implementation 'androidx.media:media:1.6.0'
|
||||
implementation 'androidx.multidex:multidex:2.0.1'
|
||||
implementation 'androidx.security:security-crypto:1.1.0-alpha04'
|
||||
|
||||
implementation 'com.caverock:androidsvg-aar:1.4'
|
||||
implementation 'com.commonsware.cwac:document:0.5.0'
|
||||
|
|
|
@ -19,7 +19,7 @@ This change eventually prevents building the app with Flutter v3.3.3.
|
|||
android:required="false" />
|
||||
|
||||
<!--
|
||||
Scoped storage on Android 10 is inconvenient because users need to confirm edition on each individual file.
|
||||
Scoped storage on Android 10 (API 29) is inconvenient because users need to confirm edition on each individual file.
|
||||
So we request `WRITE_EXTERNAL_STORAGE` until Android 10 (API 29), and enable `requestLegacyExternalStorage`
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
|
@ -32,28 +32,35 @@ This change eventually prevents building the app with Flutter v3.3.3.
|
|||
android:maxSdkVersion="29"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<!-- to access media with original metadata with scoped storage (API >=29) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
<!-- to analyze media in a service -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- to fetch map tiles -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<!-- from Android 12 (API 31), users can optionally grant access to the media management special permission -->
|
||||
<uses-permission
|
||||
android:name="android.permission.MANAGE_MEDIA"
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.SET_WALLPAPER" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<!-- to show foreground service progress via notification -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- to access media with original metadata with scoped storage (Android >=10) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
|
||||
<!-- to change wallpaper -->
|
||||
<uses-permission android:name="android.permission.SET_WALLPAPER" />
|
||||
<!-- to unlock vaults (API >=28) -->
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<!-- TODO TLAD remove this permission when this is fixed: https://github.com/flutter/flutter/issues/42451 -->
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
|
||||
<!-- for API <26 -->
|
||||
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
|
||||
<uses-permission
|
||||
android:name="com.android.launcher.permission.INSTALL_SHORTCUT"
|
||||
android:maxSdkVersion="25" />
|
||||
|
||||
<!-- allow install on API 19, but Google Maps is from API 20 -->
|
||||
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps" />
|
||||
<!--
|
||||
allow install on API 19, despite the `minSdkVersion` declared in dependencies:
|
||||
- Google Maps is from API 20
|
||||
- the Security library is from API 21
|
||||
-->
|
||||
<uses-sdk tools:overrideLibrary="io.flutter.plugins.googlemaps, androidx.security:security-crypto" />
|
||||
|
||||
<!-- from Android 11, we should define <queries> to make other apps visible to this app -->
|
||||
<queries>
|
||||
|
@ -75,12 +82,14 @@ This change eventually prevents building the app with Flutter v3.3.3.
|
|||
android:allowBackup="true"
|
||||
android:appCategory="image"
|
||||
android:banner="@drawable/banner"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/full_backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
tools:targetApi="o">
|
||||
tools:targetApi="s">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
|
|
|
@ -5,6 +5,7 @@ import android.content.Intent
|
|||
import android.os.Bundle
|
||||
import deckers.thibault.aves.utils.FlutterUtils
|
||||
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class HomeWidgetSettingsActivity : MainActivity() {
|
||||
|
@ -28,8 +29,12 @@ class HomeWidgetSettingsActivity : MainActivity() {
|
|||
finish()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val messenger = flutterEngine!!.dartExecutor
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
val messenger = flutterEngine.dartExecutor
|
||||
MethodChannel(messenger, CHANNEL).setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"configure" -> {
|
||||
|
@ -42,9 +47,9 @@ class HomeWidgetSettingsActivity : MainActivity() {
|
|||
}
|
||||
|
||||
private fun saveWidget() {
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val appWidgetManager = AppWidgetManager.getInstance(this)
|
||||
val widgetInfo = appWidgetManager.getAppWidgetOptions(appWidgetId)
|
||||
HomeWidgetProvider().onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, widgetInfo)
|
||||
HomeWidgetProvider().onAppWidgetOptionsChanged(this, appWidgetManager, appWidgetId, widgetInfo)
|
||||
|
||||
val intent = Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
setResult(RESULT_OK, intent)
|
||||
|
|
|
@ -25,14 +25,15 @@ import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
|
|||
import deckers.thibault.aves.utils.FlutterUtils.isSoftwareRenderingRequired
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
open class MainActivity : FlutterActivity() {
|
||||
open class MainActivity : FlutterFragmentActivity() {
|
||||
private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler
|
||||
private lateinit var settingsChangeStreamHandler: SettingsChangeStreamHandler
|
||||
private lateinit var intentStreamHandler: IntentStreamHandler
|
||||
|
@ -68,8 +69,12 @@ open class MainActivity : FlutterActivity() {
|
|||
// .build()
|
||||
// )
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
val messenger = flutterEngine!!.dartExecutor
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
val messenger = flutterEngine.dartExecutor
|
||||
|
||||
// notification: platform -> dart
|
||||
analysisStreamHandler = AnalysisStreamHandler().apply {
|
||||
|
@ -99,6 +104,7 @@ open class MainActivity : FlutterActivity() {
|
|||
MethodChannel(messenger, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler)
|
||||
MethodChannel(messenger, MediaStoreHandler.CHANNEL).setMethodCallHandler(MediaStoreHandler(this))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||
MethodChannel(messenger, SecurityHandler.CHANNEL).setMethodCallHandler(SecurityHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
// - need ContextWrapper
|
||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||
|
@ -193,6 +199,7 @@ open class MainActivity : FlutterActivity() {
|
|||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
when (requestCode) {
|
||||
DOCUMENT_TREE_ACCESS_REQUEST -> onDocumentTreeAccessResult(requestCode, resultCode, data)
|
||||
DELETE_SINGLE_PERMISSION_REQUEST,
|
||||
|
@ -255,7 +262,7 @@ open class MainActivity : FlutterActivity() {
|
|||
Intent.ACTION_VIEW, Intent.ACTION_SEND, "com.android.camera.action.REVIEW" -> {
|
||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
||||
// MIME type is optional
|
||||
val type = intent.type ?: intent.resolveType(context)
|
||||
val type = intent.type ?: intent.resolveType(this)
|
||||
return hashMapOf(
|
||||
INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW,
|
||||
INTENT_DATA_KEY_MIME_TYPE to type,
|
||||
|
@ -325,7 +332,7 @@ open class MainActivity : FlutterActivity() {
|
|||
private fun submitPickedItems(call: MethodCall) {
|
||||
val pickedUris = call.argument<List<String>>("uris")
|
||||
if (pickedUris != null && pickedUris.isNotEmpty()) {
|
||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(context, Uri.parse(uriString)) }
|
||||
val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) }
|
||||
val intent = Intent().apply {
|
||||
val firstUri = toUri(pickedUris.first())
|
||||
if (pickedUris.size == 1) {
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package deckers.thibault.aves
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
|
@ -18,11 +17,12 @@ import deckers.thibault.aves.utils.FlutterUtils
|
|||
import deckers.thibault.aves.utils.FlutterUtils.enableSoftwareRendering
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import deckers.thibault.aves.utils.getParcelableExtraCompat
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class WallpaperActivity : FlutterActivity() {
|
||||
class WallpaperActivity : FlutterFragmentActivity() {
|
||||
private lateinit var intentDataMap: MutableMap<String, Any?>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
|
@ -36,8 +36,33 @@ class WallpaperActivity : FlutterActivity() {
|
|||
Log.i(LOG_TAG, "onCreate intent extras=$it")
|
||||
}
|
||||
intentDataMap = extractIntentData(intent)
|
||||
}
|
||||
|
||||
initChannels(this)
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
val messenger = flutterEngine.dartExecutor
|
||||
|
||||
// dart -> platform -> dart
|
||||
// - need Context
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(this))
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(this))
|
||||
MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(this))
|
||||
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(this))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(this))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(this))
|
||||
// - need ContextWrapper
|
||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this))
|
||||
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this))
|
||||
// - need Activity
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
// - need Context
|
||||
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(this, args) }
|
||||
|
||||
// intent handling
|
||||
// detail fetch: dart -> platform
|
||||
MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result -> onMethodCall(call, result) }
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
|
@ -54,32 +79,6 @@ class WallpaperActivity : FlutterActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun initChannels(activity: Activity) {
|
||||
val messenger = flutterEngine!!.dartExecutor
|
||||
|
||||
// dart -> platform -> dart
|
||||
// - need Context
|
||||
MethodChannel(messenger, DeviceHandler.CHANNEL).setMethodCallHandler(DeviceHandler(activity))
|
||||
MethodChannel(messenger, EmbeddedDataHandler.CHANNEL).setMethodCallHandler(EmbeddedDataHandler(activity))
|
||||
MethodChannel(messenger, MediaFetchBytesHandler.CHANNEL, AvesByteSendingMethodCodec.INSTANCE).setMethodCallHandler(MediaFetchBytesHandler(activity))
|
||||
MethodChannel(messenger, MediaFetchObjectHandler.CHANNEL).setMethodCallHandler(MediaFetchObjectHandler(activity))
|
||||
MethodChannel(messenger, MetadataFetchHandler.CHANNEL).setMethodCallHandler(MetadataFetchHandler(activity))
|
||||
MethodChannel(messenger, StorageHandler.CHANNEL).setMethodCallHandler(StorageHandler(activity))
|
||||
// - need ContextWrapper
|
||||
MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(activity))
|
||||
MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(activity))
|
||||
// - need Activity
|
||||
MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(activity))
|
||||
|
||||
// result streaming: dart -> platform ->->-> dart
|
||||
// - need Context
|
||||
StreamsChannel(messenger, ImageByteStreamHandler.CHANNEL).setStreamHandlerFactory { args -> ImageByteStreamHandler(activity, args) }
|
||||
|
||||
// intent handling
|
||||
// detail fetch: dart -> platform
|
||||
MethodChannel(messenger, MainActivity.INTENT_CHANNEL).setMethodCallHandler { call, result -> onMethodCall(call, result) }
|
||||
}
|
||||
|
||||
private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getIntentData" -> {
|
||||
|
@ -94,7 +93,7 @@ class WallpaperActivity : FlutterActivity() {
|
|||
Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> {
|
||||
(intent.data ?: intent.getParcelableExtraCompat<Uri>(Intent.EXTRA_STREAM))?.let { uri ->
|
||||
// MIME type is optional
|
||||
val type = intent.type ?: intent.resolveType(context)
|
||||
val type = intent.type ?: intent.resolveType(this)
|
||||
return hashMapOf(
|
||||
MainActivity.INTENT_DATA_KEY_ACTION to MainActivity.INTENT_ACTION_SET_WALLPAPER,
|
||||
MainActivity.INTENT_DATA_KEY_MIME_TYPE to type,
|
||||
|
|
|
@ -21,7 +21,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
when (call.method) {
|
||||
"canManageMedia" -> safe(call, result, ::canManageMedia)
|
||||
"getCapabilities" -> safe(call, result, ::getCapabilities)
|
||||
"getDefaultTimeZone" -> safe(call, result, ::getDefaultTimeZone)
|
||||
"getDefaultTimeZoneRawOffsetMillis" -> safe(call, result, ::getDefaultTimeZoneRawOffsetMillis)
|
||||
"getLocales" -> safe(call, result, ::getLocales)
|
||||
"getPerformanceClass" -> safe(call, result, ::getPerformanceClass)
|
||||
"isSystemFilePickerEnabled" -> safe(call, result, ::isSystemFilePickerEnabled)
|
||||
|
@ -44,6 +44,7 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
"canRenderFlagEmojis" to (sdkInt >= Build.VERSION_CODES.M),
|
||||
"canRequestManageMedia" to (sdkInt >= Build.VERSION_CODES.S),
|
||||
"canSetLockScreenWallpaper" to (sdkInt >= Build.VERSION_CODES.N),
|
||||
"canUseCrypto" to (sdkInt >= Build.VERSION_CODES.LOLLIPOP),
|
||||
"hasGeocoder" to Geocoder.isPresent(),
|
||||
"isDynamicColorAvailable" to (sdkInt >= Build.VERSION_CODES.S),
|
||||
"showPinShortcutFeedback" to (sdkInt >= Build.VERSION_CODES.O),
|
||||
|
@ -52,8 +53,8 @@ class DeviceHandler(private val context: Context) : MethodCallHandler {
|
|||
)
|
||||
}
|
||||
|
||||
private fun getDefaultTimeZone(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(TimeZone.getDefault().id)
|
||||
private fun getDefaultTimeZoneRawOffsetMillis(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(TimeZone.getDefault().rawOffset)
|
||||
}
|
||||
|
||||
private fun getLocales(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
package deckers.thibault.aves.channel.calls
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import androidx.security.crypto.EncryptedSharedPreferences
|
||||
import androidx.security.crypto.MasterKey
|
||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
|
||||
|
||||
class SecurityHandler(private val context: Context) : MethodCallHandler {
|
||||
private var sharedPreferences: SharedPreferences? = null
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"writeValue" -> safe(call, result, ::writeValue)
|
||||
"readValue" -> safe(call, result, ::readValue)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getStore(): SharedPreferences {
|
||||
if (sharedPreferences == null) {
|
||||
val mainKey = MasterKey.Builder(context)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build()
|
||||
sharedPreferences = EncryptedSharedPreferences.create(
|
||||
context,
|
||||
FILENAME,
|
||||
mainKey,
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
}
|
||||
return sharedPreferences!!
|
||||
}
|
||||
|
||||
private fun writeValue(call: MethodCall, result: MethodChannel.Result) {
|
||||
val key = call.argument<String>("key")
|
||||
val value = call.argument<Any?>("value")
|
||||
if (key == null) {
|
||||
result.error("writeValue-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
with(getStore().edit()) {
|
||||
when (value) {
|
||||
is Boolean -> putBoolean(key, value)
|
||||
is Float -> putFloat(key, value)
|
||||
is Int -> putInt(key, value)
|
||||
is Long -> putLong(key, value)
|
||||
is String -> putString(key, value)
|
||||
null -> remove(key)
|
||||
else -> {
|
||||
result.error("writeValue-type", "unsupported type for value=$value", null)
|
||||
return
|
||||
}
|
||||
}
|
||||
apply()
|
||||
}
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun readValue(call: MethodCall, result: MethodChannel.Result) {
|
||||
val key = call.argument<String>("key")
|
||||
if (key == null) {
|
||||
result.error("readValue-args", "missing arguments", null)
|
||||
return
|
||||
}
|
||||
|
||||
result.success(getStore().all[key])
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val CHANNEL = "deckers.thibault/aves/security"
|
||||
const val FILENAME = "secret_shared_prefs"
|
||||
}
|
||||
}
|
|
@ -8,6 +8,7 @@ import androidx.core.os.EnvironmentCompat
|
|||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.utils.PermissionManager
|
||||
import deckers.thibault.aves.utils.StorageUtils
|
||||
import deckers.thibault.aves.utils.StorageUtils.getPrimaryVolumePath
|
||||
import deckers.thibault.aves.utils.StorageUtils.getVolumePaths
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
|
@ -25,6 +26,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
"getStorageVolumes" -> ioScope.launch { safe(call, result, ::getStorageVolumes) }
|
||||
"getVaultRoot" -> ioScope.launch { safe(call, result, ::getVaultRoot) }
|
||||
"getFreeSpace" -> ioScope.launch { safe(call, result, ::getFreeSpace) }
|
||||
"getGrantedDirectories" -> ioScope.launch { safe(call, result, ::getGrantedDirectories) }
|
||||
"getInaccessibleDirectories" -> ioScope.launch { safe(call, result, ::getInaccessibleDirectories) }
|
||||
|
@ -88,6 +90,10 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
|||
result.success(volumes)
|
||||
}
|
||||
|
||||
private fun getVaultRoot(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(StorageUtils.getVaultRoot(context))
|
||||
}
|
||||
|
||||
private fun getFreeSpace(call: MethodCall, result: MethodChannel.Result) {
|
||||
val path = call.argument<String>("path")
|
||||
if (path == null) {
|
||||
|
|
|
@ -109,7 +109,7 @@ class ThumbnailFetcher internal constructor(
|
|||
} else {
|
||||
@Suppress("deprecation")
|
||||
var bitmap = MediaStore.Images.Thumbnails.getThumbnail(resolver, contentId, MediaStore.Images.Thumbnails.MINI_KIND, null)
|
||||
// from Android 10, returned thumbnail is already rotated according to EXIF orientation
|
||||
// from Android 10 (API 29), returned thumbnail is already rotated according to EXIF orientation
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && bitmap != null) {
|
||||
bitmap = applyExifOrientation(context, bitmap, rotationDegrees, isFlipped)
|
||||
}
|
||||
|
|
|
@ -12,7 +12,7 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
|
|||
result.success(true)
|
||||
}
|
||||
|
||||
override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
|
||||
private fun setWindowFlag(call: MethodCall, result: MethodChannel.Result, flag: Int) {
|
||||
val on = call.argument<Boolean>("on")
|
||||
if (on == null) {
|
||||
result.error("keepOn-args", "missing arguments", null)
|
||||
|
@ -20,8 +20,6 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
|
|||
}
|
||||
|
||||
val window = activity.window
|
||||
val flag = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||
|
||||
val old = (window.attributes.flags and flag) != 0
|
||||
if (old != on) {
|
||||
if (on) {
|
||||
|
@ -33,6 +31,14 @@ class ActivityWindowHandler(private val activity: Activity) : WindowHandler(acti
|
|||
result.success(null)
|
||||
}
|
||||
|
||||
override fun keepScreenOn(call: MethodCall, result: MethodChannel.Result) {
|
||||
setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
override fun secureScreen(call: MethodCall, result: MethodChannel.Result) {
|
||||
setWindowFlag(call, result, WindowManager.LayoutParams.FLAG_SECURE)
|
||||
}
|
||||
|
||||
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
|
||||
val orientation = call.argument<Int>("orientation")
|
||||
if (orientation == null) {
|
||||
|
|
|
@ -13,6 +13,10 @@ class ServiceWindowHandler(service: Service) : WindowHandler(service) {
|
|||
result.success(null)
|
||||
}
|
||||
|
||||
override fun secureScreen(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
override fun requestOrientation(call: MethodCall, result: MethodChannel.Result) {
|
||||
result.success(false)
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
|||
when (call.method) {
|
||||
"isActivity" -> Coresult.safe(call, result, ::isActivity)
|
||||
"keepScreenOn" -> Coresult.safe(call, result, ::keepScreenOn)
|
||||
"secureScreen" -> Coresult.safe(call, result, ::secureScreen)
|
||||
"isRotationLocked" -> Coresult.safe(call, result, ::isRotationLocked)
|
||||
"requestOrientation" -> Coresult.safe(call, result, ::requestOrientation)
|
||||
"isCutoutAware" -> Coresult.safe(call, result, ::isCutoutAware)
|
||||
|
@ -25,6 +26,8 @@ abstract class WindowHandler(private val contextWrapper: ContextWrapper) : Metho
|
|||
|
||||
abstract fun keepScreenOn(call: MethodCall, result: MethodChannel.Result)
|
||||
|
||||
abstract fun secureScreen(call: MethodCall, result: MethodChannel.Result)
|
||||
|
||||
private fun isRotationLocked(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) {
|
||||
var locked = false
|
||||
try {
|
||||
|
|
|
@ -170,6 +170,11 @@ object XMP {
|
|||
}
|
||||
}
|
||||
// creating `IsoFile` with a `File` or a `File.inputStream()` yields `No such device`
|
||||
|
||||
// TODO TLAD [mp4] `IsoFile` init may fail if a skipped box has a `org.mp4parser.boxes.iso14496.part12.MetaBox` as parent,
|
||||
// because `MetaBox.parse()` changes the argument `dataSource` to a `RewindableReadableByteChannel`,
|
||||
// so it is no longer a seekable `FileChannel`, which is a requirement to skip boxes.
|
||||
|
||||
IsoFile(channel, boxParser).use { isoFile ->
|
||||
isoFile.processBoxes(UserBox::class.java, true) { box, _ ->
|
||||
val boxSize = box.size
|
||||
|
|
|
@ -33,6 +33,7 @@ import org.beyka.tiffbitmapfactory.TiffBitmapFactory
|
|||
import java.io.IOException
|
||||
|
||||
class SourceEntry {
|
||||
private val origin: Int
|
||||
val uri: Uri // content or file URI
|
||||
var path: String? = null // best effort to get local path
|
||||
private val sourceMimeType: String
|
||||
|
@ -48,12 +49,14 @@ class SourceEntry {
|
|||
|
||||
private var foundExif: Boolean = false
|
||||
|
||||
constructor(uri: Uri, sourceMimeType: String) {
|
||||
constructor(origin: Int, uri: Uri, sourceMimeType: String) {
|
||||
this.origin = origin
|
||||
this.uri = uri
|
||||
this.sourceMimeType = sourceMimeType
|
||||
}
|
||||
|
||||
constructor(map: FieldMap) {
|
||||
origin = map["origin"] as Int
|
||||
uri = Uri.parse(map["uri"] as String)
|
||||
path = map["path"] as String?
|
||||
sourceMimeType = map["sourceMimeType"] as String
|
||||
|
@ -77,6 +80,7 @@ class SourceEntry {
|
|||
|
||||
fun toMap(): FieldMap {
|
||||
return hashMapOf(
|
||||
"origin" to origin,
|
||||
"uri" to uri.toString(),
|
||||
"path" to path,
|
||||
"sourceMimeType" to sourceMimeType,
|
||||
|
@ -249,13 +253,15 @@ class SourceEntry {
|
|||
|
||||
private fun fillByTiffDecode(context: Context) {
|
||||
try {
|
||||
val fd = context.contentResolver.openFileDescriptor(uri, "r")?.detachFd() ?: return
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
context.contentResolver.openFileDescriptor(uri, "r")?.use { pfd ->
|
||||
val fd = pfd.detachFd()
|
||||
val options = TiffBitmapFactory.Options().apply {
|
||||
inJustDecodeBounds = true
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
width = options.outWidth
|
||||
height = options.outHeight
|
||||
}
|
||||
TiffBitmapFactory.decodeFileDescriptor(fd, options)
|
||||
width = options.outWidth
|
||||
height = options.outHeight
|
||||
} catch (e: Exception) {
|
||||
// ignore
|
||||
}
|
||||
|
@ -267,5 +273,11 @@ class SourceEntry {
|
|||
is Int -> o.toLong()
|
||||
else -> o as? Long
|
||||
}
|
||||
|
||||
// should match `EntryOrigins` on the Dart side
|
||||
const val ORIGIN_MEDIA_STORE_CONTENT = 0
|
||||
const val ORIGIN_UNKNOWN_CONTENT = 1
|
||||
const val ORIGIN_FILE = 2
|
||||
const val ORIGIN_VAULT = 3
|
||||
}
|
||||
}
|
|
@ -44,6 +44,7 @@ internal class ContentImageProvider : ImageProvider() {
|
|||
}
|
||||
|
||||
val fields: FieldMap = hashMapOf(
|
||||
"origin" to SourceEntry.ORIGIN_UNKNOWN_CONTENT,
|
||||
"uri" to uri.toString(),
|
||||
"sourceMimeType" to mimeType,
|
||||
)
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.content.ContextWrapper
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import deckers.thibault.aves.model.FieldMap
|
||||
import deckers.thibault.aves.model.SourceEntry
|
||||
import deckers.thibault.aves.utils.LogUtils
|
||||
import java.io.File
|
||||
|
@ -15,7 +16,7 @@ internal class FileImageProvider : ImageProvider() {
|
|||
return
|
||||
}
|
||||
|
||||
val entry = SourceEntry(uri, sourceMimeType)
|
||||
val entry = SourceEntry(SourceEntry.ORIGIN_FILE, uri, sourceMimeType)
|
||||
|
||||
val path = uri.path
|
||||
if (path != null) {
|
||||
|
@ -52,6 +53,19 @@ internal class FileImageProvider : ImageProvider() {
|
|||
throw Exception("failed to delete entry with uri=$uri path=$path")
|
||||
}
|
||||
|
||||
override fun scanPostMetadataEdit(context: Context, path: String, uri: Uri, mimeType: String, newFields: FieldMap, callback: ImageOpCallback) {
|
||||
try {
|
||||
val file = File(path)
|
||||
if (file.exists()) {
|
||||
newFields["dateModifiedSecs"] = file.lastModified() / 1000
|
||||
newFields["sizeBytes"] = file.length()
|
||||
}
|
||||
callback.onSuccess(newFields)
|
||||
} catch (e: SecurityException) {
|
||||
callback.onFailure(e)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG_TAG = LogUtils.createTag<MediaStoreImageProvider>()
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import android.net.Uri
|
|||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import androidx.core.net.toUri
|
||||
import com.commonsware.cwac.document.DocumentFileCompat
|
||||
import deckers.thibault.aves.MainActivity
|
||||
import deckers.thibault.aves.MainActivity.Companion.DELETE_SINGLE_PERMISSION_REQUEST
|
||||
|
@ -220,6 +221,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
Log.w(LOG_TAG, "failed to make entry from uri=$itemUri because of null MIME type")
|
||||
} else {
|
||||
var entryMap: FieldMap = hashMapOf(
|
||||
"origin" to SourceEntry.ORIGIN_MEDIA_STORE_CONTENT,
|
||||
"uri" to itemUri.toString(),
|
||||
"path" to cursor.getString(pathColumn),
|
||||
"sourceMimeType" to mimeType,
|
||||
|
@ -350,7 +352,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
}
|
||||
} catch (securityException: SecurityException) {
|
||||
// even if the app has access permission granted on the containing directory,
|
||||
// the delete request may yield a `RecoverableSecurityException` on Android >=10
|
||||
// the delete request may yield a `RecoverableSecurityException` on API >=29
|
||||
// when the underlying file no longer exists and this is an orphaned entry in the Media Store
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && contextWrapper is Activity) {
|
||||
Log.w(LOG_TAG, "caught a security exception when attempting to delete uri=$uri", securityException)
|
||||
|
@ -387,10 +389,12 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
val entries = kv.value
|
||||
|
||||
val toBin = targetDir == StorageUtils.TRASH_PATH_PLACEHOLDER
|
||||
val toVault = StorageUtils.isInVault(activity, targetDir)
|
||||
val toAppDir = toBin || toVault
|
||||
|
||||
var effectiveTargetDir: String? = null
|
||||
var targetDirDocFile: DocumentFileCompat? = null
|
||||
if (!toBin) {
|
||||
if (!toAppDir) {
|
||||
effectiveTargetDir = targetDir
|
||||
targetDirDocFile = StorageUtils.createDirectoryDocIfAbsent(activity, targetDir)
|
||||
if (!File(targetDir).exists()) {
|
||||
|
@ -438,13 +442,20 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
// - there is no documentation regarding support for usage with removable storage
|
||||
// - the Media Store only allows inserting in specific primary directories ("DCIM", "Pictures") when using scoped storage
|
||||
try {
|
||||
if (toBin) {
|
||||
val trashDir = StorageUtils.trashDirFor(activity, sourcePath)
|
||||
if (trashDir != null) {
|
||||
effectiveTargetDir = ensureTrailingSeparator(trashDir.path)
|
||||
targetDirDocFile = DocumentFileCompat.fromFile(trashDir)
|
||||
val appDir = when {
|
||||
toBin -> StorageUtils.trashDirFor(activity, sourcePath)
|
||||
toVault -> File(targetDir)
|
||||
else -> null
|
||||
}
|
||||
if (appDir != null) {
|
||||
effectiveTargetDir = ensureTrailingSeparator(appDir.path)
|
||||
targetDirDocFile = DocumentFileCompat.fromFile(appDir)
|
||||
|
||||
if (toVault) {
|
||||
appDir.mkdirs()
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveTargetDir != null) {
|
||||
val newFields = if (isCancelledOp()) skippedFieldMap else {
|
||||
val sourceFile = File(sourcePath)
|
||||
|
@ -463,6 +474,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
mimeType = mimeType,
|
||||
copy = copy,
|
||||
toBin = toBin,
|
||||
toVault = toVault,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -489,6 +501,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
mimeType: String,
|
||||
copy: Boolean,
|
||||
toBin: Boolean,
|
||||
toVault: Boolean,
|
||||
): FieldMap {
|
||||
val sourcePath = sourceFile.path
|
||||
val sourceDir = sourceFile.parent?.let { ensureTrailingSeparator(it) }
|
||||
|
@ -532,13 +545,21 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
Log.w(LOG_TAG, "failed to delete entry with path=$sourcePath", e)
|
||||
}
|
||||
}
|
||||
if (toBin) {
|
||||
return hashMapOf(
|
||||
return if (toBin) {
|
||||
hashMapOf(
|
||||
"trashed" to true,
|
||||
"trashPath" to targetPath,
|
||||
)
|
||||
} else if (toVault) {
|
||||
hashMapOf(
|
||||
"uri" to File(targetPath).toUri().toString(),
|
||||
"contentId" to null,
|
||||
"path" to targetPath,
|
||||
"origin" to SourceEntry.ORIGIN_VAULT,
|
||||
)
|
||||
} else {
|
||||
scanNewPath(activity, targetPath, mimeType)
|
||||
}
|
||||
return scanNewPath(activity, targetPath, mimeType)
|
||||
}
|
||||
|
||||
// `DocumentsContract.moveDocument()` needs `sourceParentDocumentUri`, which could be different for each entry
|
||||
|
@ -920,7 +941,7 @@ class MediaStoreImageProvider : ImageProvider() {
|
|||
private val VIDEO_PROJECTION = arrayOf(
|
||||
*BASE_PROJECTION,
|
||||
MediaColumns.DURATION,
|
||||
// `ORIENTATION` was only available for images before Android 10
|
||||
// `ORIENTATION` was only available for images before Android 10 (API 29)
|
||||
*if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) arrayOf(
|
||||
MediaStore.MediaColumns.ORIENTATION,
|
||||
) else emptyArray()
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package deckers.thibault.aves.utils
|
||||
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
|
||||
object MimeTypes {
|
||||
|
@ -153,47 +154,11 @@ object MimeTypes {
|
|||
// among other refs:
|
||||
// - https://android.googlesource.com/platform/external/mime-support/+/refs/heads/master/mime.types
|
||||
fun extensionFor(mimeType: String): String? = when (mimeType) {
|
||||
ARW -> ".arw"
|
||||
AVI, AVI_VND -> ".avi"
|
||||
AVIF -> ".avif"
|
||||
BMP -> ".bmp"
|
||||
CR2 -> ".cr2"
|
||||
CRW -> ".crw"
|
||||
DCR -> ".dcr"
|
||||
DJVU -> ".djvu"
|
||||
DNG -> ".dng"
|
||||
ERF -> ".erf"
|
||||
GIF -> ".gif"
|
||||
HEIC, HEIF -> ".heif"
|
||||
ICO -> ".ico"
|
||||
JPEG -> ".jpg"
|
||||
K25 -> ".k25"
|
||||
KDC -> ".kdc"
|
||||
MKV -> ".mkv"
|
||||
MOV -> ".mov"
|
||||
MP2T, MP2TS -> ".m2ts"
|
||||
MP4 -> ".mp4"
|
||||
MRW -> ".mrw"
|
||||
NEF -> ".nef"
|
||||
NRW -> ".nrw"
|
||||
OGV -> ".ogv"
|
||||
ORF -> ".orf"
|
||||
PEF -> ".pef"
|
||||
PNG -> ".png"
|
||||
PSD_VND, PSD_X -> ".psd"
|
||||
RAF -> ".raf"
|
||||
RAW -> ".raw"
|
||||
RW2 -> ".rw2"
|
||||
SR2 -> ".sr2"
|
||||
SRF -> ".srf"
|
||||
SRW -> ".srw"
|
||||
SVG -> ".svg"
|
||||
TIFF -> ".tiff"
|
||||
WBMP -> ".wbmp"
|
||||
WEBM -> ".webm"
|
||||
WEBP -> ".webp"
|
||||
X3F -> ".x3f"
|
||||
else -> null
|
||||
else -> MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)?.let { ".$it" }
|
||||
}
|
||||
|
||||
val TIFF_EXTENSION_PATTERN = Regex(".*\\.tiff?", RegexOption.IGNORE_CASE)
|
||||
|
|
|
@ -119,7 +119,7 @@ object PermissionManager {
|
|||
dirSet.add("")
|
||||
}
|
||||
} else {
|
||||
// request volume root until Android 10
|
||||
// request volume root until Android 10 (API 29)
|
||||
dirSet.add("")
|
||||
}
|
||||
dirsPerVolume[volumePath] = dirSet
|
||||
|
|
|
@ -45,23 +45,23 @@ object StorageUtils {
|
|||
const val TRASH_PATH_PLACEHOLDER = "#trash"
|
||||
|
||||
private fun isAppFile(context: Context, path: String): Boolean {
|
||||
val filesDirs = context.getExternalFilesDirs(null).filterNotNull()
|
||||
return filesDirs.any { path.startsWith(it.path) }
|
||||
val dirs = context.getExternalFilesDirs(null).filterNotNull()
|
||||
return dirs.any { path.startsWith(it.path) }
|
||||
}
|
||||
|
||||
private fun appExternalFilesDirFor(context: Context, path: String): File? {
|
||||
val filesDirs = context.getExternalFilesDirs(null).filterNotNull()
|
||||
val dirs = context.getExternalFilesDirs(null).filterNotNull()
|
||||
val volumePath = getVolumePath(context, path)
|
||||
return volumePath?.let { filesDirs.firstOrNull { it.startsWith(volumePath) } } ?: filesDirs.firstOrNull()
|
||||
return volumePath?.let { dirs.firstOrNull { it.startsWith(volumePath) } } ?: dirs.firstOrNull()
|
||||
}
|
||||
|
||||
fun trashDirFor(context: Context, path: String): File? {
|
||||
val filesDir = appExternalFilesDirFor(context, path)
|
||||
if (filesDir == null) {
|
||||
val externalFilesDir = appExternalFilesDirFor(context, path)
|
||||
if (externalFilesDir == null) {
|
||||
Log.e(LOG_TAG, "failed to find external files dir for path=$path")
|
||||
return null
|
||||
}
|
||||
val trashDir = File(filesDir, "trash")
|
||||
val trashDir = File(externalFilesDir, "trash")
|
||||
if (!trashDir.exists() && !trashDir.mkdirs()) {
|
||||
Log.e(LOG_TAG, "failed to create directories at path=$trashDir")
|
||||
return null
|
||||
|
@ -69,6 +69,10 @@ object StorageUtils {
|
|||
return trashDir
|
||||
}
|
||||
|
||||
fun getVaultRoot(context: Context) = ensureTrailingSeparator(File(context.filesDir, "vault").path)
|
||||
|
||||
fun isInVault(context: Context, path: String) = path.startsWith(getVaultRoot(context))
|
||||
|
||||
/**
|
||||
* Volume paths
|
||||
*/
|
||||
|
@ -545,7 +549,7 @@ object StorageUtils {
|
|||
}
|
||||
|
||||
// As of Glide v4.12.0, a special loader `QMediaStoreUriLoader` is automatically used
|
||||
// to work around a bug from Android 10 where metadata redaction corrupts HEIC images.
|
||||
// to work around a bug from Android 10 (API 29) where metadata redaction corrupts HEIC images.
|
||||
// This loader relies on `MediaStore.setRequireOriginal` but this yields a `SecurityException`
|
||||
// for some non image/video content URIs (e.g. `downloads`, `file`)
|
||||
fun getGlideSafeUri(context: Context, uri: Uri, mimeType: String, sizeBytes: Long? = null): Uri {
|
||||
|
|
37
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
37
android/app/src/main/res/xml/data_extraction_rules.xml
Normal file
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<data-extraction-rules>
|
||||
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||
<include
|
||||
domain="file"
|
||||
path="." />
|
||||
<include
|
||||
domain="database"
|
||||
path="." />
|
||||
<include
|
||||
domain="external"
|
||||
path="." />
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="secret_shared_prefs.xml" />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<include
|
||||
domain="file"
|
||||
path="." />
|
||||
<include
|
||||
domain="database"
|
||||
path="." />
|
||||
<include
|
||||
domain="external"
|
||||
path="." />
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="secret_shared_prefs.xml" />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
18
android/app/src/main/res/xml/full_backup_content.xml
Normal file
18
android/app/src/main/res/xml/full_backup_content.xml
Normal file
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<full-backup-content>
|
||||
<include
|
||||
domain="file"
|
||||
path="." />
|
||||
<include
|
||||
domain="database"
|
||||
path="." />
|
||||
<include
|
||||
domain="external"
|
||||
path="." />
|
||||
<include
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="secret_shared_prefs.xml" />
|
||||
</full-backup-content>
|
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||
|
|
|
@ -26,6 +26,11 @@ extension ExtraAppMode on AppMode {
|
|||
|
||||
bool get canSelectFilter => this == AppMode.main;
|
||||
|
||||
bool get canCreateFilter => {
|
||||
AppMode.main,
|
||||
AppMode.pickFilterInternal,
|
||||
}.contains(this);
|
||||
|
||||
bool get isPickingMedia => {
|
||||
AppMode.pickSingleMediaExternal,
|
||||
AppMode.pickMultipleMediaExternal,
|
||||
|
|
|
@ -78,11 +78,14 @@
|
|||
"chipActionFilterOut": "Filter out",
|
||||
"chipActionFilterIn": "Filter in",
|
||||
"chipActionHide": "Hide",
|
||||
"chipActionLock": "Lock",
|
||||
"chipActionPin": "Pin to top",
|
||||
"chipActionUnpin": "Unpin from top",
|
||||
"chipActionRename": "Rename",
|
||||
"chipActionSetCover": "Set cover",
|
||||
"chipActionCreateAlbum": "Create album",
|
||||
"chipActionCreateVault": "Create vault",
|
||||
"chipActionConfigureVault": "Configure vault",
|
||||
|
||||
"entryActionCopyToClipboard": "Copy to clipboard",
|
||||
"entryActionDelete": "Delete",
|
||||
|
@ -158,6 +161,16 @@
|
|||
"filterMimeImageLabel": "Image",
|
||||
"filterMimeVideoLabel": "Video",
|
||||
|
||||
"accessibilityAnimationsRemove": "Prevent screen effects",
|
||||
"accessibilityAnimationsKeep": "Keep screen effects",
|
||||
|
||||
"albumTierNew": "New",
|
||||
"albumTierPinned": "Pinned",
|
||||
"albumTierSpecial": "Common",
|
||||
"albumTierApps": "Apps",
|
||||
"albumTierVaults": "Vaults",
|
||||
"albumTierRegular": "Others",
|
||||
|
||||
"coordinateFormatDms": "DMS",
|
||||
"coordinateFormatDecimal": "Decimal degrees",
|
||||
"coordinateDms": "{coordinate} {direction}",
|
||||
|
@ -178,17 +191,13 @@
|
|||
"coordinateDmsEast": "E",
|
||||
"coordinateDmsWest": "W",
|
||||
|
||||
"unitSystemMetric": "Metric",
|
||||
"unitSystemImperial": "Imperial",
|
||||
"displayRefreshRatePreferHighest": "Highest rate",
|
||||
"displayRefreshRatePreferLowest": "Lowest rate",
|
||||
|
||||
"videoLoopModeNever": "Never",
|
||||
"videoLoopModeShortOnly": "Short videos only",
|
||||
"videoLoopModeAlways": "Always",
|
||||
|
||||
"videoControlsPlay": "Play",
|
||||
"videoControlsPlaySeek": "Play & seek backward/forward",
|
||||
"videoControlsPlayOutside": "Open with other player",
|
||||
"videoControlsNone": "None",
|
||||
"keepScreenOnNever": "Never",
|
||||
"keepScreenOnVideoPlayback": "During video playback",
|
||||
"keepScreenOnViewerOnly": "Viewer page only",
|
||||
"keepScreenOnAlways": "Always",
|
||||
|
||||
"mapStyleGoogleNormal": "Google Maps",
|
||||
"mapStyleGoogleHybrid": "Google Maps (Hybrid)",
|
||||
|
@ -203,28 +212,32 @@
|
|||
"nameConflictStrategyReplace": "Replace",
|
||||
"nameConflictStrategySkip": "Skip",
|
||||
|
||||
"keepScreenOnNever": "Never",
|
||||
"keepScreenOnVideoPlayback": "During video playback",
|
||||
"keepScreenOnViewerOnly": "Viewer page only",
|
||||
"keepScreenOnAlways": "Always",
|
||||
|
||||
"accessibilityAnimationsRemove": "Prevent screen effects",
|
||||
"accessibilityAnimationsKeep": "Keep screen effects",
|
||||
|
||||
"displayRefreshRatePreferHighest": "Highest rate",
|
||||
"displayRefreshRatePreferLowest": "Lowest rate",
|
||||
|
||||
"subtitlePositionTop": "Top",
|
||||
"subtitlePositionBottom": "Bottom",
|
||||
|
||||
"videoPlaybackSkip": "Skip",
|
||||
"videoPlaybackMuted": "Play muted",
|
||||
"videoPlaybackWithSound": "Play with sound",
|
||||
|
||||
"themeBrightnessLight": "Light",
|
||||
"themeBrightnessDark": "Dark",
|
||||
"themeBrightnessBlack": "Black",
|
||||
|
||||
"unitSystemMetric": "Metric",
|
||||
"unitSystemImperial": "Imperial",
|
||||
|
||||
"vaultLockTypePin": "Pin",
|
||||
"vaultLockTypePassword": "Password",
|
||||
|
||||
"videoControlsPlay": "Play",
|
||||
"videoControlsPlaySeek": "Play & seek backward/forward",
|
||||
"videoControlsPlayOutside": "Open with other player",
|
||||
"videoControlsNone": "None",
|
||||
|
||||
"videoLoopModeNever": "Never",
|
||||
"videoLoopModeShortOnly": "Short videos only",
|
||||
"videoLoopModeAlways": "Always",
|
||||
|
||||
"videoPlaybackSkip": "Skip",
|
||||
"videoPlaybackMuted": "Play muted",
|
||||
"videoPlaybackWithSound": "Play with sound",
|
||||
|
||||
"viewerTransitionSlide": "Slide",
|
||||
"viewerTransitionParallax": "Parallax",
|
||||
"viewerTransitionFade": "Fade",
|
||||
|
@ -242,12 +255,6 @@
|
|||
"widgetOpenPageCollection": "Open collection",
|
||||
"widgetOpenPageViewer": "Open viewer",
|
||||
|
||||
"albumTierNew": "New",
|
||||
"albumTierPinned": "Pinned",
|
||||
"albumTierSpecial": "Common",
|
||||
"albumTierApps": "Apps",
|
||||
"albumTierRegular": "Others",
|
||||
|
||||
"storageVolumeDescriptionFallbackPrimary": "Internal storage",
|
||||
"storageVolumeDescriptionFallbackNonPrimary": "SD card",
|
||||
"rootDirectoryDescription": "root directory",
|
||||
|
@ -367,6 +374,23 @@
|
|||
"newAlbumDialogNameLabelAlreadyExistsHelper": "Directory already exists",
|
||||
"newAlbumDialogStorageLabel": "Storage:",
|
||||
|
||||
"newVaultWarningDialogMessage": "Items in vaults are only available to this app and no others.\n\nIf you uninstall this app, or clear this app data, you will lose all these items.",
|
||||
"newVaultDialogTitle": "New Vault",
|
||||
"configureVaultDialogTitle": "Configure Vault",
|
||||
"vaultDialogLockModeWhenScreenOff": "Lock when screen turns off",
|
||||
"vaultDialogLockTypeLabel": "Lock type",
|
||||
|
||||
"pinDialogEnter": "Enter pin",
|
||||
"pinDialogConfirm": "Confirm pin",
|
||||
|
||||
"passwordDialogEnter": "Enter password",
|
||||
"passwordDialogConfirm": "Confirm password",
|
||||
|
||||
"authenticateToConfigureVault": "Authenticate to configure vault",
|
||||
"authenticateToUnlockVault": "Authenticate to unlock vault",
|
||||
|
||||
"vaultBinUsageDialogMessage": "Some vaults are using the recycle bin.",
|
||||
|
||||
"renameAlbumDialogLabel": "New name",
|
||||
"renameAlbumDialogLabelAlreadyExistsHelper": "Directory already exists",
|
||||
|
||||
|
@ -635,7 +659,6 @@
|
|||
|
||||
"albumPageTitle": "Albums",
|
||||
"albumEmpty": "No albums",
|
||||
"createAlbumTooltip": "Create album",
|
||||
"createAlbumButtonLabel": "CREATE",
|
||||
"newFilterBanner": "new",
|
||||
|
||||
|
@ -688,6 +711,7 @@
|
|||
"settingsConfirmationBeforeMoveToBinItems": "Ask before moving items to the recycle bin",
|
||||
"settingsConfirmationBeforeMoveUndatedItems": "Ask before moving undated items",
|
||||
"settingsConfirmationAfterMoveToBinItems": "Show message after moving items to the recycle bin",
|
||||
"settingsConfirmationVaultDataLoss": "Show vault data loss warning",
|
||||
|
||||
"settingsNavigationDrawerTile": "Navigation menu",
|
||||
"settingsNavigationDrawerEditorPageTitle": "Navigation Menu",
|
||||
|
@ -791,6 +815,7 @@
|
|||
"settingsSaveSearchHistory": "Save search history",
|
||||
"settingsEnableBin": "Use recycle bin",
|
||||
"settingsEnableBinSubtitle": "Keep deleted items for 30 days",
|
||||
"settingsDisablingBinWarningDialogMessage": "Items in the recycle bin will be deleted forever.",
|
||||
"settingsAllowMediaManagement": "Allow media management",
|
||||
|
||||
"settingsHiddenItemsTile": "Hidden items",
|
||||
|
|
|
@ -8,6 +8,7 @@ enum ChipAction {
|
|||
goToTagPage,
|
||||
reverse,
|
||||
hide,
|
||||
lockVault,
|
||||
}
|
||||
|
||||
extension ExtraChipAction on ChipAction {
|
||||
|
@ -24,6 +25,8 @@ extension ExtraChipAction on ChipAction {
|
|||
return context.l10n.chipActionFilterOut;
|
||||
case ChipAction.hide:
|
||||
return context.l10n.chipActionHide;
|
||||
case ChipAction.lockVault:
|
||||
return context.l10n.chipActionLock;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -41,6 +44,8 @@ extension ExtraChipAction on ChipAction {
|
|||
return AIcons.reverse;
|
||||
case ChipAction.hide:
|
||||
return AIcons.hide;
|
||||
case ChipAction.lockVault:
|
||||
return AIcons.vaultLock;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ enum ChipSetAction {
|
|||
search,
|
||||
toggleTitleSearch,
|
||||
createAlbum,
|
||||
createVault,
|
||||
// browsing or selecting
|
||||
map,
|
||||
slideshow,
|
||||
|
@ -21,9 +22,11 @@ enum ChipSetAction {
|
|||
hide,
|
||||
pin,
|
||||
unpin,
|
||||
lockVault,
|
||||
// selecting (single filter)
|
||||
rename,
|
||||
setCover,
|
||||
configureVault,
|
||||
}
|
||||
|
||||
class ChipSetActions {
|
||||
|
@ -34,15 +37,20 @@ class ChipSetActions {
|
|||
ChipSetAction.selectNone,
|
||||
];
|
||||
|
||||
// `null` items are converted to dividers
|
||||
static const browsing = [
|
||||
ChipSetAction.search,
|
||||
ChipSetAction.toggleTitleSearch,
|
||||
ChipSetAction.createAlbum,
|
||||
null,
|
||||
ChipSetAction.map,
|
||||
ChipSetAction.slideshow,
|
||||
ChipSetAction.stats,
|
||||
null,
|
||||
ChipSetAction.createAlbum,
|
||||
ChipSetAction.createVault,
|
||||
];
|
||||
|
||||
// `null` items are converted to dividers
|
||||
static const selection = [
|
||||
ChipSetAction.setCover,
|
||||
ChipSetAction.pin,
|
||||
|
@ -50,9 +58,13 @@ class ChipSetActions {
|
|||
ChipSetAction.delete,
|
||||
ChipSetAction.rename,
|
||||
ChipSetAction.hide,
|
||||
null,
|
||||
ChipSetAction.map,
|
||||
ChipSetAction.slideshow,
|
||||
ChipSetAction.stats,
|
||||
null,
|
||||
ChipSetAction.configureVault,
|
||||
ChipSetAction.lockVault,
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -76,6 +88,8 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return context.l10n.collectionActionShowTitleSearch;
|
||||
case ChipSetAction.createAlbum:
|
||||
return context.l10n.chipActionCreateAlbum;
|
||||
case ChipSetAction.createVault:
|
||||
return context.l10n.chipActionCreateVault;
|
||||
// browsing or selecting
|
||||
case ChipSetAction.map:
|
||||
return context.l10n.menuActionMap;
|
||||
|
@ -92,11 +106,15 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return context.l10n.chipActionPin;
|
||||
case ChipSetAction.unpin:
|
||||
return context.l10n.chipActionUnpin;
|
||||
case ChipSetAction.lockVault:
|
||||
return context.l10n.chipActionLock;
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
return context.l10n.chipActionRename;
|
||||
case ChipSetAction.setCover:
|
||||
return context.l10n.chipActionSetCover;
|
||||
case ChipSetAction.configureVault:
|
||||
return context.l10n.chipActionConfigureVault;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -121,6 +139,8 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return AIcons.filter;
|
||||
case ChipSetAction.createAlbum:
|
||||
return AIcons.add;
|
||||
case ChipSetAction.createVault:
|
||||
return AIcons.vaultAdd;
|
||||
// browsing or selecting
|
||||
case ChipSetAction.map:
|
||||
return AIcons.map;
|
||||
|
@ -137,11 +157,15 @@ extension ExtraChipSetAction on ChipSetAction {
|
|||
return AIcons.pin;
|
||||
case ChipSetAction.unpin:
|
||||
return AIcons.unpin;
|
||||
case ChipSetAction.lockVault:
|
||||
return AIcons.vaultLock;
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
return AIcons.name;
|
||||
case ChipSetAction.setCover:
|
||||
return AIcons.setCover;
|
||||
case ChipSetAction.configureVault:
|
||||
return AIcons.vaultConfigure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'dart:async';
|
|||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
|
@ -38,6 +39,8 @@ class Covers {
|
|||
Set<CoverRow> get all => Set.unmodifiable(_rows);
|
||||
|
||||
Tuple3<int?, String?, Color?>? of(CollectionFilter filter) {
|
||||
if (filter is AlbumFilter && vaults.isLocked(filter.album)) return null;
|
||||
|
||||
final row = _rows.firstWhereOrNull((row) => row.filter == filter);
|
||||
return row != null ? Tuple3(row.entryId, row.packageName, row.color) : null;
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/model/vaults/details.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
|
||||
abstract class MetadataDb {
|
||||
|
@ -16,17 +17,17 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> reset();
|
||||
|
||||
Future<void> removeIds(Iterable<int> ids, {Set<EntryDataType>? dataTypes});
|
||||
Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes});
|
||||
|
||||
// entries
|
||||
|
||||
Future<void> clearEntries();
|
||||
|
||||
Future<Set<AvesEntry>> loadEntries({String? directory});
|
||||
Future<Set<AvesEntry>> loadEntries({int? origin, String? directory});
|
||||
|
||||
Future<Set<AvesEntry>> loadEntriesById(Iterable<int> ids);
|
||||
Future<Set<AvesEntry>> loadEntriesById(Set<int> ids);
|
||||
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries);
|
||||
Future<void> saveEntries(Set<AvesEntry> entries);
|
||||
|
||||
Future<void> updateEntry(int id, AvesEntry entry);
|
||||
|
||||
|
@ -44,7 +45,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadata();
|
||||
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Iterable<int> ids);
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Set<int> ids);
|
||||
|
||||
Future<void> saveCatalogMetadata(Set<CatalogMetadata> metadataEntries);
|
||||
|
||||
|
@ -56,12 +57,24 @@ abstract class MetadataDb {
|
|||
|
||||
Future<Set<AddressDetails>> loadAddresses();
|
||||
|
||||
Future<Set<AddressDetails>> loadAddressesById(Iterable<int> ids);
|
||||
Future<Set<AddressDetails>> loadAddressesById(Set<int> ids);
|
||||
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses);
|
||||
|
||||
Future<void> updateAddress(int id, AddressDetails? address);
|
||||
|
||||
// vaults
|
||||
|
||||
Future<void> clearVaults();
|
||||
|
||||
Future<Set<VaultDetails>> loadAllVaults();
|
||||
|
||||
Future<void> addVaults(Set<VaultDetails> rows);
|
||||
|
||||
Future<void> updateVault(String oldName, VaultDetails row);
|
||||
|
||||
Future<void> removeVaults(Set<VaultDetails> rows);
|
||||
|
||||
// trash
|
||||
|
||||
Future<void> clearTrashDetails();
|
||||
|
@ -76,11 +89,11 @@ abstract class MetadataDb {
|
|||
|
||||
Future<Set<FavouriteRow>> loadAllFavourites();
|
||||
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows);
|
||||
Future<void> addFavourites(Set<FavouriteRow> rows);
|
||||
|
||||
Future<void> updateFavouriteId(int id, FavouriteRow row);
|
||||
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows);
|
||||
Future<void> removeFavourites(Set<FavouriteRow> rows);
|
||||
|
||||
// covers
|
||||
|
||||
|
@ -88,7 +101,7 @@ abstract class MetadataDb {
|
|||
|
||||
Future<Set<CoverRow>> loadAllCovers();
|
||||
|
||||
Future<void> addCovers(Iterable<CoverRow> rows);
|
||||
Future<void> addCovers(Set<CoverRow> rows);
|
||||
|
||||
Future<void> updateCoverEntryId(int id, CoverRow row);
|
||||
|
||||
|
@ -104,5 +117,5 @@ abstract class MetadataDb {
|
|||
|
||||
Future<void> addVideoPlayback(Set<VideoPlaybackRow> rows);
|
||||
|
||||
Future<void> removeVideoPlayback(Iterable<int> ids);
|
||||
Future<void> removeVideoPlayback(Set<int> ids);
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import 'package:aves/model/filters/filters.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/model/vaults/details.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -26,6 +27,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
static const addressTable = 'address';
|
||||
static const favouriteTable = 'favourites';
|
||||
static const coverTable = 'covers';
|
||||
static const vaultTable = 'vaults';
|
||||
static const trashTable = 'trash';
|
||||
static const videoPlaybackTable = 'videoPlayback';
|
||||
|
||||
|
@ -55,6 +57,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
', sourceDateTakenMillis INTEGER'
|
||||
', durationMillis INTEGER'
|
||||
', trashed INTEGER DEFAULT 0'
|
||||
', origin INTEGER DEFAULT 0'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $dateTakenTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
|
@ -89,6 +92,12 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
', packageName TEXT'
|
||||
', color INTEGER'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $vaultTable('
|
||||
'name TEXT PRIMARY KEY'
|
||||
', autoLock INTEGER'
|
||||
', useBin INTEGER'
|
||||
', lockType TEXT'
|
||||
')');
|
||||
await db.execute('CREATE TABLE $trashTable('
|
||||
'id INTEGER PRIMARY KEY'
|
||||
', path TEXT'
|
||||
|
@ -100,7 +109,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
')');
|
||||
},
|
||||
onUpgrade: MetadataDbUpgrader.upgradeDb,
|
||||
version: 10,
|
||||
version: 11,
|
||||
);
|
||||
|
||||
final maxIdRows = await _db.rawQuery('SELECT max(id) AS maxId FROM $entryTable');
|
||||
|
@ -122,7 +131,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> removeIds(Iterable<int> ids, {Set<EntryDataType>? dataTypes}) async {
|
||||
Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes}) async {
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
final _dataTypes = dataTypes ?? EntryDataType.values.toSet();
|
||||
|
@ -162,15 +171,23 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries({String? directory}) async {
|
||||
Future<Set<AvesEntry>> loadEntries({int? origin, String? directory}) async {
|
||||
String? where;
|
||||
final whereArgs = <Object?>[];
|
||||
|
||||
if (origin != null) {
|
||||
where = 'origin = ?';
|
||||
whereArgs.add(origin);
|
||||
}
|
||||
|
||||
if (directory != null) {
|
||||
final separator = pContext.separator;
|
||||
if (!directory.endsWith(separator)) {
|
||||
directory = '$directory$separator';
|
||||
}
|
||||
|
||||
const where = 'path LIKE ?';
|
||||
final whereArgs = ['$directory%'];
|
||||
where = '${where != null ? '$where AND ' : ''}path LIKE ?';
|
||||
whereArgs.add('$directory%');
|
||||
final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs);
|
||||
|
||||
final dirLength = directory.length;
|
||||
|
@ -184,15 +201,15 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
.toSet();
|
||||
}
|
||||
|
||||
final rows = await _db.query(entryTable);
|
||||
final rows = await _db.query(entryTable, where: where, whereArgs: whereArgs);
|
||||
return rows.map(AvesEntry.fromMap).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntriesById(Iterable<int> ids) => _getByIds(ids, entryTable, AvesEntry.fromMap);
|
||||
Future<Set<AvesEntry>> loadEntriesById(Set<int> ids) => _getByIds(ids, entryTable, AvesEntry.fromMap);
|
||||
|
||||
@override
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries) async {
|
||||
Future<void> saveEntries(Set<AvesEntry> entries) async {
|
||||
if (entries.isEmpty) return;
|
||||
final stopwatch = Stopwatch()..start();
|
||||
final batch = _db.batch();
|
||||
|
@ -258,7 +275,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Iterable<int> ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap);
|
||||
Future<Set<CatalogMetadata>> loadCatalogMetadataById(Set<int> ids) => _getByIds(ids, metadataTable, CatalogMetadata.fromMap);
|
||||
|
||||
@override
|
||||
Future<void> saveCatalogMetadata(Set<CatalogMetadata> metadataEntries) async {
|
||||
|
@ -317,7 +334,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<Set<AddressDetails>> loadAddressesById(Iterable<int> ids) => _getByIds(ids, addressTable, AddressDetails.fromMap);
|
||||
Future<Set<AddressDetails>> loadAddressesById(Set<int> ids) => _getByIds(ids, addressTable, AddressDetails.fromMap);
|
||||
|
||||
@override
|
||||
Future<void> saveAddresses(Set<AddressDetails> addresses) async {
|
||||
|
@ -346,6 +363,54 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
);
|
||||
}
|
||||
|
||||
// vaults
|
||||
|
||||
@override
|
||||
Future<void> clearVaults() async {
|
||||
final count = await _db.delete(vaultTable, where: '1');
|
||||
debugPrint('$runtimeType clearVaults deleted $count rows');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Set<VaultDetails>> loadAllVaults() async {
|
||||
final rows = await _db.query(vaultTable);
|
||||
return rows.map(VaultDetails.fromMap).toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> addVaults(Set<VaultDetails> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
final batch = _db.batch();
|
||||
rows.forEach((row) => _batchInsertVault(batch, row));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> updateVault(String oldName, VaultDetails row) async {
|
||||
final batch = _db.batch();
|
||||
batch.delete(vaultTable, where: 'name = ?', whereArgs: [oldName]);
|
||||
_batchInsertVault(batch, row);
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
void _batchInsertVault(Batch batch, VaultDetails row) {
|
||||
batch.insert(
|
||||
vaultTable,
|
||||
row.toMap(),
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeVaults(Set<VaultDetails> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
// using array in `whereArgs` and using it with `where id IN ?` is a pain, so we prefer `batch` instead
|
||||
final batch = _db.batch();
|
||||
rows.map((v) => v.name).forEach((name) => batch.delete(vaultTable, where: 'name = ?', whereArgs: [name]));
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
// trash
|
||||
|
||||
@override
|
||||
|
@ -392,7 +457,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows) async {
|
||||
Future<void> addFavourites(Set<FavouriteRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
final batch = _db.batch();
|
||||
rows.forEach((row) => _batchInsertFavourite(batch, row));
|
||||
|
@ -416,7 +481,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows) async {
|
||||
Future<void> removeFavourites(Set<FavouriteRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
final ids = rows.map((row) => row.entryId);
|
||||
if (ids.isEmpty) return;
|
||||
|
@ -442,7 +507,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> addCovers(Iterable<CoverRow> rows) async {
|
||||
Future<void> addCovers(Set<CoverRow> rows) async {
|
||||
if (rows.isEmpty) return;
|
||||
|
||||
final batch = _db.batch();
|
||||
|
@ -532,7 +597,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<void> removeVideoPlayback(Iterable<int> ids) async {
|
||||
Future<void> removeVideoPlayback(Set<int> ids) async {
|
||||
if (ids.isEmpty) return;
|
||||
|
||||
// using array in `whereArgs` and using it with `where filter IN ?` is a pain, so we prefer `batch` instead
|
||||
|
@ -543,7 +608,7 @@ class SqfliteMetadataDb implements MetadataDb {
|
|||
|
||||
// convenience methods
|
||||
|
||||
Future<Set<T>> _getByIds<T>(Iterable<int> ids, String table, T Function(Map<String, Object?> row) mapRow) async {
|
||||
Future<Set<T>> _getByIds<T>(Set<int> ids, String table, T Function(Map<String, Object?> row) mapRow) async {
|
||||
if (ids.isEmpty) return {};
|
||||
final rows = await _db.query(
|
||||
table,
|
||||
|
|
|
@ -10,6 +10,7 @@ class MetadataDbUpgrader {
|
|||
static const addressTable = SqfliteMetadataDb.addressTable;
|
||||
static const favouriteTable = SqfliteMetadataDb.favouriteTable;
|
||||
static const coverTable = SqfliteMetadataDb.coverTable;
|
||||
static const vaultTable = SqfliteMetadataDb.vaultTable;
|
||||
static const trashTable = SqfliteMetadataDb.trashTable;
|
||||
static const videoPlaybackTable = SqfliteMetadataDb.videoPlaybackTable;
|
||||
|
||||
|
@ -45,6 +46,9 @@ class MetadataDbUpgrader {
|
|||
case 9:
|
||||
await _upgradeFrom9(db);
|
||||
break;
|
||||
case 10:
|
||||
await _upgradeFrom10(db);
|
||||
break;
|
||||
}
|
||||
oldVersion++;
|
||||
}
|
||||
|
@ -370,4 +374,17 @@ class MetadataDbUpgrader {
|
|||
});
|
||||
await batch.commit(noResult: true);
|
||||
}
|
||||
|
||||
static Future<void> _upgradeFrom10(Database db) async {
|
||||
debugPrint('upgrading DB from v10');
|
||||
|
||||
await db.execute('ALTER TABLE $entryTable ADD COLUMN origin INTEGER DEFAULT 0;');
|
||||
|
||||
await db.execute('CREATE TABLE $vaultTable('
|
||||
'name TEXT PRIMARY KEY'
|
||||
', autoLock INTEGER'
|
||||
', useBin INTEGER'
|
||||
', lockType TEXT'
|
||||
')');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,16 +1,20 @@
|
|||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
final Device device = Device._private();
|
||||
|
||||
class Device {
|
||||
late final String _userAgent;
|
||||
late final bool _canGrantDirectoryAccess, _canPinShortcut, _canPrint, _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper;
|
||||
late final bool _canAuthenticateUser, _canGrantDirectoryAccess, _canPinShortcut, _canPrint;
|
||||
late final bool _canRenderFlagEmojis, _canRequestManageMedia, _canSetLockScreenWallpaper, _canUseCrypto;
|
||||
late final bool _hasGeocoder, _isDynamicColorAvailable, _isTelevision, _showPinShortcutFeedback, _supportEdgeToEdgeUIMode;
|
||||
|
||||
String get userAgent => _userAgent;
|
||||
|
||||
bool get canAuthenticateUser => _canAuthenticateUser;
|
||||
|
||||
bool get canGrantDirectoryAccess => _canGrantDirectoryAccess;
|
||||
|
||||
bool get canPinShortcut => _canPinShortcut;
|
||||
|
@ -23,6 +27,10 @@ class Device {
|
|||
|
||||
bool get canSetLockScreenWallpaper => _canSetLockScreenWallpaper;
|
||||
|
||||
bool get canUseCrypto => _canUseCrypto;
|
||||
|
||||
bool get canUseVaults => canAuthenticateUser || canUseCrypto;
|
||||
|
||||
bool get hasGeocoder => _hasGeocoder;
|
||||
|
||||
bool get isDynamicColorAvailable => _isDynamicColorAvailable;
|
||||
|
@ -42,6 +50,9 @@ class Device {
|
|||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
_isTelevision = androidInfo.systemFeatures.contains('android.software.leanback');
|
||||
|
||||
final auth = LocalAuthentication();
|
||||
_canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported();
|
||||
|
||||
final capabilities = await deviceService.getCapabilities();
|
||||
_canGrantDirectoryAccess = capabilities['canGrantDirectoryAccess'] ?? false;
|
||||
_canPinShortcut = capabilities['canPinShortcut'] ?? false;
|
||||
|
@ -49,6 +60,7 @@ class Device {
|
|||
_canRenderFlagEmojis = capabilities['canRenderFlagEmojis'] ?? false;
|
||||
_canRequestManageMedia = capabilities['canRequestManageMedia'] ?? false;
|
||||
_canSetLockScreenWallpaper = capabilities['canSetLockScreenWallpaper'] ?? false;
|
||||
_canUseCrypto = capabilities['canUseCrypto'] ?? false;
|
||||
_hasGeocoder = capabilities['hasGeocoder'] ?? false;
|
||||
_isDynamicColorAvailable = capabilities['isDynamicColorAvailable'] ?? false;
|
||||
_showPinShortcutFeedback = capabilities['showPinShortcutFeedback'] ?? false;
|
||||
|
|
|
@ -20,6 +20,7 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:aves/services/geocoding_service.dart';
|
||||
import 'package:aves/services/metadata/svg_metadata_service.dart';
|
||||
import 'package:aves/theme/format.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/change_notifier.dart';
|
||||
import 'package:aves/utils/time_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -29,6 +30,13 @@ import 'package:latlong2/latlong.dart';
|
|||
|
||||
enum EntryDataType { basic, aspectRatio, catalog, address, references }
|
||||
|
||||
class EntryOrigins {
|
||||
static const int mediaStoreContent = 0;
|
||||
static const int unknownContent = 1;
|
||||
static const int file = 2;
|
||||
static const int vault = 3;
|
||||
}
|
||||
|
||||
class AvesEntry {
|
||||
// `sizeBytes`, `dateModifiedSecs` can be missing in viewer mode
|
||||
int id;
|
||||
|
@ -40,6 +48,7 @@ class AvesEntry {
|
|||
int width, height, sourceRotationDegrees;
|
||||
int? sizeBytes, dateAddedSecs, _dateModifiedSecs, sourceDateTakenMillis, _durationMillis;
|
||||
bool trashed;
|
||||
int origin;
|
||||
|
||||
int? _catalogDateMillis;
|
||||
CatalogMetadata? _catalogMetadata;
|
||||
|
@ -67,6 +76,7 @@ class AvesEntry {
|
|||
required this.sourceDateTakenMillis,
|
||||
required int? durationMillis,
|
||||
required this.trashed,
|
||||
required this.origin,
|
||||
this.burstEntries,
|
||||
}) : id = id ?? 0 {
|
||||
this.path = path;
|
||||
|
@ -87,6 +97,7 @@ class AvesEntry {
|
|||
String? title,
|
||||
int? dateAddedSecs,
|
||||
int? dateModifiedSecs,
|
||||
int? origin,
|
||||
List<AvesEntry>? burstEntries,
|
||||
}) {
|
||||
final copyEntryId = id ?? this.id;
|
||||
|
@ -107,6 +118,7 @@ class AvesEntry {
|
|||
sourceDateTakenMillis: sourceDateTakenMillis,
|
||||
durationMillis: durationMillis,
|
||||
trashed: trashed,
|
||||
origin: origin ?? this.origin,
|
||||
burstEntries: burstEntries ?? this.burstEntries,
|
||||
)
|
||||
..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId)
|
||||
|
@ -135,6 +147,7 @@ class AvesEntry {
|
|||
sourceDateTakenMillis: map['sourceDateTakenMillis'] as int?,
|
||||
durationMillis: map['durationMillis'] as int?,
|
||||
trashed: (map['trashed'] as int? ?? 0) != 0,
|
||||
origin: map['origin'] as int,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -156,6 +169,7 @@ class AvesEntry {
|
|||
'sourceDateTakenMillis': sourceDateTakenMillis,
|
||||
'durationMillis': durationMillis,
|
||||
'trashed': trashed ? 1 : 0,
|
||||
'origin': origin,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -173,6 +187,7 @@ class AvesEntry {
|
|||
'sizeBytes': sizeBytes,
|
||||
'trashed': trashed,
|
||||
'trashPath': trashDetails?.path,
|
||||
'origin': origin,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -281,7 +296,9 @@ class AvesEntry {
|
|||
|
||||
bool get isMediaStoreMediaContent => isMediaStoreContent && {'/external/images/', '/external/video/'}.any(uri.contains);
|
||||
|
||||
bool get canEdit => !settings.isReadOnly && path != null && !trashed && isMediaStoreContent;
|
||||
bool get isVaultContent => path?.startsWith(androidFileUtils.vaultRoot) ?? false;
|
||||
|
||||
bool get canEdit => !settings.isReadOnly && path != null && !trashed && (isMediaStoreContent || isVaultContent);
|
||||
|
||||
bool get canEditDate => canEdit && (canEditExif || canEditXmp);
|
||||
|
||||
|
|
|
@ -26,7 +26,7 @@ class Favourites with ChangeNotifier {
|
|||
FavouriteRow _entryToRow(AvesEntry entry) => FavouriteRow(entryId: entry.id);
|
||||
|
||||
Future<void> add(Set<AvesEntry> entries) async {
|
||||
final newRows = entries.map(_entryToRow);
|
||||
final newRows = entries.map(_entryToRow).toSet();
|
||||
|
||||
await metadataDb.addFavourites(newRows);
|
||||
_rows.addAll(newRows);
|
||||
|
|
|
@ -73,6 +73,7 @@ class AlbumFilter extends CoveredCollectionFilter {
|
|||
final albumType = covers.effectiveAlbumType(album);
|
||||
switch (albumType) {
|
||||
case AlbumType.regular:
|
||||
case AlbumType.vault:
|
||||
break;
|
||||
case AlbumType.app:
|
||||
final appColor = colors.appColor(album);
|
||||
|
|
|
@ -107,6 +107,7 @@ class MultiPageInfo {
|
|||
sourceDateTakenMillis: mainEntry.sourceDateTakenMillis,
|
||||
durationMillis: pageInfo.durationMillis ?? mainEntry.durationMillis,
|
||||
trashed: trashed,
|
||||
origin: mainEntry.origin,
|
||||
)
|
||||
..catalogMetadata = mainEntry.catalogMetadata?.copyWith(
|
||||
mimeType: pageInfo.mimeType,
|
||||
|
|
|
@ -31,10 +31,7 @@ class SettingsDefaults {
|
|||
static const keepScreenOn = KeepScreenOn.viewerOnly;
|
||||
static const homePage = HomePageSetting.collection;
|
||||
static const enableBottomNavigationBar = true;
|
||||
static const confirmDeleteForever = true;
|
||||
static const confirmMoveToBin = true;
|
||||
static const confirmMoveUndatedItems = true;
|
||||
static const confirmAfterMoveToBin = true;
|
||||
static const confirm = true;
|
||||
static const setMetadataDateBeforeFileOp = false;
|
||||
static final drawerTypeBookmarks = [
|
||||
null,
|
||||
|
|
|
@ -6,7 +6,7 @@ enum AvesThemeBrightness { system, light, dark, black }
|
|||
|
||||
enum AvesThemeColorMode { monochrome, polychrome }
|
||||
|
||||
enum ConfirmationDialog { deleteForever, moveToBin, moveUndatedItems }
|
||||
enum ConfirmationDialog { createVault, deleteForever, moveToBin, moveUndatedItems }
|
||||
|
||||
enum CoordinateFormat { dms, decimal }
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ class Settings extends ChangeNotifier {
|
|||
static const int _recentFilterHistoryMax = 10;
|
||||
static const Set<String> _internalKeys = {
|
||||
hasAcceptedTermsKey,
|
||||
catalogTimeZoneKey,
|
||||
catalogTimeZoneRawOffsetMillisKey,
|
||||
searchHistoryKey,
|
||||
platformAccelerometerRotationKey,
|
||||
platformTransitionAnimationScaleKey,
|
||||
|
@ -57,7 +57,7 @@ class Settings extends ChangeNotifier {
|
|||
static const isInstalledAppAccessAllowedKey = 'is_installed_app_access_allowed';
|
||||
static const isErrorReportingAllowedKey = 'is_crashlytics_enabled';
|
||||
static const localeKey = 'locale';
|
||||
static const catalogTimeZoneKey = 'catalog_time_zone';
|
||||
static const catalogTimeZoneRawOffsetMillisKey = 'catalog_time_zone_raw_offset_millis';
|
||||
static const tileExtentPrefixKey = 'tile_extent_';
|
||||
static const tileLayoutPrefixKey = 'tile_layout_';
|
||||
static const entryRenamingPatternKey = 'entry_renaming_pattern';
|
||||
|
@ -78,6 +78,7 @@ class Settings extends ChangeNotifier {
|
|||
static const keepScreenOnKey = 'keep_screen_on';
|
||||
static const homePageKey = 'home_page';
|
||||
static const enableBottomNavigationBarKey = 'show_bottom_navigation_bar';
|
||||
static const confirmCreateVaultKey = 'confirm_create_vault';
|
||||
static const confirmDeleteForeverKey = 'confirm_delete_forever';
|
||||
static const confirmMoveToBinKey = 'confirm_move_to_bin';
|
||||
static const confirmMoveUndatedItemsKey = 'confirm_move_undated_items';
|
||||
|
@ -282,10 +283,10 @@ class Settings extends ChangeNotifier {
|
|||
}
|
||||
|
||||
Future<void> sanitize() async {
|
||||
if (timeToTakeAction == AccessibilityTimeout.system && !(await AccessibilityService.hasRecommendedTimeouts())) {
|
||||
if (timeToTakeAction == AccessibilityTimeout.system && !await AccessibilityService.hasRecommendedTimeouts()) {
|
||||
_set(timeToTakeActionKey, null);
|
||||
}
|
||||
if (viewerUseCutout != SettingsDefaults.viewerUseCutout && !(await windowService.isCutoutAware())) {
|
||||
if (viewerUseCutout != SettingsDefaults.viewerUseCutout && !await windowService.isCutoutAware()) {
|
||||
_set(viewerUseCutoutKey, null);
|
||||
}
|
||||
}
|
||||
|
@ -361,9 +362,9 @@ class Settings extends ChangeNotifier {
|
|||
return _appliedLocale!;
|
||||
}
|
||||
|
||||
String get catalogTimeZone => getString(catalogTimeZoneKey) ?? '';
|
||||
int get catalogTimeZoneRawOffsetMillis => getInt(catalogTimeZoneRawOffsetMillisKey) ?? 0;
|
||||
|
||||
set catalogTimeZone(String newValue) => _set(catalogTimeZoneKey, newValue);
|
||||
set catalogTimeZoneRawOffsetMillis(int newValue) => _set(catalogTimeZoneRawOffsetMillisKey, newValue);
|
||||
|
||||
double getTileExtent(String routeName) => getDouble(tileExtentPrefixKey + routeName) ?? 0;
|
||||
|
||||
|
@ -437,19 +438,23 @@ class Settings extends ChangeNotifier {
|
|||
|
||||
set enableBottomNavigationBar(bool newValue) => _set(enableBottomNavigationBarKey, newValue);
|
||||
|
||||
bool get confirmDeleteForever => getBool(confirmDeleteForeverKey) ?? SettingsDefaults.confirmDeleteForever;
|
||||
bool get confirmCreateVault => getBool(confirmCreateVaultKey) ?? SettingsDefaults.confirm;
|
||||
|
||||
set confirmCreateVault(bool newValue) => _set(confirmCreateVaultKey, newValue);
|
||||
|
||||
bool get confirmDeleteForever => getBool(confirmDeleteForeverKey) ?? SettingsDefaults.confirm;
|
||||
|
||||
set confirmDeleteForever(bool newValue) => _set(confirmDeleteForeverKey, newValue);
|
||||
|
||||
bool get confirmMoveToBin => getBool(confirmMoveToBinKey) ?? SettingsDefaults.confirmMoveToBin;
|
||||
bool get confirmMoveToBin => getBool(confirmMoveToBinKey) ?? SettingsDefaults.confirm;
|
||||
|
||||
set confirmMoveToBin(bool newValue) => _set(confirmMoveToBinKey, newValue);
|
||||
|
||||
bool get confirmMoveUndatedItems => getBool(confirmMoveUndatedItemsKey) ?? SettingsDefaults.confirmMoveUndatedItems;
|
||||
bool get confirmMoveUndatedItems => getBool(confirmMoveUndatedItemsKey) ?? SettingsDefaults.confirm;
|
||||
|
||||
set confirmMoveUndatedItems(bool newValue) => _set(confirmMoveUndatedItemsKey, newValue);
|
||||
|
||||
bool get confirmAfterMoveToBin => getBool(confirmAfterMoveToBinKey) ?? SettingsDefaults.confirmAfterMoveToBin;
|
||||
bool get confirmAfterMoveToBin => getBool(confirmAfterMoveToBinKey) ?? SettingsDefaults.confirm;
|
||||
|
||||
set confirmAfterMoveToBin(bool newValue) => _set(confirmAfterMoveToBinKey, newValue);
|
||||
|
||||
|
@ -1019,6 +1024,7 @@ class Settings extends ChangeNotifier {
|
|||
case enableBlurEffectKey:
|
||||
case enableBottomNavigationBarKey:
|
||||
case mustBackTwiceToExitKey:
|
||||
case confirmCreateVaultKey:
|
||||
case confirmDeleteForeverKey:
|
||||
case confirmMoveToBinKey:
|
||||
case confirmMoveUndatedItemsKey:
|
||||
|
|
|
@ -2,6 +2,7 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/collection_utils.dart';
|
||||
|
@ -61,8 +62,10 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
|
||||
void updateDirectories() {
|
||||
final visibleDirectories = visibleEntries.map((entry) => entry.directory).toSet();
|
||||
addDirectories(albums: visibleDirectories);
|
||||
addDirectories(albums: {
|
||||
...visibleEntries.map((entry) => entry.directory),
|
||||
...vaults.all.map((v) => v.path),
|
||||
});
|
||||
cleanEmptyAlbums();
|
||||
}
|
||||
|
||||
|
@ -73,22 +76,24 @@ mixin AlbumMixin on SourceBase {
|
|||
}
|
||||
}
|
||||
|
||||
void cleanEmptyAlbums([Set<String?>? albums]) {
|
||||
final emptyAlbums = (albums ?? _directories).where((v) => _isEmptyAlbum(v) && !_newAlbums.contains(v)).toSet();
|
||||
if (emptyAlbums.isNotEmpty) {
|
||||
_directories.removeAll(emptyAlbums);
|
||||
void cleanEmptyAlbums([Set<String>? albums]) {
|
||||
final removableAlbums = (albums ?? _directories).where(_isRemovable).toSet();
|
||||
if (removableAlbums.isNotEmpty) {
|
||||
_directories.removeAll(removableAlbums);
|
||||
_onAlbumChanged();
|
||||
invalidateAlbumFilterSummary(directories: emptyAlbums);
|
||||
invalidateAlbumFilterSummary(directories: removableAlbums);
|
||||
|
||||
final bookmarks = settings.drawerAlbumBookmarks;
|
||||
emptyAlbums.forEach((album) {
|
||||
removableAlbums.forEach((album) {
|
||||
bookmarks?.remove(album);
|
||||
});
|
||||
settings.drawerAlbumBookmarks = bookmarks;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isEmptyAlbum(String? album) => !visibleEntries.any((entry) => entry.directory == album);
|
||||
bool _isRemovable(String album) {
|
||||
return !(visibleEntries.any((entry) => entry.directory == album) || _newAlbums.contains(album) || vaults.isVault(album));
|
||||
}
|
||||
|
||||
// filter summary
|
||||
|
||||
|
@ -166,8 +171,8 @@ mixin AlbumMixin on SourceBase {
|
|||
final separator = pContext.separator;
|
||||
assert(!dirPath.endsWith(separator));
|
||||
|
||||
final type = androidFileUtils.getAlbumType(dirPath);
|
||||
if (context != null) {
|
||||
final type = androidFileUtils.getAlbumType(dirPath);
|
||||
switch (type) {
|
||||
case AlbumType.camera:
|
||||
return context.l10n.albumCamera;
|
||||
|
@ -180,11 +185,14 @@ mixin AlbumMixin on SourceBase {
|
|||
case AlbumType.videoCaptures:
|
||||
return context.l10n.albumVideoCaptures;
|
||||
case AlbumType.regular:
|
||||
case AlbumType.vault:
|
||||
case AlbumType.app:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (type == AlbumType.vault) return pContext.basename(dirPath);
|
||||
|
||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||
if (dir == null) return dirPath;
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ import 'package:aves/model/source/events.dart';
|
|||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/model/source/trash.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/analysis_service.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -60,9 +61,14 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
final oldValue = event.oldValue;
|
||||
if (oldValue is List<String>?) {
|
||||
final oldHiddenFilters = (oldValue ?? []).map(CollectionFilter.fromJson).whereNotNull().toSet();
|
||||
_onFilterVisibilityChanged(oldHiddenFilters, settings.hiddenFilters);
|
||||
final newlyVisibleFilters = oldHiddenFilters.whereNot(settings.hiddenFilters.contains).toSet();
|
||||
_onFilterVisibilityChanged(newlyVisibleFilters);
|
||||
}
|
||||
});
|
||||
vaults.addListener(() {
|
||||
final newlyVisibleFilters = vaults.vaultDirectories.whereNot(vaults.isLocked).map((v) => AlbumFilter(v, null)).toSet();
|
||||
_onFilterVisibilityChanged(newlyVisibleFilters);
|
||||
});
|
||||
}
|
||||
|
||||
final EventBus _eventBus = EventBus();
|
||||
|
@ -108,16 +114,22 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
_savedDates = Map.unmodifiable(await metadataDb.loadDates());
|
||||
}
|
||||
|
||||
Set<CollectionFilter> _getAppHiddenFilters() => {
|
||||
...settings.hiddenFilters,
|
||||
...vaults.vaultDirectories.where(vaults.isLocked).map((v) => AlbumFilter(v, null)),
|
||||
};
|
||||
|
||||
Iterable<AvesEntry> _applyHiddenFilters(Iterable<AvesEntry> entries) {
|
||||
final hiddenFilters = {
|
||||
TrashFilter.instance,
|
||||
...settings.hiddenFilters,
|
||||
..._getAppHiddenFilters(),
|
||||
};
|
||||
return entries.where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
|
||||
}
|
||||
|
||||
Iterable<AvesEntry> _applyTrashFilter(Iterable<AvesEntry> entries) {
|
||||
return entries.where(TrashFilter.instance.test);
|
||||
final hiddenFilters = _getAppHiddenFilters();
|
||||
return entries.where(TrashFilter.instance.test).where((entry) => !hiddenFilters.any((filter) => filter.test(entry)));
|
||||
}
|
||||
|
||||
void _invalidate({Set<AvesEntry>? entries, bool notify = true}) {
|
||||
|
@ -198,23 +210,24 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
|
||||
Future<void> _moveEntry(AvesEntry entry, Map newFields, {required bool persist}) async {
|
||||
newFields.keys.forEach((key) {
|
||||
final newValue = newFields[key];
|
||||
switch (key) {
|
||||
case 'contentId':
|
||||
entry.contentId = newFields['contentId'] as int?;
|
||||
entry.contentId = newValue as int?;
|
||||
break;
|
||||
case 'dateModifiedSecs':
|
||||
// `dateModifiedSecs` changes when moving entries to another directory,
|
||||
// but it does not change when renaming the containing directory
|
||||
entry.dateModifiedSecs = newFields['dateModifiedSecs'] as int?;
|
||||
entry.dateModifiedSecs = newValue as int?;
|
||||
break;
|
||||
case 'path':
|
||||
entry.path = newFields['path'] as String?;
|
||||
entry.path = newValue as String?;
|
||||
break;
|
||||
case 'title':
|
||||
entry.sourceTitle = newFields['title'] as String?;
|
||||
entry.sourceTitle = newValue as String?;
|
||||
break;
|
||||
case 'trashed':
|
||||
final trashed = newFields['trashed'] as bool;
|
||||
final trashed = newValue as bool;
|
||||
entry.trashed = trashed;
|
||||
entry.trashDetails = trashed
|
||||
? TrashDetails(
|
||||
|
@ -225,7 +238,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
: null;
|
||||
break;
|
||||
case 'uri':
|
||||
entry.uri = newFields['uri'] as String;
|
||||
entry.uri = newValue as String;
|
||||
break;
|
||||
case 'origin':
|
||||
entry.origin = newValue as int;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
@ -251,6 +267,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
final bookmark = settings.drawerAlbumBookmarks?.indexOf(sourceAlbum);
|
||||
final pinned = settings.pinnedFilters.contains(oldFilter);
|
||||
|
||||
if (vaults.isVault(sourceAlbum)) {
|
||||
await vaults.rename(sourceAlbum, destinationAlbum);
|
||||
}
|
||||
|
||||
final existingCover = covers.of(oldFilter);
|
||||
await covers.set(
|
||||
filter: newFilter,
|
||||
|
@ -266,6 +286,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
destinationAlbums: {destinationAlbum},
|
||||
movedOps: movedOps,
|
||||
);
|
||||
|
||||
// restore bookmark and pin, as the obsolete album got removed and its associated state cleaned
|
||||
if (bookmark != null && bookmark != -1) {
|
||||
settings.drawerAlbumBookmarks = settings.drawerAlbumBookmarks?..insert(bookmark, destinationAlbum);
|
||||
|
@ -312,6 +333,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
title: newFields['title'] as String?,
|
||||
dateAddedSecs: newFields['dateAddedSecs'] as int?,
|
||||
dateModifiedSecs: newFields['dateModifiedSecs'] as int?,
|
||||
origin: newFields['origin'] as int?,
|
||||
));
|
||||
} else {
|
||||
debugPrint('failed to find source entry with uri=$sourceUri');
|
||||
|
@ -345,7 +367,7 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
break;
|
||||
case MoveType.move:
|
||||
case MoveType.export:
|
||||
cleanEmptyAlbums(fromAlbums);
|
||||
cleanEmptyAlbums(fromAlbums.whereNotNull().toSet());
|
||||
addDirectories(albums: destinationAlbums);
|
||||
break;
|
||||
case MoveType.toBin:
|
||||
|
@ -507,11 +529,10 @@ abstract class CollectionSource with SourceBase, AlbumMixin, LocationMixin, TagM
|
|||
return recentEntry(filter);
|
||||
}
|
||||
|
||||
void _onFilterVisibilityChanged(Set<CollectionFilter> oldHiddenFilters, Set<CollectionFilter> currentHiddenFilters) {
|
||||
void _onFilterVisibilityChanged(Set<CollectionFilter> newlyVisibleFilters) {
|
||||
updateDerivedFilters();
|
||||
eventBus.fire(const FilterVisibilityChangedEvent());
|
||||
|
||||
final newlyVisibleFilters = oldHiddenFilters.whereNot(currentHiddenFilters.contains).toSet();
|
||||
if (newlyVisibleFilters.isNotEmpty) {
|
||||
final candidateEntries = visibleEntries.where((entry) => newlyVisibleFilters.any((f) => f.test(entry))).toSet();
|
||||
analyze(null, entries: candidateEntries);
|
||||
|
|
|
@ -8,6 +8,7 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums/enums.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -44,17 +45,18 @@ class MediaStoreSource extends CollectionSource {
|
|||
final stopwatch = Stopwatch()..start();
|
||||
state = SourceState.loading;
|
||||
await metadataDb.init();
|
||||
await vaults.init();
|
||||
await favourites.init();
|
||||
await covers.init();
|
||||
final currentTimeZone = await deviceService.getDefaultTimeZone();
|
||||
if (currentTimeZone != null) {
|
||||
final catalogTimeZone = settings.catalogTimeZone;
|
||||
if (currentTimeZone != catalogTimeZone) {
|
||||
final currentTimeZoneOffset = await deviceService.getDefaultTimeZoneRawOffsetMillis();
|
||||
if (currentTimeZoneOffset != null) {
|
||||
final catalogTimeZoneOffset = settings.catalogTimeZoneRawOffsetMillis;
|
||||
if (currentTimeZoneOffset != catalogTimeZoneOffset) {
|
||||
// clear catalog metadata to get correct date/times when moving to a different time zone
|
||||
debugPrint('$runtimeType clear catalog metadata to get correct date/times');
|
||||
await metadataDb.clearDates();
|
||||
await metadataDb.clearCatalogMetadata();
|
||||
settings.catalogTimeZone = currentTimeZone;
|
||||
settings.catalogTimeZoneRawOffsetMillis = currentTimeZoneOffset;
|
||||
}
|
||||
}
|
||||
await loadDates();
|
||||
|
@ -74,7 +76,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
|
||||
final Set<AvesEntry> topEntries = {};
|
||||
if (loadTopEntriesFirst) {
|
||||
final topIds = settings.topEntryIds;
|
||||
final topIds = settings.topEntryIds?.toSet();
|
||||
if (topIds != null) {
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load ${topIds.length} top entries');
|
||||
topEntries.addAll(await metadataDb.loadEntriesById(topIds));
|
||||
|
@ -83,7 +85,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
}
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} fetch known entries');
|
||||
final knownEntries = await metadataDb.loadEntries(directory: directory);
|
||||
final knownEntries = await metadataDb.loadEntries(origin: EntryOrigins.mediaStoreContent, directory: directory);
|
||||
final knownLiveEntries = knownEntries.where((entry) => !entry.trashed).toSet();
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} check obsolete entries');
|
||||
|
@ -103,6 +105,8 @@ class MediaStoreSource extends CollectionSource {
|
|||
// with items that may be hidden right away because of their metadata
|
||||
addEntries(knownEntries, notify: false);
|
||||
|
||||
await _addVaultEntries(directory);
|
||||
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} load metadata');
|
||||
if (directory != null) {
|
||||
final ids = knownLiveEntries.map((entry) => entry.id).toSet();
|
||||
|
@ -129,7 +133,7 @@ class MediaStoreSource extends CollectionSource {
|
|||
// clean up obsolete entries
|
||||
if (removedEntries.isNotEmpty) {
|
||||
debugPrint('$runtimeType refresh ${stopwatch.elapsed} remove obsolete entries');
|
||||
await metadataDb.removeIds(removedEntries.map((entry) => entry.id));
|
||||
await metadataDb.removeIds(removedEntries.map((entry) => entry.id).toSet());
|
||||
}
|
||||
|
||||
// verify paths because some apps move files without updating their `last modified date`
|
||||
|
@ -274,6 +278,36 @@ class MediaStoreSource extends CollectionSource {
|
|||
await refreshEntries(entriesToRefresh, EntryDataType.values.toSet());
|
||||
}
|
||||
|
||||
await _refreshVaultEntries(changedUris.where(vaults.isVaultEntryUri).toSet());
|
||||
|
||||
return tempUris;
|
||||
}
|
||||
|
||||
// vault
|
||||
|
||||
Future<void> _addVaultEntries(String? directory) async {
|
||||
addEntries(await metadataDb.loadEntries(origin: EntryOrigins.vault, directory: directory));
|
||||
}
|
||||
|
||||
Future<void> _refreshVaultEntries(Set<String> changedUris) async {
|
||||
final entriesToRefresh = <AvesEntry>{};
|
||||
final existingDirectories = <String>{};
|
||||
|
||||
for (final uri in changedUris) {
|
||||
final existingEntry = allEntries.firstWhereOrNull((entry) => entry.uri == uri);
|
||||
if (existingEntry != null) {
|
||||
entriesToRefresh.add(existingEntry);
|
||||
final existingDirectory = existingEntry.directory;
|
||||
if (existingDirectory != null) {
|
||||
existingDirectories.add(existingDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
invalidateAlbumFilterSummary(directories: existingDirectories);
|
||||
|
||||
if (entriesToRefresh.isNotEmpty) {
|
||||
await refreshEntries(entriesToRefresh, EntryDataType.values.toSet());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
57
lib/model/vaults/details.dart
Normal file
57
lib/model/vaults/details.dart
Normal file
|
@ -0,0 +1,57 @@
|
|||
import 'package:aves/model/vaults/enums.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/collection_utils.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
@immutable
|
||||
class VaultDetails extends Equatable {
|
||||
final String name;
|
||||
final bool autoLockScreenOff, useBin;
|
||||
final VaultLockType lockType;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, autoLockScreenOff, useBin, lockType];
|
||||
|
||||
const VaultDetails({
|
||||
required this.name,
|
||||
required this.autoLockScreenOff,
|
||||
required this.useBin,
|
||||
required this.lockType,
|
||||
});
|
||||
|
||||
VaultDetails copyWith({
|
||||
String? name,
|
||||
}) {
|
||||
return VaultDetails(
|
||||
name: name ?? this.name,
|
||||
autoLockScreenOff: autoLockScreenOff,
|
||||
useBin: useBin,
|
||||
lockType: lockType,
|
||||
);
|
||||
}
|
||||
|
||||
factory VaultDetails.fromMap(Map map) {
|
||||
return VaultDetails(
|
||||
name: map['name'] as String,
|
||||
autoLockScreenOff: (map['autoLock'] as int? ?? 0) != 0,
|
||||
useBin: (map['useBin'] as int? ?? 0) != 0,
|
||||
lockType: VaultLockType.values.safeByName(map['lockType'] as String, VaultLockType.system),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toMap() => {
|
||||
'name': name,
|
||||
'autoLock': autoLockScreenOff ? 1 : 0,
|
||||
'useBin': useBin ? 1 : 0,
|
||||
'lockType': lockType.name,
|
||||
};
|
||||
|
||||
String get passKey => 'vault_pass_$name';
|
||||
|
||||
String get path => '${androidFileUtils.vaultRoot}$name';
|
||||
|
||||
static String? nameFromPath(String path) {
|
||||
return path.startsWith(androidFileUtils.vaultRoot) ? path.substring(androidFileUtils.vaultRoot.length) : null;
|
||||
}
|
||||
}
|
17
lib/model/vaults/enums.dart
Normal file
17
lib/model/vaults/enums.dart
Normal file
|
@ -0,0 +1,17 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
enum VaultLockType { system, pin, password }
|
||||
|
||||
extension ExtraVaultLockType on VaultLockType {
|
||||
String getText(BuildContext context) {
|
||||
switch (this) {
|
||||
case VaultLockType.system:
|
||||
return context.l10n.settingsSystemDefault;
|
||||
case VaultLockType.pin:
|
||||
return context.l10n.vaultLockTypePin;
|
||||
case VaultLockType.password:
|
||||
return context.l10n.vaultLockTypePassword;
|
||||
}
|
||||
}
|
||||
}
|
240
lib/model/vaults/vaults.dart
Normal file
240
lib/model/vaults/vaults.dart
Normal file
|
@ -0,0 +1,240 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:aves/model/vaults/details.dart';
|
||||
import 'package:aves/model/vaults/enums.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/password_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/pin_dialog.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:local_auth/error_codes.dart' as auth_error;
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:screen_state/screen_state.dart';
|
||||
|
||||
final Vaults vaults = Vaults._private();
|
||||
|
||||
class Vaults extends ChangeNotifier {
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
Set<VaultDetails> _rows = {};
|
||||
final Set<String> _unlockedDirPaths = {};
|
||||
|
||||
Vaults._private();
|
||||
|
||||
Future<void> init() async {
|
||||
_rows = await metadataDb.loadAllVaults();
|
||||
_vaultDirPaths = null;
|
||||
final screenStateStream = Screen().screenStateStream;
|
||||
if (screenStateStream != null) {
|
||||
_subscriptions.add(screenStateStream.where((event) => event == ScreenStateEvent.SCREEN_OFF).listen((event) => _onScreenOff()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscriptions
|
||||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Set<VaultDetails> get all => Set.unmodifiable(_rows);
|
||||
|
||||
VaultDetails? _detailsForPath(String dirPath) => _rows.firstWhereOrNull((v) => v.path == dirPath);
|
||||
|
||||
Future<void> create(VaultDetails details) async {
|
||||
await metadataDb.addVaults({details});
|
||||
|
||||
_rows.add(details);
|
||||
_vaultDirPaths = null;
|
||||
_unlockedDirPaths.add(details.path);
|
||||
_onLockStateChanged();
|
||||
}
|
||||
|
||||
Future<void> remove(Set<String> dirPaths) async {
|
||||
final details = dirPaths.map(_detailsForPath).whereNotNull().toSet();
|
||||
if (details.isEmpty) return;
|
||||
|
||||
await metadataDb.removeVaults(details);
|
||||
|
||||
await Future.forEach(details, (v) => securityService.writeValue(v.passKey, null));
|
||||
|
||||
_rows.removeAll(details);
|
||||
_vaultDirPaths = null;
|
||||
_unlockedDirPaths.removeAll(dirPaths);
|
||||
_onLockStateChanged();
|
||||
}
|
||||
|
||||
Future<void> rename(String oldDirPath, String newDirPath) async {
|
||||
final oldDetails = _detailsForPath(oldDirPath);
|
||||
if (oldDetails == null) return;
|
||||
|
||||
final newName = VaultDetails.nameFromPath(newDirPath);
|
||||
if (newName == null) return;
|
||||
|
||||
final newDetails = oldDetails.copyWith(name: newName);
|
||||
await metadataDb.updateVault(oldDetails.name, newDetails);
|
||||
|
||||
final pass = await securityService.readValue(oldDetails.passKey);
|
||||
if (pass != null) {
|
||||
await securityService.writeValue(newDetails.passKey, pass);
|
||||
}
|
||||
|
||||
_rows
|
||||
..remove(oldDetails)
|
||||
..add(newDetails);
|
||||
_vaultDirPaths = null;
|
||||
_unlockedDirPaths
|
||||
..remove(oldDirPath)
|
||||
..add(newDirPath);
|
||||
_onLockStateChanged();
|
||||
}
|
||||
|
||||
// update details, except name
|
||||
Future<void> update(VaultDetails newDetails) async {
|
||||
final oldDetails = _detailsForPath(newDetails.path);
|
||||
if (oldDetails == null) return;
|
||||
|
||||
await metadataDb.updateVault(newDetails.name, newDetails);
|
||||
|
||||
_rows
|
||||
..remove(oldDetails)
|
||||
..add(newDetails);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await metadataDb.clearVaults();
|
||||
_rows.clear();
|
||||
_vaultDirPaths = null;
|
||||
}
|
||||
|
||||
Set<String>? _vaultDirPaths;
|
||||
|
||||
Set<String> get vaultDirectories {
|
||||
_vaultDirPaths ??= _rows.map((v) => v.path).toSet();
|
||||
return _vaultDirPaths!;
|
||||
}
|
||||
|
||||
VaultDetails? getVault(String? dirPath) => all.firstWhereOrNull((v) => v.path == dirPath);
|
||||
|
||||
bool isVault(String dirPath) => vaultDirectories.contains(dirPath);
|
||||
|
||||
bool isLocked(String dirPath) => isVault(dirPath) && !_unlockedDirPaths.contains(dirPath);
|
||||
|
||||
bool isVaultEntryUri(String uriString) {
|
||||
final uri = Uri.parse(uriString);
|
||||
if (uri.scheme != 'file') return false;
|
||||
|
||||
final path = uri.pathSegments.fold('', (prev, v) => '$prev${pContext.separator}$v');
|
||||
return vaultDirectories.any(path.startsWith);
|
||||
}
|
||||
|
||||
void lock(Set<String> dirPaths) {
|
||||
final unlocked = dirPaths.where((v) => isVault(v) && !isLocked(v)).toSet();
|
||||
if (unlocked.isEmpty) return;
|
||||
|
||||
_unlockedDirPaths.removeAll(unlocked);
|
||||
_onLockStateChanged();
|
||||
}
|
||||
|
||||
Future<bool> tryUnlock(String dirPath, BuildContext context) async {
|
||||
if (!isVault(dirPath) || !isLocked(dirPath)) return true;
|
||||
|
||||
final details = _detailsForPath(dirPath);
|
||||
if (details == null) return false;
|
||||
|
||||
bool? confirmed;
|
||||
switch (details.lockType) {
|
||||
case VaultLockType.system:
|
||||
try {
|
||||
confirmed = await LocalAuthentication().authenticate(
|
||||
localizedReason: context.l10n.authenticateToUnlockVault,
|
||||
);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
break;
|
||||
case VaultLockType.pin:
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const PinDialog(needConfirmation: false),
|
||||
routeSettings: const RouteSettings(name: PinDialog.routeName),
|
||||
);
|
||||
if (pin != null) {
|
||||
confirmed = pin == await securityService.readValue(details.passKey);
|
||||
}
|
||||
break;
|
||||
case VaultLockType.password:
|
||||
final password = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const PasswordDialog(needConfirmation: false),
|
||||
routeSettings: const RouteSettings(name: PasswordDialog.routeName),
|
||||
);
|
||||
if (password != null) {
|
||||
confirmed = password == await securityService.readValue(details.passKey);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (confirmed == null || !confirmed) return false;
|
||||
|
||||
_unlockedDirPaths.add(dirPath);
|
||||
_onLockStateChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> setPass(BuildContext context, VaultDetails details) async {
|
||||
switch (details.lockType) {
|
||||
case VaultLockType.system:
|
||||
final l10n = context.l10n;
|
||||
try {
|
||||
return await LocalAuthentication().authenticate(
|
||||
localizedReason: l10n.authenticateToConfigureVault,
|
||||
);
|
||||
} on PlatformException catch (e, stack) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => AvesDialog(
|
||||
content: Text(e.message ?? l10n.genericFailureFeedback),
|
||||
actions: const [OkButton()],
|
||||
),
|
||||
routeSettings: const RouteSettings(name: AvesDialog.warningRouteName),
|
||||
);
|
||||
if (e.code != auth_error.notAvailable) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case VaultLockType.pin:
|
||||
final pin = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const PinDialog(needConfirmation: true),
|
||||
routeSettings: const RouteSettings(name: PinDialog.routeName),
|
||||
);
|
||||
if (pin != null) {
|
||||
return await securityService.writeValue(details.passKey, pin);
|
||||
}
|
||||
break;
|
||||
case VaultLockType.password:
|
||||
final password = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const PasswordDialog(needConfirmation: true),
|
||||
routeSettings: const RouteSettings(name: PasswordDialog.routeName),
|
||||
);
|
||||
if (password != null) {
|
||||
return await securityService.writeValue(details.passKey, password);
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onScreenOff() => lock(all.where((v) => v.autoLockScreenOff).map((v) => v.path).toSet());
|
||||
|
||||
void _onLockStateChanged() {
|
||||
windowService.secureScreen(_unlockedDirPaths.isNotEmpty);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ import 'package:aves/services/media/media_session_service.dart';
|
|||
import 'package:aves/services/media/media_store_service.dart';
|
||||
import 'package:aves/services/metadata/metadata_edit_service.dart';
|
||||
import 'package:aves/services/metadata/metadata_fetch_service.dart';
|
||||
import 'package:aves/services/security_service.dart';
|
||||
import 'package:aves/services/storage_service.dart';
|
||||
import 'package:aves/services/window_service.dart';
|
||||
import 'package:aves_report/aves_report.dart';
|
||||
|
@ -41,6 +42,7 @@ final MetadataEditService metadataEditService = getIt<MetadataEditService>();
|
|||
final MetadataFetchService metadataFetchService = getIt<MetadataFetchService>();
|
||||
final MobileServices mobileServices = getIt<MobileServices>();
|
||||
final ReportService reportService = getIt<ReportService>();
|
||||
final SecurityService securityService = getIt<SecurityService>();
|
||||
final StorageService storageService = getIt<StorageService>();
|
||||
final WindowService windowService = getIt<WindowService>();
|
||||
|
||||
|
@ -60,6 +62,7 @@ void initPlatformServices() {
|
|||
getIt.registerLazySingleton<MetadataFetchService>(PlatformMetadataFetchService.new);
|
||||
getIt.registerLazySingleton<MobileServices>(PlatformMobileServices.new);
|
||||
getIt.registerLazySingleton<ReportService>(PlatformReportService.new);
|
||||
getIt.registerLazySingleton<SecurityService>(PlatformSecurityService.new);
|
||||
getIt.registerLazySingleton<StorageService>(PlatformStorageService.new);
|
||||
getIt.registerLazySingleton<WindowService>(PlatformWindowService.new);
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ abstract class DeviceService {
|
|||
|
||||
Future<Map<String, dynamic>> getCapabilities();
|
||||
|
||||
Future<String?> getDefaultTimeZone();
|
||||
Future<int?> getDefaultTimeZoneRawOffsetMillis();
|
||||
|
||||
Future<List<Locale>> getLocales();
|
||||
|
||||
|
@ -45,9 +45,9 @@ class PlatformDeviceService implements DeviceService {
|
|||
}
|
||||
|
||||
@override
|
||||
Future<String?> getDefaultTimeZone() async {
|
||||
Future<int?> getDefaultTimeZoneRawOffsetMillis() async {
|
||||
try {
|
||||
return await _platform.invokeMethod('getDefaultTimeZone');
|
||||
return await _platform.invokeMethod('getDefaultTimeZoneRawOffsetMillis');
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
|
|
39
lib/services/security_service.dart
Normal file
39
lib/services/security_service.dart
Normal file
|
@ -0,0 +1,39 @@
|
|||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
abstract class SecurityService {
|
||||
Future<bool> writeValue<T>(String key, T? value);
|
||||
|
||||
Future<T?> readValue<T>(String key);
|
||||
}
|
||||
|
||||
class PlatformSecurityService implements SecurityService {
|
||||
static const _platform = MethodChannel('deckers.thibault/aves/security');
|
||||
|
||||
@override
|
||||
Future<bool> writeValue<T>(String key, T? value) async {
|
||||
try {
|
||||
await _platform.invokeMethod('writeValue', <String, dynamic>{
|
||||
'key': key,
|
||||
'value': value,
|
||||
});
|
||||
return true;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<T?> readValue<T>(String key) async {
|
||||
try {
|
||||
final result = await _platform.invokeMethod('readValue', <String, dynamic>{
|
||||
'key': key,
|
||||
});
|
||||
if (result != null) return result as T;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
|
@ -9,6 +9,8 @@ import 'package:streams_channel/streams_channel.dart';
|
|||
abstract class StorageService {
|
||||
Future<Set<StorageVolume>> getStorageVolumes();
|
||||
|
||||
Future<String> getVaultRoot();
|
||||
|
||||
Future<int?> getFreeSpace(StorageVolume volume);
|
||||
|
||||
Future<List<String>> getGrantedDirectories();
|
||||
|
@ -53,6 +55,17 @@ class PlatformStorageService implements StorageService {
|
|||
return {};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> getVaultRoot() async {
|
||||
try {
|
||||
final result = await _platform.invokeMethod('getVaultRoot');
|
||||
return result as String;
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> getFreeSpace(StorageVolume volume) async {
|
||||
try {
|
||||
|
|
|
@ -8,6 +8,8 @@ abstract class WindowService {
|
|||
|
||||
Future<void> keepScreenOn(bool on);
|
||||
|
||||
Future<void> secureScreen(bool on);
|
||||
|
||||
Future<bool> isRotationLocked();
|
||||
|
||||
Future<void> requestOrientation([Orientation? orientation]);
|
||||
|
@ -42,6 +44,17 @@ class PlatformWindowService implements WindowService {
|
|||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> secureScreen(bool on) async {
|
||||
try {
|
||||
await _platform.invokeMethod('secureScreen', <String, dynamic>{
|
||||
'on': on,
|
||||
});
|
||||
} on PlatformException catch (e, stack) {
|
||||
await reportService.recordError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isRotationLocked() async {
|
||||
try {
|
||||
|
|
|
@ -132,6 +132,9 @@ class AIcons {
|
|||
static const IconData streamVideo = Icons.movie_outlined;
|
||||
static const IconData streamAudio = Icons.audiotrack_outlined;
|
||||
static const IconData streamText = Icons.closed_caption_outlined;
|
||||
static const IconData vaultLock = Icons.lock_outline;
|
||||
static const IconData vaultAdd = Icons.enhanced_encryption_outlined;
|
||||
static const IconData vaultConfigure = MdiIcons.shieldLockOutline;
|
||||
static const IconData videoSettings = Icons.video_settings_outlined;
|
||||
static const IconData view = Icons.grid_view_outlined;
|
||||
static const IconData zoomIn = Icons.add_outlined;
|
||||
|
@ -147,6 +150,8 @@ class AIcons {
|
|||
static const IconData downloadAlbum = Icons.file_download;
|
||||
static const IconData screenshotAlbum = Icons.screenshot_outlined;
|
||||
static const IconData recordingAlbum = Icons.smartphone_outlined;
|
||||
static const IconData locked = Icons.lock_outline;
|
||||
static const IconData unlocked = Icons.lock_open_outlined;
|
||||
|
||||
// thumbnail overlay
|
||||
static const IconData animated = Icons.slideshow;
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
@ -10,7 +11,8 @@ final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
|||
class AndroidFileUtils {
|
||||
static const String trashDirPath = '#trash';
|
||||
|
||||
late final String separator, primaryStorage, dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath;
|
||||
late final String separator, vaultRoot, primaryStorage;
|
||||
late final String dcimPath, downloadPath, moviesPath, picturesPath, avesVideoCapturesPath;
|
||||
late final Set<String> videoCapturesPaths;
|
||||
Set<StorageVolume> storageVolumes = {};
|
||||
Set<Package> _packages = {};
|
||||
|
@ -28,6 +30,7 @@ class AndroidFileUtils {
|
|||
|
||||
separator = pContext.separator;
|
||||
await _initStorageVolumes();
|
||||
vaultRoot = await storageService.getVaultRoot();
|
||||
primaryStorage = storageVolumes.firstWhereOrNull((volume) => volume.isPrimary)?.path ?? separator;
|
||||
// standard
|
||||
dcimPath = pContext.join(primaryStorage, 'DCIM');
|
||||
|
@ -90,15 +93,17 @@ class AndroidFileUtils {
|
|||
|
||||
bool isOnRemovableStorage(String path) => getStorageVolume(path)?.isRemovable ?? false;
|
||||
|
||||
AlbumType getAlbumType(String albumPath) {
|
||||
if (isCameraPath(albumPath)) return AlbumType.camera;
|
||||
if (isDownloadPath(albumPath)) return AlbumType.download;
|
||||
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||
if (isVideoCapturesPath(albumPath)) return AlbumType.videoCaptures;
|
||||
AlbumType getAlbumType(String dirPath) {
|
||||
if (vaults.isVault(dirPath)) return AlbumType.vault;
|
||||
|
||||
final dir = pContext.split(albumPath).last;
|
||||
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||
if (isCameraPath(dirPath)) return AlbumType.camera;
|
||||
if (isDownloadPath(dirPath)) return AlbumType.download;
|
||||
if (isScreenRecordingsPath(dirPath)) return AlbumType.screenRecordings;
|
||||
if (isScreenshotsPath(dirPath)) return AlbumType.screenshots;
|
||||
if (isVideoCapturesPath(dirPath)) return AlbumType.videoCaptures;
|
||||
|
||||
final dir = pContext.split(dirPath).last;
|
||||
if (dirPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||
|
||||
return AlbumType.regular;
|
||||
}
|
||||
|
@ -115,7 +120,16 @@ class AndroidFileUtils {
|
|||
}
|
||||
}
|
||||
|
||||
enum AlbumType { regular, app, camera, download, screenRecordings, screenshots, videoCaptures }
|
||||
enum AlbumType {
|
||||
regular,
|
||||
vault,
|
||||
app,
|
||||
camera,
|
||||
download,
|
||||
screenRecordings,
|
||||
screenshots,
|
||||
videoCaptures,
|
||||
}
|
||||
|
||||
class Package {
|
||||
final String packageName;
|
||||
|
|
|
@ -17,3 +17,13 @@ extension ExtraMapNullableKeyValue<K extends Object, V> on Map<K?, V?> {
|
|||
extension ExtraNumIterable on Iterable<int?> {
|
||||
int get sum => fold(0, (prev, v) => prev + (v ?? 0));
|
||||
}
|
||||
|
||||
extension ExtraEnum<T extends Enum> on Iterable<T> {
|
||||
T safeByName(String name, T defaultValue) {
|
||||
try {
|
||||
return byName(name);
|
||||
} catch (error) {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -80,6 +80,12 @@ class Dependencies {
|
|||
license: mit,
|
||||
sourceUrl: 'https://github.com/ajinasokan/flutter_displaymode',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Local Auth',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/local_auth/local_auth/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/local_auth/local_auth',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Package Info Plus',
|
||||
license: bsd3,
|
||||
|
@ -101,11 +107,17 @@ class Dependencies {
|
|||
license: mit,
|
||||
sourceUrl: 'https://github.com/aaassseee/screen_brightness',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Screen State',
|
||||
license: mit,
|
||||
licenseUrl: 'https://github.com/cph-cachet/flutter-plugins/blob/master/packages/screen_state/LICENSE',
|
||||
sourceUrl: 'https://github.com/cph-cachet/flutter-plugins/tree/master/packages/screen_state',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Shared Preferences',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/shared_preferences/shared_preferences/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/shared_preferences/shared_preferences',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/shared_preferences/shared_preferences/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/shared_preferences/shared_preferences',
|
||||
),
|
||||
Dependency(
|
||||
name: 'sqflite',
|
||||
|
@ -120,8 +132,8 @@ class Dependencies {
|
|||
Dependency(
|
||||
name: 'URL Launcher',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/url_launcher/url_launcher/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/url_launcher/url_launcher',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/url_launcher/url_launcher/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/url_launcher/url_launcher',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Volume Controller',
|
||||
|
@ -139,8 +151,8 @@ class Dependencies {
|
|||
Dependency(
|
||||
name: 'Google Maps for Flutter',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/master/packages/google_maps_flutter/google_maps_flutter/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/master/packages/google_maps_flutter/google_maps_flutter',
|
||||
licenseUrl: 'https://github.com/flutter/plugins/blob/main/packages/google_maps_flutter/google_maps_flutter/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/plugins/tree/main/packages/google_maps_flutter/google_maps_flutter',
|
||||
),
|
||||
];
|
||||
|
||||
|
@ -219,8 +231,8 @@ class Dependencies {
|
|||
Dependency(
|
||||
name: 'Flutter Markdown',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_markdown/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_markdown',
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/main/packages/flutter_markdown/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/main/packages/flutter_markdown',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Flutter Staggered Animations',
|
||||
|
@ -240,8 +252,8 @@ class Dependencies {
|
|||
Dependency(
|
||||
name: 'Palette Generator',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/palette_generator/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/palette_generator',
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/main/packages/palette_generator/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/main/packages/palette_generator',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Panorama (Aves fork)',
|
||||
|
@ -253,6 +265,11 @@ class Dependencies {
|
|||
license: bsd2,
|
||||
sourceUrl: 'https://github.com/diegoveloper/flutter_percent_indicator',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Pinput',
|
||||
license: mit,
|
||||
sourceUrl: 'https://github.com/Tkko/Flutter_PinPut',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Provider',
|
||||
license: mit,
|
||||
|
@ -294,8 +311,8 @@ class Dependencies {
|
|||
Dependency(
|
||||
name: 'Flutter Lints',
|
||||
license: bsd3,
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/master/packages/flutter_lints/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/master/packages/flutter_lints',
|
||||
licenseUrl: 'https://github.com/flutter/packages/blob/main/packages/flutter_lints/LICENSE',
|
||||
sourceUrl: 'https://github.com/flutter/packages/tree/main/packages/flutter_lints',
|
||||
),
|
||||
Dependency(
|
||||
name: 'Get It',
|
||||
|
|
|
@ -17,6 +17,7 @@ import 'package:aves/model/settings/settings.dart';
|
|||
import 'package:aves/model/source/analysis_controller.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/android_app_service.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
|
@ -24,6 +25,7 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/utils/collection_utils.dart';
|
||||
import 'package:aves/utils/mime_utils.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/entry_editor.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
|
@ -45,8 +47,6 @@ import 'package:latlong2/latlong.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
import '../common/action_mixins/entry_storage.dart';
|
||||
|
||||
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, EntryEditorMixin, EntryStorageMixin {
|
||||
bool isVisible(
|
||||
EntrySetAction action, {
|
||||
|
@ -284,10 +284,29 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
|
||||
Future<void> _delete(BuildContext context) async {
|
||||
final entries = _getTargetItems(context);
|
||||
final byBinUsage = groupBy<AvesEntry, bool>(entries, (entry) {
|
||||
final details = vaults.getVault(entry.directory);
|
||||
return details?.useBin ?? settings.enableBin;
|
||||
});
|
||||
await Future.forEach(
|
||||
byBinUsage.entries,
|
||||
(kv) => doDelete(
|
||||
context: context,
|
||||
entries: kv.value.toSet(),
|
||||
enableBin: kv.key,
|
||||
));
|
||||
|
||||
_browse(context);
|
||||
}
|
||||
|
||||
Future<void> doDelete({
|
||||
required BuildContext context,
|
||||
required Set<AvesEntry> entries,
|
||||
required bool enableBin,
|
||||
}) async {
|
||||
final pureTrash = entries.every((entry) => entry.trashed);
|
||||
if (settings.enableBin && !pureTrash) {
|
||||
await _move(context, moveType: MoveType.toBin);
|
||||
if (enableBin && !pureTrash) {
|
||||
await doMove(context, moveType: MoveType.toBin, entries: entries);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -296,7 +315,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
final storageDirs = entries.map((e) => e.storageDirectory).whereNotNull().toSet();
|
||||
final todoCount = entries.length;
|
||||
|
||||
if (!await showConfirmationDialog(
|
||||
if (!await showSkippableConfirmationDialog(
|
||||
context: context,
|
||||
type: ConfirmationDialog.deleteForever,
|
||||
message: l10n.deleteEntriesConfirmationDialogMessage(todoCount),
|
||||
|
@ -329,8 +348,6 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
|||
await storageService.deleteEmptyDirectories(storageDirs);
|
||||
},
|
||||
);
|
||||
|
||||
_browse(context);
|
||||
}
|
||||
|
||||
Future<void> _move(BuildContext context, {required MoveType moveType}) async {
|
||||
|
|
|
@ -38,11 +38,12 @@ class _MoveButtonState extends ChooserQuickButtonState<MoveButton, String> {
|
|||
|
||||
@override
|
||||
Widget buildChooser(Animation<double> animation, PopupMenuPosition chooserPosition) {
|
||||
final options = settings.recentDestinationAlbums;
|
||||
final source = context.read<CollectionSource>();
|
||||
final rawAlbums = source.rawAlbums;
|
||||
final options = settings.recentDestinationAlbums.where(rawAlbums.contains).toList();
|
||||
final takeCount = MenuQuickChooser.maxOptionCount - options.length;
|
||||
if (takeCount > 0) {
|
||||
final source = context.read<CollectionSource>();
|
||||
final filters = source.rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet();
|
||||
final filters = rawAlbums.whereNot(options.contains).map((album) => AlbumFilter(album, null)).toSet();
|
||||
final allMapEntries = filters.map((filter) => FilterGridItem(filter, source.recentEntry(filter))).toList();
|
||||
allMapEntries.sort(FilterNavigationPage.compareFiltersByDate);
|
||||
options.addAll(allMapEntries.take(takeCount).map((v) => v.filter.album));
|
||||
|
|
|
@ -194,7 +194,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
|||
}) async {
|
||||
if (moveType == MoveType.toBin) {
|
||||
final l10n = context.l10n;
|
||||
if (!await showConfirmationDialog(
|
||||
if (!await showSkippableConfirmationDialog(
|
||||
context: context,
|
||||
type: ConfirmationDialog.moveToBin,
|
||||
message: l10n.binEntriesConfirmationDialogMessage(entries.length),
|
||||
|
@ -291,7 +291,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
|||
return dateMillis == null || dateMillis == 0;
|
||||
}).toSet();
|
||||
if (undatedItems.isNotEmpty) {
|
||||
if (!await showConfirmationDialog(
|
||||
if (!await showSkippableConfirmationDialog(
|
||||
context: context,
|
||||
type: ConfirmationDialog.moveUndatedItems,
|
||||
delegate: MoveUndatedConfirmationDialogDelegate(),
|
||||
|
|
|
@ -135,22 +135,26 @@ mixin FeedbackMixin {
|
|||
required Stream<T> opStream,
|
||||
int? itemCount,
|
||||
VoidCallback? onCancel,
|
||||
void Function(Set<T> processed)? onDone,
|
||||
}) =>
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ReportOverlay<T>(
|
||||
opStream: opStream,
|
||||
itemCount: itemCount,
|
||||
onCancel: onCancel,
|
||||
onDone: (processed) {
|
||||
Navigator.maybeOf(context)?.pop();
|
||||
onDone?.call(processed);
|
||||
},
|
||||
),
|
||||
routeSettings: const RouteSettings(name: ReportOverlay.routeName),
|
||||
);
|
||||
Future<void> Function(Set<T> processed)? onDone,
|
||||
}) async {
|
||||
final completer = Completer();
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => ReportOverlay<T>(
|
||||
opStream: opStream,
|
||||
itemCount: itemCount,
|
||||
onCancel: onCancel,
|
||||
onDone: (processed) async {
|
||||
Navigator.maybeOf(context)?.pop();
|
||||
await onDone?.call(processed);
|
||||
completer.complete();
|
||||
},
|
||||
),
|
||||
routeSettings: const RouteSettings(name: ReportOverlay.routeName),
|
||||
);
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
class ReportOverlay<T> extends StatefulWidget {
|
||||
|
|
32
lib/widgets/common/action_mixins/vault_aware.dart
Normal file
32
lib/widgets/common/action_mixins/vault_aware.dart
Normal file
|
@ -0,0 +1,32 @@
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
mixin VaultAwareMixin on FeedbackMixin {
|
||||
Future<bool> unlockAlbum(BuildContext context, String dirPath) async {
|
||||
final success = await vaults.tryUnlock(dirPath, context);
|
||||
if (!success) {
|
||||
showFeedback(context, context.l10n.genericFailureFeedback);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
Future<bool> unlockFilter(BuildContext context, CollectionFilter filter) {
|
||||
return filter is AlbumFilter ? unlockAlbum(context, filter.album) : Future.value(true);
|
||||
}
|
||||
|
||||
Future<bool> unlockFilters(BuildContext context, Set<AlbumFilter> filters) async {
|
||||
var unlocked = true;
|
||||
await Future.forEach(filters, (filter) async {
|
||||
if (unlocked) {
|
||||
unlocked = await unlockFilter(context, filter);
|
||||
}
|
||||
});
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
void lockFilters(Set<AlbumFilter> filters) => vaults.lock(filters.map((v) => v.album).toSet());
|
||||
}
|
|
@ -99,6 +99,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
if (filter is TagFilter) ChipAction.goToTagPage,
|
||||
ChipAction.reverse,
|
||||
ChipAction.hide,
|
||||
ChipAction.lockVault,
|
||||
];
|
||||
|
||||
// remove focus, if any, to prevent the keyboard from showing up
|
||||
|
@ -107,6 +108,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
|
||||
final overlay = Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||
const touchArea = Size(kMinInteractiveDimension, kMinInteractiveDimension);
|
||||
final actionDelegate = ChipActionDelegate();
|
||||
final selectedAction = await showMenu<ChipAction>(
|
||||
context: context,
|
||||
position: RelativeRect.fromRect(tapPosition & touchArea, Offset.zero & overlay.size),
|
||||
|
@ -115,7 +117,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
child: Text(filter.getLabel(context)),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
...actions.map((action) {
|
||||
...actions.where((action) => actionDelegate.isVisible(action, filter: filter)).map((action) {
|
||||
late String text;
|
||||
if (action == ChipAction.reverse) {
|
||||
text = filter.reversed ? context.l10n.chipActionFilterIn : context.l10n.chipActionFilterOut;
|
||||
|
@ -134,7 +136,7 @@ class AvesFilterChip extends StatefulWidget {
|
|||
if (selectedAction != null) {
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
ChipActionDelegate().onActionSelected(context, filter, selectedAction);
|
||||
actionDelegate.onActionSelected(context, filter, selectedAction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||
import 'package:aves/model/covers.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -339,8 +340,9 @@ class IconUtils {
|
|||
height: size,
|
||||
)
|
||||
: null;
|
||||
case AlbumType.vault:
|
||||
return buildIcon(vaults.isLocked(albumPath) ? AIcons.locked : AIcons.unlocked);
|
||||
case AlbumType.regular:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,10 +4,13 @@ import 'package:aves/model/favourites.dart';
|
|||
import 'package:aves/model/metadata/address.dart';
|
||||
import 'package:aves/model/metadata/catalog.dart';
|
||||
import 'package:aves/model/metadata/trash.dart';
|
||||
import 'package:aves/model/vaults/details.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/model/video_playback.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/file_utils.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class DebugAppDatabaseSection extends StatefulWidget {
|
||||
|
@ -24,6 +27,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
late Future<Set<CatalogMetadata>> _dbMetadataLoader;
|
||||
late Future<Set<AddressDetails>> _dbAddressLoader;
|
||||
late Future<Set<TrashDetails>> _dbTrashLoader;
|
||||
late Future<Set<VaultDetails>> _dbVaultsLoader;
|
||||
late Future<Set<FavouriteRow>> _dbFavouritesLoader;
|
||||
late Future<Set<CoverRow>> _dbCoversLoader;
|
||||
late Future<Set<VideoPlaybackRow>> _dbVideoPlaybackLoader;
|
||||
|
@ -73,10 +77,12 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
|
||||
|
||||
final entries = snapshot.data!;
|
||||
final byOrigin = groupBy<AvesEntry, int>(entries, (entry) => entry.origin);
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('entry rows: ${snapshot.data!.length}'),
|
||||
child: Text('entry rows: ${entries.length} (${byOrigin.entries.map((kv) => '${kv.key}: ${kv.value.length}').join(', ')})'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
|
@ -171,6 +177,27 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<Set>(
|
||||
future: _dbVaultsLoader,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasError) return Text(snapshot.error.toString());
|
||||
|
||||
if (snapshot.connectionState != ConnectionState.done) return const SizedBox();
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text('vault rows: ${snapshot.data!.length} (${vaults.all.length} in memory)'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: () => vaults.clear().then((_) => _startDbReport()),
|
||||
child: const Text('Clear'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
FutureBuilder<Set>(
|
||||
future: _dbFavouritesLoader,
|
||||
builder: (context, snapshot) {
|
||||
|
@ -248,6 +275,7 @@ class _DebugAppDatabaseSectionState extends State<DebugAppDatabaseSection> with
|
|||
_dbMetadataLoader = metadataDb.loadCatalogMetadata();
|
||||
_dbAddressLoader = metadataDb.loadAddresses();
|
||||
_dbTrashLoader = metadataDb.loadAllTrashDetails();
|
||||
_dbVaultsLoader = metadataDb.loadAllVaults();
|
||||
_dbFavouritesLoader = metadataDb.loadAllFavourites();
|
||||
_dbCoversLoader = metadataDb.loadAllCovers();
|
||||
_dbVideoPlaybackLoader = metadataDb.loadAllVideoPlayback();
|
||||
|
|
|
@ -47,6 +47,7 @@ class DebugSettingsSection extends StatelessWidget {
|
|||
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
child: InfoRowGroup(
|
||||
info: {
|
||||
'catalogTimeZoneRawOffsetMillis': '${settings.catalogTimeZoneRawOffsetMillis}',
|
||||
'tileExtent - Collection': '${settings.getTileExtent(CollectionPage.routeName)}',
|
||||
'tileExtent - Albums': '${settings.getTileExtent(AlbumListPage.routeName)}',
|
||||
'tileExtent - Countries': '${settings.getTileExtent(CountryListPage.routeName)}',
|
||||
|
|
|
@ -5,6 +5,86 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import 'aves_dialog.dart';
|
||||
|
||||
Future<bool> showConfirmationDialog({
|
||||
required BuildContext context,
|
||||
required String message,
|
||||
required String confirmationButtonLabel,
|
||||
}) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AvesDialog(
|
||||
content: Text(message),
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.maybeOf(context)?.pop(true),
|
||||
child: Text(confirmationButtonLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
routeSettings: const RouteSettings(name: AvesDialog.confirmationRouteName),
|
||||
);
|
||||
return confirmed ?? false;
|
||||
}
|
||||
|
||||
Future<bool> showSkippableConfirmationDialog({
|
||||
required BuildContext context,
|
||||
required ConfirmationDialog type,
|
||||
String? message,
|
||||
ConfirmationDialogDelegate? delegate,
|
||||
required String confirmationButtonLabel,
|
||||
}) async {
|
||||
if (!_shouldConfirm(type)) return true;
|
||||
|
||||
assert((message != null) ^ (delegate != null));
|
||||
final effectiveDelegate = delegate ?? MessageConfirmationDialogDelegate(message!);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _SkippableConfirmationDialog(
|
||||
type: type,
|
||||
delegate: effectiveDelegate,
|
||||
confirmationButtonLabel: confirmationButtonLabel,
|
||||
),
|
||||
routeSettings: const RouteSettings(name: _SkippableConfirmationDialog.routeName),
|
||||
);
|
||||
if (confirmed == null) return false;
|
||||
|
||||
if (confirmed) {
|
||||
effectiveDelegate.apply();
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
bool _shouldConfirm(ConfirmationDialog type) {
|
||||
switch (type) {
|
||||
case ConfirmationDialog.createVault:
|
||||
return settings.confirmCreateVault;
|
||||
case ConfirmationDialog.deleteForever:
|
||||
return settings.confirmDeleteForever;
|
||||
case ConfirmationDialog.moveToBin:
|
||||
return settings.confirmMoveToBin;
|
||||
case ConfirmationDialog.moveUndatedItems:
|
||||
return settings.confirmMoveUndatedItems;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipConfirmation(ConfirmationDialog type) {
|
||||
switch (type) {
|
||||
case ConfirmationDialog.createVault:
|
||||
settings.confirmCreateVault = false;
|
||||
break;
|
||||
case ConfirmationDialog.deleteForever:
|
||||
settings.confirmDeleteForever = false;
|
||||
break;
|
||||
case ConfirmationDialog.moveToBin:
|
||||
settings.confirmMoveToBin = false;
|
||||
break;
|
||||
case ConfirmationDialog.moveUndatedItems:
|
||||
settings.confirmMoveUndatedItems = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ConfirmationDialogDelegate {
|
||||
List<Widget> build(BuildContext context);
|
||||
|
||||
|
@ -25,77 +105,24 @@ class MessageConfirmationDialogDelegate extends ConfirmationDialogDelegate {
|
|||
];
|
||||
}
|
||||
|
||||
Future<bool> showConfirmationDialog({
|
||||
required BuildContext context,
|
||||
required ConfirmationDialog type,
|
||||
String? message,
|
||||
ConfirmationDialogDelegate? delegate,
|
||||
required String confirmationButtonLabel,
|
||||
}) async {
|
||||
if (!_shouldConfirm(type)) return true;
|
||||
|
||||
assert((message != null) ^ (delegate != null));
|
||||
final effectiveDelegate = delegate ?? MessageConfirmationDialogDelegate(message!);
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _AvesConfirmationDialog(
|
||||
type: type,
|
||||
delegate: effectiveDelegate,
|
||||
confirmationButtonLabel: confirmationButtonLabel,
|
||||
),
|
||||
routeSettings: const RouteSettings(name: _AvesConfirmationDialog.routeName),
|
||||
);
|
||||
if (confirmed == null) return false;
|
||||
|
||||
if (confirmed) {
|
||||
effectiveDelegate.apply();
|
||||
}
|
||||
return confirmed;
|
||||
}
|
||||
|
||||
bool _shouldConfirm(ConfirmationDialog type) {
|
||||
switch (type) {
|
||||
case ConfirmationDialog.deleteForever:
|
||||
return settings.confirmDeleteForever;
|
||||
case ConfirmationDialog.moveToBin:
|
||||
return settings.confirmMoveToBin;
|
||||
case ConfirmationDialog.moveUndatedItems:
|
||||
return settings.confirmMoveUndatedItems;
|
||||
}
|
||||
}
|
||||
|
||||
void _skipConfirmation(ConfirmationDialog type) {
|
||||
switch (type) {
|
||||
case ConfirmationDialog.deleteForever:
|
||||
settings.confirmDeleteForever = false;
|
||||
break;
|
||||
case ConfirmationDialog.moveToBin:
|
||||
settings.confirmMoveToBin = false;
|
||||
break;
|
||||
case ConfirmationDialog.moveUndatedItems:
|
||||
settings.confirmMoveUndatedItems = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
class _AvesConfirmationDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/confirmation';
|
||||
class _SkippableConfirmationDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/skippable_confirmation';
|
||||
|
||||
final ConfirmationDialog type;
|
||||
final ConfirmationDialogDelegate delegate;
|
||||
final String confirmationButtonLabel;
|
||||
|
||||
const _AvesConfirmationDialog({
|
||||
const _SkippableConfirmationDialog({
|
||||
required this.type,
|
||||
required this.delegate,
|
||||
required this.confirmationButtonLabel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_AvesConfirmationDialog> createState() => _AvesConfirmationDialogState();
|
||||
State<_SkippableConfirmationDialog> createState() => _SkippableConfirmationDialogState();
|
||||
}
|
||||
|
||||
class _AvesConfirmationDialogState extends State<_AvesConfirmationDialog> {
|
||||
class _SkippableConfirmationDialogState extends State<_SkippableConfirmationDialog> {
|
||||
final ValueNotifier<bool> _skip = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
|
|
|
@ -29,7 +29,7 @@ class AvesDialog extends StatelessWidget {
|
|||
this.scrollableContent,
|
||||
this.horizontalContentPadding = defaultHorizontalContentPadding,
|
||||
this.content,
|
||||
required this.actions,
|
||||
this.actions = const [],
|
||||
}) : assert((scrollableContent != null) ^ (content != null)),
|
||||
scrollController = scrollController ?? ScrollController();
|
||||
|
||||
|
|
|
@ -9,12 +9,11 @@ import 'package:aves/widgets/common/basic/text/outlined.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/fx/highlight_decoration.dart';
|
||||
import 'package:aves/widgets/common/identity/highlight_title.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class RemoveEntryMetadataDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/remove_entry_metadata';
|
||||
|
||||
|
|
|
@ -4,10 +4,9 @@ import 'package:aves/model/entry.dart';
|
|||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class RenameEntryDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/rename_entry';
|
||||
|
||||
|
|
|
@ -4,11 +4,10 @@ import 'package:aves/services/common/services.dart';
|
|||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../aves_dialog.dart';
|
||||
|
||||
class CreateAlbumDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/create_album';
|
||||
|
||||
|
|
184
lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart
Normal file
184
lib/widgets/dialogs/filter_editors/edit_vault_dialog.dart
Normal file
|
@ -0,0 +1,184 @@
|
|||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/vaults/details.dart';
|
||||
import 'package:aves/model/vaults/enums.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_caption.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_selection_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class EditVaultDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/edit_vault';
|
||||
|
||||
final VaultDetails? initialDetails;
|
||||
|
||||
const EditVaultDialog({
|
||||
super.key,
|
||||
this.initialDetails,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EditVaultDialog> createState() => _EditVaultDialogState();
|
||||
}
|
||||
|
||||
class _EditVaultDialogState extends State<EditVaultDialog> {
|
||||
final TextEditingController _nameController = TextEditingController();
|
||||
late bool _useBin;
|
||||
late bool _autoLockScreenOff;
|
||||
late VaultLockType _lockType;
|
||||
|
||||
final ValueNotifier<bool> _existsNotifier = ValueNotifier(false);
|
||||
final ValueNotifier<bool> _isValidNotifier = ValueNotifier(false);
|
||||
|
||||
final List<VaultLockType> _lockTypeOptions = [
|
||||
if (device.canAuthenticateUser) VaultLockType.system,
|
||||
if (device.canUseCrypto) ...[
|
||||
VaultLockType.pin,
|
||||
VaultLockType.password,
|
||||
],
|
||||
];
|
||||
|
||||
VaultDetails? get initialDetails => widget.initialDetails;
|
||||
|
||||
String get newName => _nameController.text;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final details = initialDetails ??
|
||||
VaultDetails(
|
||||
name: '',
|
||||
autoLockScreenOff: true,
|
||||
useBin: settings.enableBin,
|
||||
lockType: _lockTypeOptions.first,
|
||||
);
|
||||
_nameController.text = details.name;
|
||||
_useBin = details.useBin;
|
||||
_autoLockScreenOff = details.autoLockScreenOff;
|
||||
_lockType = details.lockType;
|
||||
_validate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_existsNotifier.dispose();
|
||||
_isValidNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final isNew = initialDetails == null;
|
||||
return AvesDialog(
|
||||
title: isNew ? l10n.newVaultDialogTitle : l10n.configureVaultDialogTitle,
|
||||
scrollableContent: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ValueListenableBuilder<bool>(
|
||||
valueListenable: _existsNotifier,
|
||||
builder: (context, exists, child) {
|
||||
return TextField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.newAlbumDialogNameLabel,
|
||||
helperText: exists ? l10n.newAlbumDialogNameLabelAlreadyExistsHelper : '',
|
||||
),
|
||||
onChanged: (_) => _validate(),
|
||||
onSubmitted: (_) => _submit(context),
|
||||
);
|
||||
}),
|
||||
),
|
||||
if (_lockTypeOptions.length > 1)
|
||||
ListTile(
|
||||
title: Text(l10n.vaultDialogLockTypeLabel),
|
||||
subtitle: AvesCaption(_lockType.getText(context)),
|
||||
onTap: () {
|
||||
_unfocus();
|
||||
showSelectionDialog<VaultLockType>(
|
||||
context: context,
|
||||
builder: (context) => AvesSelectionDialog<VaultLockType>(
|
||||
initialValue: _lockType,
|
||||
options: Map.fromEntries(_lockTypeOptions.map((v) => MapEntry(v, v.getText(context)))),
|
||||
),
|
||||
onSelection: (v) => setState(() => _lockType = v),
|
||||
);
|
||||
},
|
||||
),
|
||||
SwitchListTile(
|
||||
value: _autoLockScreenOff,
|
||||
onChanged: (v) => setState(() => _autoLockScreenOff = v),
|
||||
title: Text(l10n.vaultDialogLockModeWhenScreenOff),
|
||||
),
|
||||
if (settings.enableBin)
|
||||
SwitchListTile(
|
||||
value: _useBin,
|
||||
onChanged: (v) async {
|
||||
if (!v) {
|
||||
final album = initialDetails?.path;
|
||||
if (album != null) {
|
||||
final filter = AlbumFilter(album, null);
|
||||
final source = context.read<CollectionSource>();
|
||||
if (source.trashedEntries.any(filter.test)) {
|
||||
if (!await showConfirmationDialog(
|
||||
context: context,
|
||||
message: l10n.settingsDisablingBinWarningDialogMessage,
|
||||
confirmationButtonLabel: l10n.applyButtonLabel,
|
||||
)) return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setState(() => _useBin = v);
|
||||
},
|
||||
title: Text(l10n.settingsEnableBin),
|
||||
),
|
||||
],
|
||||
actions: [
|
||||
const CancelButton(),
|
||||
ValueListenableBuilder<bool>(
|
||||
valueListenable: _isValidNotifier,
|
||||
builder: (context, isValid, child) {
|
||||
return TextButton(
|
||||
onPressed: isValid ? () => _submit(context) : null,
|
||||
child: Text(isNew ? l10n.createAlbumButtonLabel : l10n.applyButtonLabel),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// remove focus, if any, to prevent the keyboard from showing up
|
||||
// after the user is done with the dialog
|
||||
void _unfocus() => FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
Future<void> _validate() async {
|
||||
final notEmpty = newName.isNotEmpty;
|
||||
final exists = notEmpty && vaults.all.map((v) => v.name).contains(newName) && newName != initialDetails?.name;
|
||||
_existsNotifier.value = exists;
|
||||
_isValidNotifier.value = notEmpty && !exists;
|
||||
}
|
||||
|
||||
Future<void> _submit(BuildContext context) async {
|
||||
if (!_isValidNotifier.value) return;
|
||||
|
||||
_unfocus();
|
||||
|
||||
final details = VaultDetails(
|
||||
name: newName,
|
||||
autoLockScreenOff: _autoLockScreenOff,
|
||||
useBin: _useBin,
|
||||
lockType: _lockType,
|
||||
);
|
||||
if (!await vaults.setPass(context, details)) return;
|
||||
|
||||
Navigator.maybeOf(context)?.pop(details);
|
||||
}
|
||||
}
|
64
lib/widgets/dialogs/filter_editors/password_dialog.dart
Normal file
64
lib/widgets/dialogs/filter_editors/password_dialog.dart
Normal file
|
@ -0,0 +1,64 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class PasswordDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/password';
|
||||
|
||||
final bool needConfirmation;
|
||||
|
||||
const PasswordDialog({
|
||||
super.key,
|
||||
required this.needConfirmation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PasswordDialog> createState() => _PasswordDialogState();
|
||||
}
|
||||
|
||||
class _PasswordDialogState extends State<PasswordDialog> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
bool _confirming = false;
|
||||
String? _firstPassword;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_confirming ? context.l10n.passwordDialogConfirm : context.l10n.passwordDialogEnter),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
obscureText: true,
|
||||
onSubmitted: (password) {
|
||||
if (widget.needConfirmation) {
|
||||
if (_confirming) {
|
||||
Navigator.maybeOf(context)?.pop<String>(_firstPassword == password ? password : null);
|
||||
} else {
|
||||
_firstPassword = password;
|
||||
_controller.clear();
|
||||
setState(() => _confirming = true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus());
|
||||
}
|
||||
} else {
|
||||
Navigator.maybeOf(context)?.pop<String>(password);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
65
lib/widgets/dialogs/filter_editors/pin_dialog.dart
Normal file
65
lib/widgets/dialogs/filter_editors/pin_dialog.dart
Normal file
|
@ -0,0 +1,65 @@
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pinput/pinput.dart';
|
||||
|
||||
class PinDialog extends StatefulWidget {
|
||||
static const routeName = '/dialog/pin';
|
||||
|
||||
final bool needConfirmation;
|
||||
|
||||
const PinDialog({
|
||||
super.key,
|
||||
required this.needConfirmation,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PinDialog> createState() => _PinDialogState();
|
||||
}
|
||||
|
||||
class _PinDialogState extends State<PinDialog> {
|
||||
final _controller = TextEditingController();
|
||||
final _focusNode = FocusNode();
|
||||
bool _confirming = false;
|
||||
String? _firstPin;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AvesDialog(
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_confirming ? context.l10n.pinDialogConfirm : context.l10n.pinDialogEnter),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Pinput(
|
||||
onCompleted: (pin) {
|
||||
if (widget.needConfirmation) {
|
||||
if (_confirming) {
|
||||
Navigator.maybeOf(context)?.pop<String>(_firstPin == pin ? pin : null);
|
||||
} else {
|
||||
_firstPin = pin;
|
||||
_controller.clear();
|
||||
setState(() => _confirming = true);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _focusNode.requestFocus());
|
||||
}
|
||||
} else {
|
||||
Navigator.maybeOf(context)?.pop<String>(pin);
|
||||
}
|
||||
},
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
obscureText: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -4,10 +4,13 @@ import 'package:aves/model/actions/move_type.dart';
|
|||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/album.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums/enums.dart';
|
||||
import 'package:aves/model/vaults/details.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/common/basic/menu.dart';
|
||||
|
@ -16,7 +19,9 @@ import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
|||
import 'package:aves/widgets/common/identity/buttons/captioned_button.dart';
|
||||
import 'package:aves/widgets/common/identity/empty.dart';
|
||||
import 'package:aves/widgets/common/providers/selection_provider.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/edit_vault_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/action_delegates/album_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/app_bar.dart';
|
||||
|
@ -79,6 +84,19 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
|
|||
}
|
||||
}
|
||||
|
||||
static const _quickActions = [
|
||||
ChipSetAction.createAlbum,
|
||||
];
|
||||
|
||||
// `null` items are converted to dividers
|
||||
static const _menuActions = [
|
||||
...ChipSetActions.general,
|
||||
null,
|
||||
ChipSetAction.toggleTitleSearch,
|
||||
null,
|
||||
ChipSetAction.createVault,
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableProvider<ValueNotifier<AppMode>>.value(
|
||||
|
@ -141,23 +159,37 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
|
|||
selectedFilters: selectedFilters,
|
||||
);
|
||||
|
||||
void onActionSelected(ChipSetAction action) {
|
||||
switch (action) {
|
||||
case ChipSetAction.createAlbum:
|
||||
_createAlbum();
|
||||
break;
|
||||
case ChipSetAction.createVault:
|
||||
_createVault();
|
||||
break;
|
||||
default:
|
||||
actionDelegate.onActionSelected(context, {}, action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return settings.useTvLayout
|
||||
? _buildTelevisionActions(
|
||||
context: context,
|
||||
isVisible: isVisible,
|
||||
actionDelegate: actionDelegate,
|
||||
onActionSelected: onActionSelected,
|
||||
)
|
||||
: _buildMobileActions(
|
||||
context: context,
|
||||
isVisible: isVisible,
|
||||
actionDelegate: actionDelegate,
|
||||
onActionSelected: onActionSelected,
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildTelevisionActions({
|
||||
required BuildContext context,
|
||||
required bool Function(ChipSetAction action) isVisible,
|
||||
required AlbumChipSetActionDelegate actionDelegate,
|
||||
required void Function(ChipSetAction action) onActionSelected,
|
||||
}) {
|
||||
return [
|
||||
...ChipSetActions.general,
|
||||
|
@ -165,7 +197,7 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
|
|||
return CaptionedButton(
|
||||
icon: action.getIcon(),
|
||||
caption: action.getText(context),
|
||||
onPressed: () => actionDelegate.onActionSelected(context, {}, action),
|
||||
onPressed: () => onActionSelected(action),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
@ -173,34 +205,22 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
|
|||
List<Widget> _buildMobileActions({
|
||||
required BuildContext context,
|
||||
required bool Function(ChipSetAction action) isVisible,
|
||||
required AlbumChipSetActionDelegate actionDelegate,
|
||||
required void Function(ChipSetAction action) onActionSelected,
|
||||
}) {
|
||||
return [
|
||||
if (widget.moveType != null)
|
||||
IconButton(
|
||||
icon: const Icon(AIcons.add),
|
||||
onPressed: () async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const CreateAlbumDialog(),
|
||||
routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName),
|
||||
);
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
Navigator.maybeOf(context)?.pop<AlbumFilter>(AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum)));
|
||||
}
|
||||
},
|
||||
tooltip: context.l10n.createAlbumTooltip,
|
||||
),
|
||||
..._quickActions.where(isVisible).map((action) => IconButton(
|
||||
icon: action.getIcon(),
|
||||
onPressed: () => onActionSelected(action),
|
||||
tooltip: action.getText(context),
|
||||
)),
|
||||
MenuIconTheme(
|
||||
child: PopupMenuButton<ChipSetAction>(
|
||||
itemBuilder: (context) {
|
||||
return [
|
||||
...ChipSetActions.general.where(isVisible).map((action) => FilterGridAppBar.toMenuItem(context, action, enabled: true)),
|
||||
const PopupMenuDivider(),
|
||||
FilterGridAppBar.toMenuItem(context, ChipSetAction.toggleTitleSearch, enabled: true),
|
||||
];
|
||||
return _menuActions.where((v) => v == null || isVisible(v)).map((action) {
|
||||
if (action == null) return const PopupMenuDivider();
|
||||
return FilterGridAppBar.toMenuItem(context, action, enabled: true);
|
||||
}).toList();
|
||||
},
|
||||
onSelected: (action) async {
|
||||
// remove focus, if any, to prevent the keyboard from showing up
|
||||
|
@ -209,10 +229,53 @@ class _AlbumPickPageState extends State<_AlbumPickPage> {
|
|||
|
||||
// wait for the popup menu to hide before proceeding with the action
|
||||
await Future.delayed(Durations.popupMenuAnimation * timeDilation);
|
||||
actionDelegate.onActionSelected(context, {}, action);
|
||||
onActionSelected(action);
|
||||
},
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Future<void> _createAlbum() async {
|
||||
final directory = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const CreateAlbumDialog(),
|
||||
routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName),
|
||||
);
|
||||
if (directory == null) return;
|
||||
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
|
||||
_pickAlbum(directory);
|
||||
}
|
||||
|
||||
Future<void> _createVault() async {
|
||||
final l10n = context.l10n;
|
||||
if (!await showSkippableConfirmationDialog(
|
||||
context: context,
|
||||
type: ConfirmationDialog.createVault,
|
||||
message: l10n.newVaultWarningDialogMessage,
|
||||
confirmationButtonLabel: l10n.continueButtonLabel,
|
||||
)) return;
|
||||
|
||||
final details = await showDialog<VaultDetails>(
|
||||
context: context,
|
||||
builder: (context) => const EditVaultDialog(),
|
||||
routeSettings: const RouteSettings(name: EditVaultDialog.routeName),
|
||||
);
|
||||
if (details == null) return;
|
||||
|
||||
// wait for the dialog to hide as applying the change may block the UI
|
||||
await Future.delayed(Durations.dialogTransitionAnimation * timeDilation);
|
||||
|
||||
await vaults.create(details);
|
||||
_pickAlbum(details.path);
|
||||
}
|
||||
|
||||
void _pickAlbum(String directory) {
|
||||
source.createAlbum(directory);
|
||||
final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory));
|
||||
Navigator.maybeOf(context)?.pop<AlbumFilter>(filter);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -99,6 +99,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
case AlbumChipGroupFactor.importance:
|
||||
final specialKey = AlbumImportanceSectionKey.special(context);
|
||||
final appsKey = AlbumImportanceSectionKey.apps(context);
|
||||
final vaultKey = AlbumImportanceSectionKey.vault(context);
|
||||
final regularKey = AlbumImportanceSectionKey.regular(context);
|
||||
sections = groupBy<FilterGridItem<AlbumFilter>, ChipSectionKey>(unpinnedMapEntries, (kv) {
|
||||
switch (covers.effectiveAlbumType(kv.filter.album)) {
|
||||
|
@ -106,6 +107,8 @@ class AlbumListPage extends StatelessWidget {
|
|||
return regularKey;
|
||||
case AlbumType.app:
|
||||
return appsKey;
|
||||
case AlbumType.vault:
|
||||
return vaultKey;
|
||||
default:
|
||||
return specialKey;
|
||||
}
|
||||
|
@ -115,6 +118,7 @@ class AlbumListPage extends StatelessWidget {
|
|||
// group ordering
|
||||
if (sections.containsKey(specialKey)) specialKey: sections[specialKey]!,
|
||||
if (sections.containsKey(appsKey)) appsKey: sections[appsKey]!,
|
||||
if (sections.containsKey(vaultKey)) vaultKey: sections[vaultKey]!,
|
||||
if (sections.containsKey(regularKey)) regularKey: sections[regularKey]!,
|
||||
};
|
||||
break;
|
||||
|
|
|
@ -3,14 +3,19 @@ import 'dart:io';
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/actions/chip_set_actions.dart';
|
||||
import 'package:aves/model/actions/move_type.dart';
|
||||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/entry.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/settings/enums/enums.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums/enums.dart';
|
||||
import 'package:aves/model/source/enums/view.dart';
|
||||
import 'package:aves/model/vaults/details.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/media/enums.dart';
|
||||
|
@ -18,10 +23,13 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/widgets/aves_app.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/create_album_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/edit_vault_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/filter_editors/rename_album_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/tile_view_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
|
@ -32,7 +40,7 @@ import 'package:flutter/scheduler.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with EntryStorageMixin {
|
||||
class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with EntryStorageMixin, VaultAwareMixin {
|
||||
final Iterable<FilterGridItem<AlbumFilter>> _items;
|
||||
|
||||
AlbumChipSetActionDelegate(Iterable<FilterGridItem<AlbumFilter>> items) : _items = items;
|
||||
|
@ -73,12 +81,24 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
required int itemCount,
|
||||
required Set<AlbumFilter> selectedFilters,
|
||||
}) {
|
||||
final selectedSingleItem = selectedFilters.length == 1;
|
||||
final isMain = appMode == AppMode.main;
|
||||
|
||||
final canCreate = !settings.isReadOnly && appMode.canCreateFilter && !isSelecting;
|
||||
switch (action) {
|
||||
case ChipSetAction.createAlbum:
|
||||
return !settings.isReadOnly && appMode == AppMode.main && !isSelecting;
|
||||
return canCreate;
|
||||
case ChipSetAction.createVault:
|
||||
return canCreate && device.canUseVaults;
|
||||
case ChipSetAction.delete:
|
||||
case ChipSetAction.rename:
|
||||
return !settings.isReadOnly && appMode == AppMode.main && isSelecting;
|
||||
return isMain && isSelecting && !settings.isReadOnly;
|
||||
case ChipSetAction.hide:
|
||||
return isMain && selectedFilters.none((v) => vaults.isVault(v.album));
|
||||
case ChipSetAction.configureVault:
|
||||
return isMain && selectedSingleItem && vaults.isVault(selectedFilters.first.album);
|
||||
case ChipSetAction.lockVault:
|
||||
return isMain && selectedFilters.any((v) => vaults.isVault(v.album));
|
||||
default:
|
||||
return super.isVisible(
|
||||
action,
|
||||
|
@ -97,14 +117,25 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
required int itemCount,
|
||||
required Set<AlbumFilter> selectedFilters,
|
||||
}) {
|
||||
final selectedItemCount = selectedFilters.length;
|
||||
final hasSelection = selectedItemCount > 0;
|
||||
|
||||
switch (action) {
|
||||
case ChipSetAction.rename:
|
||||
{
|
||||
if (selectedFilters.length != 1) return false;
|
||||
// do not allow renaming volume root
|
||||
final dir = VolumeRelativeDirectory.fromPath(selectedFilters.first.album);
|
||||
return dir != null && dir.relativeDir.isNotEmpty;
|
||||
}
|
||||
if (selectedFilters.length != 1) return false;
|
||||
|
||||
final dirPath = selectedFilters.first.album;
|
||||
if (vaults.isVault(dirPath)) return true;
|
||||
|
||||
// do not allow renaming volume root
|
||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||
return dir != null && dir.relativeDir.isNotEmpty;
|
||||
case ChipSetAction.hide:
|
||||
return hasSelection;
|
||||
case ChipSetAction.lockVault:
|
||||
return selectedFilters.map((v) => v.album).any((v) => vaults.isVault(v) && !vaults.isLocked(v));
|
||||
case ChipSetAction.configureVault:
|
||||
return true;
|
||||
default:
|
||||
return super.canApply(
|
||||
action,
|
||||
|
@ -121,16 +152,26 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
switch (action) {
|
||||
// general
|
||||
case ChipSetAction.createAlbum:
|
||||
_createAlbum(context);
|
||||
_createAlbum(context, locked: false);
|
||||
break;
|
||||
case ChipSetAction.createVault:
|
||||
_createAlbum(context, locked: true);
|
||||
break;
|
||||
// single/multiple filters
|
||||
case ChipSetAction.delete:
|
||||
_delete(context, filters);
|
||||
break;
|
||||
case ChipSetAction.lockVault:
|
||||
lockFilters(filters);
|
||||
_browse(context);
|
||||
break;
|
||||
// single filter
|
||||
case ChipSetAction.rename:
|
||||
_rename(context, filters.first);
|
||||
break;
|
||||
case ChipSetAction.configureVault:
|
||||
_configureVault(context, filters.first);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -172,51 +213,92 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
}
|
||||
}
|
||||
|
||||
void _createAlbum(BuildContext context) async {
|
||||
final newAlbum = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const CreateAlbumDialog(),
|
||||
routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName),
|
||||
);
|
||||
if (newAlbum != null && newAlbum.isNotEmpty) {
|
||||
final source = context.read<CollectionSource>();
|
||||
source.createAlbum(newAlbum);
|
||||
void _createAlbum(BuildContext context, {required bool locked}) async {
|
||||
final l10n = context.l10n;
|
||||
final source = context.read<CollectionSource>();
|
||||
late final String? directory;
|
||||
if (locked) {
|
||||
if (!await showSkippableConfirmationDialog(
|
||||
context: context,
|
||||
type: ConfirmationDialog.createVault,
|
||||
message: l10n.newVaultWarningDialogMessage,
|
||||
confirmationButtonLabel: l10n.continueButtonLabel,
|
||||
)) return;
|
||||
|
||||
final showAction = SnackBarAction(
|
||||
label: context.l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
// local context may be deactivated when action is triggered after navigation
|
||||
final context = AvesApp.navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
final filter = AlbumFilter(newAlbum, source.getAlbumDisplayName(context, newAlbum));
|
||||
if (context.currentRouteName == AlbumListPage.routeName) {
|
||||
highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter);
|
||||
} else {
|
||||
highlightInfo.set(filter);
|
||||
await Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: AlbumListPage.routeName),
|
||||
builder: (_) => const AlbumListPage(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
final details = await showDialog<VaultDetails>(
|
||||
context: context,
|
||||
builder: (context) => const EditVaultDialog(),
|
||||
routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName),
|
||||
);
|
||||
showFeedback(context, context.l10n.genericSuccessFeedback, showAction);
|
||||
if (details == null) return;
|
||||
|
||||
await vaults.create(details);
|
||||
directory = details.path;
|
||||
} else {
|
||||
directory = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => const CreateAlbumDialog(),
|
||||
routeSettings: const RouteSettings(name: CreateAlbumDialog.routeName),
|
||||
);
|
||||
if (directory == null) return;
|
||||
}
|
||||
source.createAlbum(directory);
|
||||
|
||||
final filter = AlbumFilter(directory, source.getAlbumDisplayName(context, directory));
|
||||
final showAction = SnackBarAction(
|
||||
label: l10n.showButtonLabel,
|
||||
onPressed: () async {
|
||||
// local context may be deactivated when action is triggered after navigation
|
||||
final context = AvesApp.navigatorKey.currentContext;
|
||||
if (context != null) {
|
||||
final highlightInfo = context.read<HighlightInfo>();
|
||||
if (context.currentRouteName == AlbumListPage.routeName) {
|
||||
highlightInfo.trackItem(FilterGridItem(filter, null), highlightItem: filter);
|
||||
} else {
|
||||
highlightInfo.set(filter);
|
||||
await Navigator.maybeOf(context)?.pushAndRemoveUntil(
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: AlbumListPage.routeName),
|
||||
builder: (_) => const AlbumListPage(),
|
||||
),
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
showFeedback(context, l10n.genericSuccessFeedback, showAction);
|
||||
}
|
||||
|
||||
Future<void> _delete(BuildContext context, Set<AlbumFilter> filters) async {
|
||||
final byBinUsage = groupBy<AlbumFilter, bool>(filters, (filter) {
|
||||
final details = vaults.getVault(filter.album);
|
||||
return details?.useBin ?? settings.enableBin;
|
||||
});
|
||||
await Future.forEach(
|
||||
byBinUsage.entries,
|
||||
(kv) => _doDelete(
|
||||
context: context,
|
||||
filters: kv.value.toSet(),
|
||||
enableBin: kv.key,
|
||||
));
|
||||
_browse(context);
|
||||
}
|
||||
|
||||
Future<void> _doDelete({
|
||||
required BuildContext context,
|
||||
required Set<AlbumFilter> filters,
|
||||
required bool enableBin,
|
||||
}) async {
|
||||
if (!await unlockFilters(context, filters)) return;
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
final todoEntries = source.visibleEntries.where((entry) => filters.any((f) => f.test(entry))).toSet();
|
||||
final todoAlbums = filters.map((v) => v.album).toSet();
|
||||
final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet();
|
||||
final emptyAlbums = todoAlbums.whereNot(filledAlbums.contains).toSet();
|
||||
|
||||
if (settings.enableBin && filledAlbums.isNotEmpty) {
|
||||
if (enableBin && filledAlbums.isNotEmpty) {
|
||||
await doMove(
|
||||
context,
|
||||
moveType: MoveType.toBin,
|
||||
|
@ -231,7 +313,6 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
}
|
||||
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final todoCount = todoEntries.length;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
|
@ -255,6 +336,26 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
|
||||
if (!await checkStoragePermissionForAlbums(context, filledAlbums)) return;
|
||||
|
||||
await _deleteEntriesForever(context, todoEntries);
|
||||
|
||||
final vaultAlbumFilters = filters.where((v) => vaults.isVault(v.album)).toSet();
|
||||
if (vaultAlbumFilters.isNotEmpty) {
|
||||
final allEntries = source.allEntries;
|
||||
final emptyVaultAlbums = vaultAlbumFilters.whereNot((v) => allEntries.any(v.test)).map((v) => v.album).toSet();
|
||||
await vaults.remove(emptyVaultAlbums);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteEntriesForever(BuildContext context, Set<AvesEntry> todoEntries) async {
|
||||
if (todoEntries.isEmpty) return;
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
final filledAlbums = todoEntries.map((e) => e.directory).whereNotNull().toSet();
|
||||
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final todoCount = todoEntries.length;
|
||||
|
||||
source.pauseMonitoring();
|
||||
final opId = mediaEditService.newOpId;
|
||||
await showOpReport<ImageOpEvent>(
|
||||
|
@ -283,23 +384,21 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
}
|
||||
|
||||
Future<void> _rename(BuildContext context, AlbumFilter filter) async {
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final source = context.read<CollectionSource>();
|
||||
if (!await unlockFilter(context, filter)) return;
|
||||
|
||||
final album = filter.album;
|
||||
final todoEntries = source.visibleEntries.where(filter.test).toSet();
|
||||
final todoCount = todoEntries.length;
|
||||
if (!vaults.isVault(album)) {
|
||||
final dir = VolumeRelativeDirectory.fromPath(album);
|
||||
// do not allow renaming volume root
|
||||
if (dir == null || dir.relativeDir.isEmpty) return;
|
||||
|
||||
final dir = VolumeRelativeDirectory.fromPath(album);
|
||||
// do not allow renaming volume root
|
||||
if (dir == null || dir.relativeDir.isEmpty) return;
|
||||
|
||||
// check whether renaming is possible given OS restrictions,
|
||||
// before asking to input a new name
|
||||
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||
if (restrictedDirs.contains(dir)) {
|
||||
await showRestrictedDirectoryDialog(context, dir);
|
||||
return;
|
||||
// check whether renaming is possible given OS restrictions,
|
||||
// before asking to input a new name
|
||||
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||
if (restrictedDirs.contains(dir)) {
|
||||
await showRestrictedDirectoryDialog(context, dir);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
final newName = await showDialog<String>(
|
||||
|
@ -309,6 +408,17 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
);
|
||||
if (newName == null || newName.isEmpty) return;
|
||||
|
||||
await _doRename(context, filter, newName);
|
||||
}
|
||||
|
||||
Future<void> _doRename(BuildContext context, AlbumFilter filter, String newName) async {
|
||||
final l10n = context.l10n;
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final source = context.read<CollectionSource>();
|
||||
final album = filter.album;
|
||||
final todoEntries = source.visibleEntries.where(filter.test).toSet();
|
||||
final todoCount = todoEntries.length;
|
||||
|
||||
final destinationAlbumParent = pContext.dirname(album);
|
||||
final destinationAlbum = pContext.join(destinationAlbumParent, newName);
|
||||
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
||||
|
@ -353,4 +463,37 @@ class AlbumChipSetActionDelegate extends ChipSetActionDelegate<AlbumFilter> with
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _configureVault(BuildContext context, AlbumFilter filter) async {
|
||||
if (!await unlockFilter(context, filter)) return;
|
||||
|
||||
final oldDetails = vaults.getVault(filter.album);
|
||||
if (oldDetails == null) return;
|
||||
|
||||
final newDetails = await showDialog<VaultDetails>(
|
||||
context: context,
|
||||
builder: (context) => EditVaultDialog(initialDetails: oldDetails),
|
||||
routeSettings: const RouteSettings(name: EditVaultDialog.routeName),
|
||||
);
|
||||
if (newDetails == null || oldDetails == newDetails) return;
|
||||
|
||||
if (oldDetails.useBin && !newDetails.useBin) {
|
||||
final filter = AlbumFilter(oldDetails.path, null);
|
||||
final source = context.read<CollectionSource>();
|
||||
await _deleteEntriesForever(context, source.trashedEntries.where(filter.test).toSet());
|
||||
}
|
||||
|
||||
final oldName = oldDetails.name;
|
||||
final newName = newDetails.name;
|
||||
if (oldName != newName) {
|
||||
await vaults.update(newDetails.copyWith(name: oldName));
|
||||
// wipe the old pass, if any, so that it does not overwrite the new pass
|
||||
// when renaming the vault afterwards
|
||||
await securityService.writeValue(oldDetails.passKey, null);
|
||||
await _doRename(context, filter, newName);
|
||||
} else {
|
||||
await vaults.update(newDetails);
|
||||
_browse(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
import 'package:aves/model/actions/chip_actions.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/highlight.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||
|
@ -11,7 +15,24 @@ import 'package:aves/widgets/filter_grids/tags_page.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class ChipActionDelegate {
|
||||
class ChipActionDelegate with FeedbackMixin, VaultAwareMixin {
|
||||
bool isVisible(
|
||||
ChipAction action, {
|
||||
required CollectionFilter filter,
|
||||
}) {
|
||||
switch (action) {
|
||||
case ChipAction.goToAlbumPage:
|
||||
case ChipAction.goToCountryPage:
|
||||
case ChipAction.goToTagPage:
|
||||
case ChipAction.reverse:
|
||||
return true;
|
||||
case ChipAction.hide:
|
||||
return !(filter is AlbumFilter && vaults.isVault(filter.album));
|
||||
case ChipAction.lockVault:
|
||||
return (filter is AlbumFilter && vaults.isVault(filter.album) && !vaults.isLocked(filter.album));
|
||||
}
|
||||
}
|
||||
|
||||
void onActionSelected(BuildContext context, CollectionFilter filter, ChipAction action) {
|
||||
reportService.log('$action');
|
||||
switch (action) {
|
||||
|
@ -30,8 +51,10 @@ class ChipActionDelegate {
|
|||
case ChipAction.hide:
|
||||
_hide(context, filter);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
case ChipAction.lockVault:
|
||||
if (filter is AlbumFilter) {
|
||||
lockFilters({filter});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,6 +17,7 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/search/route.dart';
|
||||
import 'package:aves/widgets/common/tile_extent_controller.dart';
|
||||
|
@ -33,7 +34,7 @@ import 'package:flutter/scheduler.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
|
||||
abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, VaultAwareMixin {
|
||||
Iterable<FilterGridItem<T>> get allItems;
|
||||
|
||||
ChipSortFactor get sortFactor;
|
||||
|
@ -88,6 +89,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
case ChipSetAction.toggleTitleSearch:
|
||||
return !useTvLayout && !isSelecting;
|
||||
case ChipSetAction.createAlbum:
|
||||
case ChipSetAction.createVault:
|
||||
return false;
|
||||
// browsing or selecting
|
||||
case ChipSetAction.map:
|
||||
|
@ -95,19 +97,21 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
case ChipSetAction.stats:
|
||||
return isMain;
|
||||
// selecting (single/multiple filters)
|
||||
case ChipSetAction.delete:
|
||||
return false;
|
||||
case ChipSetAction.hide:
|
||||
return isMain;
|
||||
case ChipSetAction.pin:
|
||||
return !hasSelection || !settings.pinnedFilters.containsAll(selectedFilters);
|
||||
case ChipSetAction.unpin:
|
||||
return hasSelection && settings.pinnedFilters.containsAll(selectedFilters);
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
case ChipSetAction.delete:
|
||||
case ChipSetAction.lockVault:
|
||||
return false;
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.setCover:
|
||||
return isMain;
|
||||
case ChipSetAction.rename:
|
||||
case ChipSetAction.configureVault:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,6 +135,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
case ChipSetAction.search:
|
||||
case ChipSetAction.toggleTitleSearch:
|
||||
case ChipSetAction.createAlbum:
|
||||
case ChipSetAction.createVault:
|
||||
return true;
|
||||
// browsing or selecting
|
||||
case ChipSetAction.map:
|
||||
|
@ -142,10 +147,12 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
case ChipSetAction.hide:
|
||||
case ChipSetAction.pin:
|
||||
case ChipSetAction.unpin:
|
||||
case ChipSetAction.lockVault:
|
||||
return hasSelection;
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
case ChipSetAction.setCover:
|
||||
case ChipSetAction.configureVault:
|
||||
return selectedItemCount == 1;
|
||||
}
|
||||
}
|
||||
|
@ -174,6 +181,7 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
context.read<Query>().toggle();
|
||||
break;
|
||||
case ChipSetAction.createAlbum:
|
||||
case ChipSetAction.createVault:
|
||||
break;
|
||||
// browsing or selecting
|
||||
case ChipSetAction.map:
|
||||
|
@ -186,8 +194,6 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
_goToStats(context, filters);
|
||||
break;
|
||||
// selecting (single/multiple filters)
|
||||
case ChipSetAction.delete:
|
||||
break;
|
||||
case ChipSetAction.hide:
|
||||
_hide(context, filters);
|
||||
break;
|
||||
|
@ -199,12 +205,16 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
settings.pinnedFilters = settings.pinnedFilters..removeAll(filters);
|
||||
_browse(context);
|
||||
break;
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.rename:
|
||||
case ChipSetAction.delete:
|
||||
case ChipSetAction.lockVault:
|
||||
break;
|
||||
// selecting (single filter)
|
||||
case ChipSetAction.setCover:
|
||||
_setCover(context, filters.first);
|
||||
break;
|
||||
case ChipSetAction.rename:
|
||||
case ChipSetAction.configureVault:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -326,6 +336,8 @@ abstract class ChipSetActionDelegate<T extends CollectionFilter> with FeedbackMi
|
|||
}
|
||||
|
||||
void _setCover(BuildContext context, T filter) async {
|
||||
if (!await unlockFilter(context, filter)) return;
|
||||
|
||||
final existingCover = covers.of(filter);
|
||||
final entryId = existingCover?.item1;
|
||||
final customEntry = entryId != null ? context.read<CollectionSource>().visibleEntries.firstWhereOrNull((entry) => entry.id == entryId) : null;
|
||||
|
|
|
@ -19,6 +19,7 @@ import 'package:aves/widgets/common/search/route.dart';
|
|||
import 'package:aves/widgets/filter_grids/common/action_delegates/chip_set.dart';
|
||||
import 'package:aves/widgets/filter_grids/common/query_bar.dart';
|
||||
import 'package:aves/widgets/search/search_delegate.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
@ -51,7 +52,7 @@ class FilterGridAppBar<T extends CollectionFilter, CSAD extends ChipSetActionDel
|
|||
@override
|
||||
State<FilterGridAppBar<T, CSAD>> createState() => _FilterGridAppBarState<T, CSAD>();
|
||||
|
||||
static PopupMenuItem<ChipSetAction> toMenuItem(BuildContext context, ChipSetAction action, {required bool enabled}) {
|
||||
static PopupMenuEntry<ChipSetAction> toMenuItem(BuildContext context, ChipSetAction action, {required bool enabled}) {
|
||||
late Widget child;
|
||||
switch (action) {
|
||||
case ChipSetAction.toggleTitleSearch:
|
||||
|
@ -286,7 +287,7 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
|
|||
return [
|
||||
...ChipSetActions.general,
|
||||
...isSelecting ? ChipSetActions.selection : ChipSetActions.browsing,
|
||||
].where(isVisible).map((action) {
|
||||
].whereNotNull().where(isVisible).map((action) {
|
||||
final enabled = canApply(action);
|
||||
return CaptionedButton(
|
||||
iconButtonBuilder: (context, focusNode) => _buildButtonIcon(
|
||||
|
@ -326,15 +327,20 @@ class _FilterGridAppBarState<T extends CollectionFilter, CSAD extends ChipSetAct
|
|||
|
||||
final browsingMenuActions = ChipSetActions.browsing.where((v) => !browsingQuickActions.contains(v));
|
||||
final selectionMenuActions = ChipSetActions.selection.where((v) => !selectionQuickActions.contains(v));
|
||||
final contextualMenuItems = (isSelecting ? selectionMenuActions : browsingMenuActions).where(isVisible).map(
|
||||
(action) => FilterGridAppBar.toMenuItem(context, action, enabled: canApply(action)),
|
||||
);
|
||||
final contextualMenuActions = (isSelecting ? selectionMenuActions : browsingMenuActions).where((v) => v == null || isVisible(v)).toList();
|
||||
if (contextualMenuActions.isNotEmpty && contextualMenuActions.first == null) contextualMenuActions.removeAt(0);
|
||||
if (contextualMenuActions.isNotEmpty && contextualMenuActions.last == null) contextualMenuActions.removeLast();
|
||||
|
||||
return [
|
||||
...generalMenuItems,
|
||||
if (contextualMenuItems.isNotEmpty) ...[
|
||||
if (contextualMenuActions.isNotEmpty) ...[
|
||||
const PopupMenuDivider(),
|
||||
...contextualMenuItems,
|
||||
...contextualMenuActions.map(
|
||||
(action) {
|
||||
if (action == null) return const PopupMenuDivider();
|
||||
return FilterGridAppBar.toMenuItem(context, action, enabled: canApply(action));
|
||||
},
|
||||
),
|
||||
],
|
||||
];
|
||||
},
|
||||
|
|
|
@ -9,9 +9,11 @@ import 'package:aves/model/source/album.dart';
|
|||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/location.dart';
|
||||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/android_file_utils.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
import 'package:aves/widgets/common/thumbnail/image.dart';
|
||||
|
@ -22,7 +24,7 @@ import 'package:provider/provider.dart';
|
|||
class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
|
||||
final T filter;
|
||||
final double extent, thumbnailExtent;
|
||||
final bool showText, pinned;
|
||||
final bool showText, pinned, locked;
|
||||
final String? banner;
|
||||
final FilterCallback? onTap;
|
||||
final HeroType heroType;
|
||||
|
@ -34,6 +36,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
|
|||
double? thumbnailExtent,
|
||||
this.showText = true,
|
||||
this.pinned = false,
|
||||
required this.locked,
|
||||
this.banner,
|
||||
this.onTap,
|
||||
this.heroType = HeroType.onTap,
|
||||
|
@ -98,17 +101,18 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildChip(BuildContext context, CollectionSource source) {
|
||||
final entry = source.coverEntry(filter);
|
||||
final _filter = filter;
|
||||
final entry = _filter is AlbumFilter && vaults.isLocked(_filter.album) ? null : source.coverEntry(_filter);
|
||||
final titlePadding = min<double>(4.0, extent / 32);
|
||||
Key? chipKey;
|
||||
if (filter is AlbumFilter) {
|
||||
if (_filter is AlbumFilter) {
|
||||
// when we asynchronously fetch installed app names,
|
||||
// album filters themselves do not change, but decoration derived from it does
|
||||
chipKey = ValueKey(androidFileUtils.areAppNamesReadyNotifier.value);
|
||||
}
|
||||
return AvesFilterChip(
|
||||
key: chipKey,
|
||||
filter: filter,
|
||||
filter: _filter,
|
||||
showText: showText,
|
||||
showGenericIcon: false,
|
||||
decoration: AvesFilterDecoration(
|
||||
|
@ -128,10 +132,10 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
|
|||
},
|
||||
child: entry == null
|
||||
? StreamBuilder<Set<CollectionFilter>?>(
|
||||
stream: covers.colorChangeStream.where((event) => event == null || event.contains(filter)),
|
||||
stream: covers.colorChangeStream.where((event) => event == null || event.contains(_filter)),
|
||||
builder: (context, snapshot) {
|
||||
return FutureBuilder<Color>(
|
||||
future: filter.color(context),
|
||||
future: _filter.color(context),
|
||||
builder: (context, snapshot) {
|
||||
final color = snapshot.data;
|
||||
const neutral = Colors.white;
|
||||
|
@ -159,7 +163,7 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
|
|||
radius: radius(extent),
|
||||
),
|
||||
banner: banner,
|
||||
details: showText ? _buildDetails(context, source, filter) : null,
|
||||
details: showText ? _buildDetails(context, source, _filter) : null,
|
||||
padding: titlePadding,
|
||||
heroType: heroType,
|
||||
onTap: onTap,
|
||||
|
@ -199,8 +203,18 @@ class CoveredFilterChip<T extends CollectionFilter> extends StatelessWidget {
|
|||
size: iconSize,
|
||||
),
|
||||
),
|
||||
if (filter is AlbumFilter && vaults.isVault(filter.album))
|
||||
AnimatedPadding(
|
||||
padding: EdgeInsetsDirectional.only(end: padding),
|
||||
duration: Durations.chipDecorationAnimation,
|
||||
child: Icon(
|
||||
AIcons.locked,
|
||||
color: _detailColor(context),
|
||||
size: iconSize,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
numberFormat.format(source.count(filter)),
|
||||
locked ? Constants.overlayUnknown : numberFormat.format(source.count(filter)),
|
||||
style: TextStyle(
|
||||
color: _detailColor(context),
|
||||
fontSize: fontSize,
|
||||
|
|
|
@ -2,7 +2,7 @@ import 'package:aves/theme/icons.dart';
|
|||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum AlbumImportance { newAlbum, pinned, special, apps, regular }
|
||||
enum AlbumImportance { newAlbum, pinned, special, apps, vaults, regular }
|
||||
|
||||
extension ExtraAlbumImportance on AlbumImportance {
|
||||
String getText(BuildContext context) {
|
||||
|
@ -15,6 +15,8 @@ extension ExtraAlbumImportance on AlbumImportance {
|
|||
return context.l10n.albumTierSpecial;
|
||||
case AlbumImportance.apps:
|
||||
return context.l10n.albumTierApps;
|
||||
case AlbumImportance.vaults:
|
||||
return context.l10n.albumTierVaults;
|
||||
case AlbumImportance.regular:
|
||||
return context.l10n.albumTierRegular;
|
||||
}
|
||||
|
@ -30,6 +32,8 @@ extension ExtraAlbumImportance on AlbumImportance {
|
|||
return AIcons.important;
|
||||
case AlbumImportance.apps:
|
||||
return AIcons.app;
|
||||
case AlbumImportance.vaults:
|
||||
return AIcons.locked;
|
||||
case AlbumImportance.regular:
|
||||
return AIcons.album;
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums/enums.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/durations.dart';
|
||||
import 'package:aves/widgets/common/basic/draggable_scrollbar.dart';
|
||||
|
@ -346,57 +347,63 @@ class _FilterGridContentState<T extends CollectionFilter> extends State<_FilterG
|
|||
extent: thumbnailExtent,
|
||||
child: FilterListDetailsTheme(
|
||||
extent: thumbnailExtent,
|
||||
child: SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleSections,
|
||||
showHeaders: widget.showHeaders,
|
||||
selectable: widget.selectable,
|
||||
tileLayout: tileLayout,
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
tileWidth: thumbnailExtent,
|
||||
tileHeight: tileHeight,
|
||||
tileBuilder: (gridItem, tileSize) {
|
||||
final extent = tileSize.shortestSide;
|
||||
final tile = InteractiveFilterTile(
|
||||
gridItem: gridItem,
|
||||
chipExtent: extent,
|
||||
thumbnailExtent: extent,
|
||||
child: AnimatedBuilder(
|
||||
animation: vaults,
|
||||
builder: (context, child) {
|
||||
return SectionedFilterListLayoutProvider<T>(
|
||||
sections: visibleSections,
|
||||
showHeaders: widget.showHeaders,
|
||||
selectable: widget.selectable,
|
||||
tileLayout: tileLayout,
|
||||
banner: _getFilterBanner(context, gridItem.filter),
|
||||
heroType: widget.heroType,
|
||||
);
|
||||
if (!settings.useTvLayout) return tile;
|
||||
scrollableWidth: scrollableWidth,
|
||||
columnCount: columnCount,
|
||||
spacing: tileSpacing,
|
||||
horizontalPadding: horizontalPadding,
|
||||
tileWidth: thumbnailExtent,
|
||||
tileHeight: tileHeight,
|
||||
tileBuilder: (gridItem, tileSize) {
|
||||
final extent = tileSize.shortestSide;
|
||||
final tile = InteractiveFilterTile(
|
||||
gridItem: gridItem,
|
||||
chipExtent: extent,
|
||||
thumbnailExtent: extent,
|
||||
tileLayout: tileLayout,
|
||||
banner: _getFilterBanner(context, gridItem.filter),
|
||||
heroType: widget.heroType,
|
||||
);
|
||||
if (!settings.useTvLayout) return tile;
|
||||
|
||||
return Focus(
|
||||
onFocusChange: (focused) {
|
||||
if (focused) {
|
||||
_focusedItemNotifier.value = gridItem;
|
||||
} else if (_focusedItemNotifier.value == gridItem) {
|
||||
_focusedItemNotifier.value = null;
|
||||
}
|
||||
return Focus(
|
||||
onFocusChange: (focused) {
|
||||
if (focused) {
|
||||
_focusedItemNotifier.value = gridItem;
|
||||
} else if (_focusedItemNotifier.value == gridItem) {
|
||||
_focusedItemNotifier.value = null;
|
||||
}
|
||||
},
|
||||
child: ValueListenableBuilder<FilterGridItem<T>?>(
|
||||
valueListenable: _focusedItemNotifier,
|
||||
builder: (context, focusedItem, child) {
|
||||
return AnimatedScale(
|
||||
scale: focusedItem == gridItem ? 1 : .9,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
duration: context.select<DurationsData, Duration>((v) => v.tvImageFocusAnimation),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: tile,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: ValueListenableBuilder<FilterGridItem<T>?>(
|
||||
valueListenable: _focusedItemNotifier,
|
||||
builder: (context, focusedItem, child) {
|
||||
return AnimatedScale(
|
||||
scale: focusedItem == gridItem ? 1 : .9,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
duration: context.select<DurationsData, Duration>((v) => v.tvImageFocusAnimation),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
child: tile,
|
||||
),
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
coverRatioResolver: (item) {
|
||||
final coverEntry = source.coverEntry(item.filter) ?? item.entry;
|
||||
return coverEntry?.displayAspectRatio ?? 1;
|
||||
},
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
tileAnimationDelay: tileAnimationDelay,
|
||||
coverRatioResolver: (item) {
|
||||
final coverEntry = source.coverEntry(item.filter) ?? item.entry;
|
||||
return coverEntry?.displayAspectRatio ?? 1;
|
||||
},
|
||||
child: child!,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -1,10 +1,14 @@
|
|||
import 'package:aves/app_mode.dart';
|
||||
import 'package:aves/model/filters/album.dart';
|
||||
import 'package:aves/model/filters/filters.dart';
|
||||
import 'package:aves/model/selection.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/source/enums/enums.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/common/grid/scaling.dart';
|
||||
import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
|
||||
|
@ -35,7 +39,7 @@ class InteractiveFilterTile<T extends CollectionFilter> extends StatefulWidget {
|
|||
State<InteractiveFilterTile<T>> createState() => _InteractiveFilterTileState<T>();
|
||||
}
|
||||
|
||||
class _InteractiveFilterTileState<T extends CollectionFilter> extends State<InteractiveFilterTile<T>> {
|
||||
class _InteractiveFilterTileState<T extends CollectionFilter> extends State<InteractiveFilterTile<T>> with FeedbackMixin, VaultAwareMixin {
|
||||
HeroType? _heroTypeOverride;
|
||||
|
||||
FilterGridItem<T> get gridItem => widget.gridItem;
|
||||
|
@ -46,7 +50,9 @@ class _InteractiveFilterTileState<T extends CollectionFilter> extends State<Inte
|
|||
Widget build(BuildContext context) {
|
||||
final filter = gridItem.filter;
|
||||
|
||||
void onTap() {
|
||||
Future<void> onTap() async {
|
||||
if (!await unlockFilter(context, filter)) return;
|
||||
|
||||
final appMode = context.read<ValueNotifier<AppMode>?>()?.value;
|
||||
switch (appMode) {
|
||||
case AppMode.main:
|
||||
|
@ -135,6 +141,7 @@ class FilterTile<T extends CollectionFilter> extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
final filter = gridItem.filter;
|
||||
final pinned = settings.pinnedFilters.contains(filter);
|
||||
final locked = filter is AlbumFilter && vaults.isLocked(filter.album);
|
||||
final onChipTap = onTap != null ? (filter) => onTap?.call() : null;
|
||||
|
||||
switch (tileLayout) {
|
||||
|
@ -151,6 +158,7 @@ class FilterTile<T extends CollectionFilter> extends StatelessWidget {
|
|||
thumbnailExtent: thumbnailExtent,
|
||||
showText: true,
|
||||
pinned: pinned,
|
||||
locked: locked,
|
||||
banner: banner,
|
||||
onTap: onChipTap,
|
||||
heroType: heroType,
|
||||
|
@ -170,6 +178,7 @@ class FilterTile<T extends CollectionFilter> extends StatelessWidget {
|
|||
extent: chipExtent,
|
||||
thumbnailExtent: thumbnailExtent,
|
||||
showText: false,
|
||||
locked: locked,
|
||||
banner: banner,
|
||||
onTap: onChipTap,
|
||||
heroType: heroType,
|
||||
|
@ -179,6 +188,7 @@ class FilterTile<T extends CollectionFilter> extends StatelessWidget {
|
|||
child: FilterListDetails(
|
||||
gridItem: gridItem,
|
||||
pinned: pinned,
|
||||
locked: locked,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
@ -16,7 +16,7 @@ import 'package:provider/provider.dart';
|
|||
|
||||
class FilterListDetails<T extends CollectionFilter> extends StatelessWidget {
|
||||
final FilterGridItem<T> gridItem;
|
||||
final bool pinned;
|
||||
final bool pinned, locked;
|
||||
|
||||
T get filter => gridItem.filter;
|
||||
|
||||
|
@ -26,6 +26,7 @@ class FilterListDetails<T extends CollectionFilter> extends StatelessWidget {
|
|||
super.key,
|
||||
required this.gridItem,
|
||||
required this.pinned,
|
||||
required this.locked,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -72,9 +73,11 @@ class FilterListDetails<T extends CollectionFilter> extends StatelessWidget {
|
|||
// otherwise the leading icon will be low-res scaled up/down
|
||||
textScaleFactor: 1,
|
||||
),
|
||||
const SizedBox(height: FilterListDetailsTheme.titleDetailPadding),
|
||||
if (detailsTheme.showDate) _buildDateRow(context, detailsTheme, hasTitleLeading),
|
||||
if (detailsTheme.showCount) _buildCountRow(context, detailsTheme, hasTitleLeading),
|
||||
if (!locked) ...[
|
||||
const SizedBox(height: FilterListDetailsTheme.titleDetailPadding),
|
||||
if (detailsTheme.showDate) _buildDateRow(context, detailsTheme, hasTitleLeading),
|
||||
if (detailsTheme.showCount) _buildCountRow(context, detailsTheme, hasTitleLeading),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
@ -32,6 +32,8 @@ class AlbumImportanceSectionKey extends ChipSectionKey {
|
|||
|
||||
factory AlbumImportanceSectionKey.apps(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.apps);
|
||||
|
||||
factory AlbumImportanceSectionKey.vault(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.vaults);
|
||||
|
||||
factory AlbumImportanceSectionKey.regular(BuildContext context) => AlbumImportanceSectionKey._private(context, AlbumImportance.regular);
|
||||
|
||||
@override
|
||||
|
|
|
@ -107,11 +107,10 @@ class _AppDrawerState extends State<AppDrawer> {
|
|||
Future<void> goTo(String routeName, WidgetBuilder pageBuilder) async {
|
||||
Navigator.maybeOf(context)?.pop();
|
||||
await Future.delayed(Durations.drawerTransitionAnimation);
|
||||
await Navigator.maybeOf(context)?.push(
|
||||
MaterialPageRoute(
|
||||
settings: RouteSettings(name: routeName),
|
||||
builder: pageBuilder,
|
||||
));
|
||||
await Navigator.maybeOf(context)?.push(MaterialPageRoute(
|
||||
settings: RouteSettings(name: routeName),
|
||||
builder: pageBuilder,
|
||||
));
|
||||
}
|
||||
|
||||
return Container(
|
||||
|
|
|
@ -19,6 +19,8 @@ import 'package:aves/model/source/location.dart';
|
|||
import 'package:aves/model/source/tag.dart';
|
||||
import 'package:aves/ref/mime_types.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
|
||||
import 'package:aves/widgets/common/basic/tv_edge_focus.dart';
|
||||
import 'package:aves/widgets/common/expandable_filter_row.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
|
@ -30,7 +32,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class CollectionSearchDelegate extends AvesSearchDelegate {
|
||||
class CollectionSearchDelegate extends AvesSearchDelegate with FeedbackMixin, VaultAwareMixin {
|
||||
final CollectionSource source;
|
||||
final CollectionLens? parentCollection;
|
||||
final ValueNotifier<String?> _expandedSectionNotifier = ValueNotifier(null);
|
||||
|
@ -285,12 +287,14 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
|
|||
return cleanQuery.isNotEmpty ? QueryFilter(cleanQuery, colorful: colorful) : null;
|
||||
}
|
||||
|
||||
void _select(BuildContext context, CollectionFilter? filter) {
|
||||
Future<void> _select(BuildContext context, CollectionFilter? filter) async {
|
||||
if (filter == null) {
|
||||
goBack(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await unlockFilter(context, filter)) return;
|
||||
|
||||
if (settings.saveSearchHistory) {
|
||||
final history = settings.searchHistory
|
||||
..remove(filter)
|
||||
|
|
|
@ -39,6 +39,12 @@ class ConfirmationDialogPage extends StatelessWidget {
|
|||
onChanged: (v) => settings.confirmAfterMoveToBin = v,
|
||||
title: l10n.settingsConfirmationAfterMoveToBinItems,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
SettingsSwitchListTile(
|
||||
selector: (context, s) => s.confirmCreateVault,
|
||||
onChanged: (v) => settings.confirmCreateVault = v,
|
||||
title: l10n.settingsConfirmationVaultDataLoss,
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
|
|
@ -3,10 +3,15 @@ import 'dart:async';
|
|||
import 'package:aves/app_flavor.dart';
|
||||
import 'package:aves/model/device.dart';
|
||||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/theme/colors.dart';
|
||||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/widgets/collection/entry_set_action_delegate.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||
import 'package:aves/widgets/settings/common/tile_leading.dart';
|
||||
import 'package:aves/widgets/settings/common/tiles.dart';
|
||||
import 'package:aves/widgets/settings/privacy/access_grants_page.dart';
|
||||
|
@ -92,7 +97,41 @@ class SettingsTilePrivacyEnableBin extends SettingsTile {
|
|||
@override
|
||||
Widget build(BuildContext context) => SettingsSwitchListTile(
|
||||
selector: (context, s) => s.enableBin,
|
||||
onChanged: (v) {
|
||||
onChanged: (v) async {
|
||||
final l10n = context.l10n;
|
||||
if (!v) {
|
||||
if (vaults.all.any((v) => v.useBin)) {
|
||||
await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AvesDialog(
|
||||
content: Text(l10n.vaultBinUsageDialogMessage),
|
||||
actions: const [OkButton()],
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final source = context.read<CollectionSource>();
|
||||
final trashedEntries = source.trashedEntries;
|
||||
if (trashedEntries.isNotEmpty) {
|
||||
if (!await showConfirmationDialog(
|
||||
context: context,
|
||||
message: l10n.settingsDisablingBinWarningDialogMessage,
|
||||
confirmationButtonLabel: l10n.applyButtonLabel,
|
||||
)) return;
|
||||
|
||||
// delete forever trashed items
|
||||
await EntrySetActionDelegate().doDelete(
|
||||
context: context,
|
||||
entries: trashedEntries,
|
||||
enableBin: false,
|
||||
);
|
||||
|
||||
// in case of failure or cancellation
|
||||
if (source.trashedEntries.isNotEmpty) return;
|
||||
}
|
||||
}
|
||||
|
||||
settings.enableBin = v;
|
||||
if (!v) {
|
||||
settings.searchHistory = [];
|
||||
|
|
|
@ -14,6 +14,8 @@ import 'package:aves/theme/durations.dart';
|
|||
import 'package:aves/theme/icons.dart';
|
||||
import 'package:aves/utils/constants.dart';
|
||||
import 'package:aves/widgets/collection/collection_page.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
|
||||
import 'package:aves/widgets/common/basic/insets.dart';
|
||||
import 'package:aves/widgets/common/basic/scaffold.dart';
|
||||
import 'package:aves/widgets/common/basic/tv_edge_focus.dart';
|
||||
|
@ -51,7 +53,7 @@ class StatsPage extends StatefulWidget {
|
|||
State<StatsPage> createState() => _StatsPageState();
|
||||
}
|
||||
|
||||
class _StatsPageState extends State<StatsPage> {
|
||||
class _StatsPageState extends State<StatsPage> with FeedbackMixin, VaultAwareMixin {
|
||||
final Map<String, int> _entryCountPerCountry = {}, _entryCountPerPlace = {}, _entryCountPerTag = {}, _entryCountPerAlbum = {};
|
||||
final Map<int, int> _entryCountPerRating = Map.fromEntries(List.generate(7, (i) => MapEntry(5 - i, 0)));
|
||||
late final ValueNotifier<bool> _isPageAnimatingNotifier;
|
||||
|
@ -319,7 +321,9 @@ class _StatsPageState extends State<StatsPage> {
|
|||
];
|
||||
}
|
||||
|
||||
void _onFilterSelection(BuildContext context, CollectionFilter filter) {
|
||||
Future<void> _onFilterSelection(BuildContext context, CollectionFilter filter) async {
|
||||
if (!await unlockFilter(context, filter)) return;
|
||||
|
||||
if (widget.parentCollection != null) {
|
||||
_applyToParentCollectionPage(context, filter);
|
||||
} else {
|
||||
|
|
|
@ -14,6 +14,7 @@ import 'package:aves/model/settings/enums/enums.dart';
|
|||
import 'package:aves/model/settings/settings.dart';
|
||||
import 'package:aves/model/source/collection_lens.dart';
|
||||
import 'package:aves/model/source/collection_source.dart';
|
||||
import 'package:aves/model/vaults/vaults.dart';
|
||||
import 'package:aves/services/common/image_op_events.dart';
|
||||
import 'package:aves/services/common/services.dart';
|
||||
import 'package:aves/services/media/enums.dart';
|
||||
|
@ -25,6 +26,7 @@ import 'package:aves/widgets/common/action_mixins/entry_storage.dart';
|
|||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/size_aware.dart';
|
||||
import 'package:aves/widgets/common/action_mixins/vault_aware.dart';
|
||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||
import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart';
|
||||
import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart';
|
||||
|
@ -47,7 +49,7 @@ import 'package:flutter/scheduler.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:tuple/tuple.dart';
|
||||
|
||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin {
|
||||
class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin, SingleEntryEditorMixin, EntryStorageMixin, VaultAwareMixin {
|
||||
final AvesEntry mainEntry, pageEntry;
|
||||
final CollectionLens? collection;
|
||||
final EntryInfoActionDelegate _metadataActionDelegate = EntryInfoActionDelegate();
|
||||
|
@ -290,11 +292,13 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
}
|
||||
}
|
||||
|
||||
void quickMove(BuildContext context, String album, {required bool copy}) {
|
||||
Future<void> quickMove(BuildContext context, String album, {required bool copy}) async {
|
||||
if (!await unlockAlbum(context, album)) return;
|
||||
|
||||
final targetEntry = _getTargetEntry(context, copy ? EntryAction.copy : EntryAction.move);
|
||||
if (!copy && targetEntry.directory == album) return;
|
||||
|
||||
doQuickMove(
|
||||
await doQuickMove(
|
||||
context,
|
||||
moveType: copy ? MoveType.copy : MoveType.move,
|
||||
entriesByDestination: {
|
||||
|
@ -379,13 +383,16 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
}
|
||||
|
||||
Future<void> _delete(BuildContext context, AvesEntry targetEntry) async {
|
||||
if (settings.enableBin && !targetEntry.trashed) {
|
||||
final vault = vaults.getVault(targetEntry.directory);
|
||||
final enableBin = vault?.useBin ?? settings.enableBin;
|
||||
|
||||
if (enableBin && !targetEntry.trashed) {
|
||||
await _move(context, targetEntry, moveType: MoveType.toBin);
|
||||
return;
|
||||
}
|
||||
|
||||
final l10n = context.l10n;
|
||||
if (!await showConfirmationDialog(
|
||||
if (!await showSkippableConfirmationDialog(
|
||||
context: context,
|
||||
type: ConfirmationDialog.deleteForever,
|
||||
message: l10n.deleteEntriesConfirmationDialogMessage(1),
|
||||
|
@ -446,14 +453,14 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
|
|||
nameConflictStrategy: NameConflictStrategy.rename,
|
||||
),
|
||||
itemCount: selectionCount,
|
||||
onDone: (processed) {
|
||||
onDone: (processed) async {
|
||||
final successOps = processed.where((e) => e.success).toSet();
|
||||
final exportedOps = successOps.where((e) => !e.skipped).toSet();
|
||||
final newUris = exportedOps.map((v) => v.newFields['uri'] as String?).whereNotNull().toSet();
|
||||
final isMainMode = context.read<ValueNotifier<AppMode>>().value == AppMode.main;
|
||||
|
||||
source.resumeMonitoring();
|
||||
source.refreshUris(newUris);
|
||||
unawaited(source.refreshUris(newUris));
|
||||
|
||||
final l10n = context.l10n;
|
||||
final showAction = isMainMode && newUris.isNotEmpty
|
||||
|
|
|
@ -39,7 +39,7 @@ class _DbTabState extends State<DbTab> {
|
|||
void _loadDatabase() {
|
||||
final id = entry.id;
|
||||
_dbDateLoader = metadataDb.loadDates().then((values) => values[id]);
|
||||
_dbEntryLoader = metadataDb.loadEntries().then((values) => values.firstWhereOrNull((row) => row.id == id));
|
||||
_dbEntryLoader = metadataDb.loadEntriesById({id}).then((values) => values.firstOrNull);
|
||||
_dbMetadataLoader = metadataDb.loadCatalogMetadata().then((values) => values.firstWhereOrNull((row) => row.id == id));
|
||||
_dbAddressLoader = metadataDb.loadAddresses().then((values) => values.firstWhereOrNull((row) => row.id == id));
|
||||
_dbTrashDetailsLoader = metadataDb.loadAllTrashDetails().then((values) => values.firstWhereOrNull((row) => row.id == id));
|
||||
|
|
|
@ -69,6 +69,7 @@ class ViewerDebugPage extends StatelessWidget {
|
|||
info: {
|
||||
'hash': '#${shortHash(entry)}',
|
||||
'id': '${entry.id}',
|
||||
'origin': '${entry.origin}',
|
||||
'contentId': '${entry.contentId}',
|
||||
'uri': entry.uri,
|
||||
'path': entry.path ?? '',
|
||||
|
|
|
@ -2,14 +2,14 @@ group 'deckers.thibault.aves.aves_platform_meta'
|
|||
version '1.0-SNAPSHOT'
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.7.10'
|
||||
ext.kotlin_version = '1.7.20'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:7.2.1'
|
||||
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
72
pubspec.lock
72
pubspec.lock
|
@ -627,6 +627,46 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
local_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: local_auth
|
||||
sha256: "8cea55dca20d1e0efa5480df2d47ae30851e7a24cb8e7d225be7e67ae8485aa4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
local_auth_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_android
|
||||
sha256: cfcbc4936e288d61ef85a04feef6b95f49ba496d4fd98364e6abafb462b06a1f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.18"
|
||||
local_auth_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_ios
|
||||
sha256: aa32478d7513066564139af57e11e2cad1bbd535c1efd224a88a8764c5665e3b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.12"
|
||||
local_auth_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_platform_interface
|
||||
sha256: fbb6973f2fd088e2677f39a5ab550aa1cfbc00997859d5e865569872499d6d61
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
local_auth_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: local_auth_windows
|
||||
sha256: "888482e4f9ca3560e00bc227ce2badeb4857aad450c42a31c6cfc9dc21e0ccbc"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -877,6 +917,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.0"
|
||||
pinput:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pinput
|
||||
sha256: e6aabd1571dde622f9b942f62ac2c80f84b0b50f95fa209a93e78f7d621e1f82
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.23"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1013,6 +1061,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
screen_state:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: screen_state
|
||||
sha256: "39184c718baf303f26200f6b1392b12a549d88410e907e046d75594588c0df5d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -1106,6 +1162,14 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.99"
|
||||
smart_auth:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: smart_auth
|
||||
sha256: "8cfaec55b77d5930ed1666bb7ae70db5bade099bb1422401386853b400962113"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.8"
|
||||
smooth_page_indicator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -1275,6 +1339,14 @@ packages:
|
|||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
universal_platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_platform
|
||||
sha256: d315be0f6641898b280ffa34e2ddb14f3d12b1a37882557869646e0cc363d0cc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0+1"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
|
|
@ -69,6 +69,7 @@ dependencies:
|
|||
get_it:
|
||||
intl:
|
||||
latlong2:
|
||||
local_auth:
|
||||
material_color_utilities:
|
||||
material_design_icons_flutter:
|
||||
overlay_support:
|
||||
|
@ -82,10 +83,12 @@ dependencies:
|
|||
pdf:
|
||||
percent_indicator:
|
||||
permission_handler:
|
||||
pinput:
|
||||
printing:
|
||||
proj4dart:
|
||||
provider:
|
||||
screen_brightness:
|
||||
screen_state:
|
||||
shared_preferences:
|
||||
smooth_page_indicator:
|
||||
sqflite:
|
||||
|
|
|
@ -4,5 +4,5 @@ import 'package:test/fake.dart';
|
|||
|
||||
class FakeDeviceService extends Fake implements DeviceService {
|
||||
@override
|
||||
Future<String> getDefaultTimeZone() => SynchronousFuture('');
|
||||
Future<int> getDefaultTimeZoneRawOffsetMillis() => SynchronousFuture(3600000);
|
||||
}
|
||||
|
|
|
@ -39,6 +39,7 @@ class FakeMediaStoreService extends Fake implements MediaStoreService {
|
|||
contentId ??= id;
|
||||
final date = dateSecs;
|
||||
return AvesEntry(
|
||||
origin: EntryOrigins.mediaStoreContent,
|
||||
id: id,
|
||||
uri: 'content://media/external/images/media/$contentId',
|
||||
path: '$album/$filenameWithoutExtension.jpg',
|
||||
|
|
|
@ -19,15 +19,15 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
Future<void> init() => SynchronousFuture(null);
|
||||
|
||||
@override
|
||||
Future<void> removeIds(Iterable<int> ids, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
|
||||
Future<void> removeIds(Set<int> ids, {Set<EntryDataType>? dataTypes}) => SynchronousFuture(null);
|
||||
|
||||
// entries
|
||||
|
||||
@override
|
||||
Future<Set<AvesEntry>> loadEntries({String? directory}) => SynchronousFuture({});
|
||||
Future<Set<AvesEntry>> loadEntries({int? origin, String? directory}) => SynchronousFuture({});
|
||||
|
||||
@override
|
||||
Future<void> saveEntries(Iterable<AvesEntry> entries) => SynchronousFuture(null);
|
||||
Future<void> saveEntries(Set<AvesEntry> entries) => SynchronousFuture(null);
|
||||
|
||||
@override
|
||||
Future<void> updateEntry(int id, AvesEntry entry) => SynchronousFuture(null);
|
||||
|
@ -76,13 +76,13 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
Future<Set<FavouriteRow>> loadAllFavourites() => SynchronousFuture({});
|
||||
|
||||
@override
|
||||
Future<void> addFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
||||
Future<void> addFavourites(Set<FavouriteRow> rows) => SynchronousFuture(null);
|
||||
|
||||
@override
|
||||
Future<void> updateFavouriteId(int id, FavouriteRow row) => SynchronousFuture(null);
|
||||
|
||||
@override
|
||||
Future<void> removeFavourites(Iterable<FavouriteRow> rows) => SynchronousFuture(null);
|
||||
Future<void> removeFavourites(Set<FavouriteRow> rows) => SynchronousFuture(null);
|
||||
|
||||
// covers
|
||||
|
||||
|
@ -90,7 +90,7 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
Future<Set<CoverRow>> loadAllCovers() => SynchronousFuture({});
|
||||
|
||||
@override
|
||||
Future<void> addCovers(Iterable<CoverRow> rows) => SynchronousFuture(null);
|
||||
Future<void> addCovers(Set<CoverRow> rows) => SynchronousFuture(null);
|
||||
|
||||
@override
|
||||
Future<void> updateCoverEntryId(int id, CoverRow row) => SynchronousFuture(null);
|
||||
|
@ -101,5 +101,5 @@ class FakeMetadataDb extends Fake implements MetadataDb {
|
|||
// video playback
|
||||
|
||||
@override
|
||||
Future<void> removeVideoPlayback(Iterable<int> ids) => SynchronousFuture(null);
|
||||
Future<void> removeVideoPlayback(Set<int> ids) => SynchronousFuture(null);
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue