This commit is contained in:
Thibault Deckers 2023-02-16 19:49:33 +01:00
parent 1a76edb288
commit bc6d75e928
100 changed files with 2978 additions and 651 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

@ -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,

View file

@ -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) {

View file

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

View file

@ -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) {

View file

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

View file

@ -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) {

View file

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

View file

@ -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 {

View file

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

View file

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

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

@ -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 {

View 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>

View 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>

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -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,

View file

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

View file

@ -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:

View file

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

View file

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

View file

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

View 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;
}
}

View 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;
}
}
}

View 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();
}
}

View file

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

View file

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

View 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;
}
}

View file

@ -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 {

View file

@ -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 {

View file

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

View file

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

View file

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

View file

@ -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',

View file

@ -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 {

View file

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

View file

@ -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(),

View file

@ -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 {

View 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());
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(

View file

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

View file

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

View file

@ -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 = [];

View file

@ -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 {

View file

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

View file

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

View file

@ -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 ?? '',

View file

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

View file

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

View file

@ -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:

View file

@ -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:

View file

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

View file

@ -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',

View file

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