diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb3f7929..35a9d80b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [v1.11.4] - 2024-07-09 + +### Added + +- Collection: stack RAW and JPEG with same file names +- Collection: ask to rename/replace/skip when converting items with name conflict +- Export: bulk converting motion photos to still images +- Explorer: view folder tree and filter paths + +### Fixed + +- switching to PiP when changing device orientation on Android >=13 +- handling wallpaper intent without URI +- sizing widgets with some launchers on Android >=12 + ## [v1.11.3] - 2024-06-17 ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index 7175da16d..bf093bc5a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -196,9 +196,9 @@ repositories { dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1' - implementation "androidx.appcompat:appcompat:1.6.1" + implementation "androidx.appcompat:appcompat:1.7.0" implementation 'androidx.core:core-ktx:1.13.1' - implementation 'androidx.lifecycle:lifecycle-process:2.8.0' + implementation 'androidx.lifecycle:lifecycle-process:2.8.2' implementation 'androidx.media:media:1.7.0' implementation 'androidx.multidex:multidex:2.0.1' implementation 'androidx.security:security-crypto:1.1.0-alpha06' diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 67ad18af7..6d1cb4a88 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -120,6 +120,7 @@ android:label="@string/app_name" android:requestLegacyExternalStorage="true" android:roundIcon="@mipmap/ic_launcher_round" + android:showWhenLocked="true" android:supportsRtl="true" tools:targetApi="tiramisu"> + @@ -163,6 +165,7 @@ + diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisWorker.kt b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisWorker.kt index a0c1a0ab9..af4bf81bf 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisWorker.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/AnalysisWorker.kt @@ -27,6 +27,10 @@ import deckers.thibault.aves.utils.LogUtils import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlin.coroutines.Continuation import kotlin.coroutines.resume @@ -34,13 +38,17 @@ import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine class AnalysisWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) { + private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var workCont: Continuation? = null private var flutterEngine: FlutterEngine? = null private var backgroundChannel: MethodChannel? = null override suspend fun doWork(): Result { - createNotificationChannel() - setForeground(createForegroundInfo()) + defaultScope.launch { + // prevent ANR triggered by slow operations in main thread + createNotificationChannel() + setForeground(createForegroundInfo()) + } suspendCoroutine { cont -> workCont = cont onStart() diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt index 81933f251..6e5e695d3 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt @@ -12,6 +12,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.util.Log +import android.util.SizeF import android.widget.RemoteViews import app.loup.streams_channel.StreamsChannel import deckers.thibault.aves.channel.AvesByteSendingMethodCodec @@ -40,12 +41,16 @@ class HomeWidgetProvider : AppWidgetProvider() { for (widgetId in appWidgetIds) { val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId) - defaultScope.launch { - val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false) - updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, backgroundProps) + goAsync().run { + defaultScope.launch { + val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false) + updateWidgetImage(context, appWidgetManager, widgetId, backgroundProps) + + val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false) + updateWidgetImage(context, appWidgetManager, widgetId, imageProps) - val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = false) - updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps) + finish() + } } } } @@ -61,20 +66,32 @@ class HomeWidgetProvider : AppWidgetProvider() { imageByteFetchJob = defaultScope.launch { delay(500) val imageProps = getProps(context, widgetId, widgetInfo, drawEntryImage = true, reuseEntry = true) - updateWidgetImage(context, appWidgetManager, widgetId, widgetInfo, imageProps) + updateWidgetImage(context, appWidgetManager, widgetId, imageProps) } } private fun getDevicePixelRatio(): Float = Resources.getSystem().displayMetrics.density - private fun getWidgetSizePx(context: Context, widgetInfo: Bundle): Pair { - val devicePixelRatio = getDevicePixelRatio() - val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT - val widthKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH else AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH - val heightKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT else AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT - val widthPx = (widgetInfo.getInt(widthKey) * devicePixelRatio).roundToInt() - val heightPx = (widgetInfo.getInt(heightKey) * devicePixelRatio).roundToInt() - return Pair(widthPx, heightPx) + private fun getWidgetSizesDip(context: Context, widgetInfo: Bundle): List { + var sizes: List? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java) + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + @Suppress("DEPRECATION") + widgetInfo.getParcelableArrayList(AppWidgetManager.OPTION_APPWIDGET_SIZES) + } else { + null + } + + if (sizes.isNullOrEmpty()) { + val isPortrait = context.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + val widthKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH else AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH + val heightKey = if (isPortrait) AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT else AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT + val widthDip = widgetInfo.getInt(widthKey) + val heightDip = widgetInfo.getInt(heightKey) + sizes = listOf(SizeF(widthDip.toFloat(), heightDip.toFloat())) + } + + return sizes.map { size -> hashMapOf("widthDip" to size.width, "heightDip" to size.height) } } private suspend fun getProps( @@ -84,8 +101,11 @@ class HomeWidgetProvider : AppWidgetProvider() { drawEntryImage: Boolean, reuseEntry: Boolean = false, ): FieldMap? { - val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) - if (widthPx == 0 || heightPx == 0) return null + val sizesDip = getWidgetSizesDip(context, widgetInfo) + if (sizesDip.isEmpty()) return null + + val sizeDip = sizesDip.first() + if (sizeDip["widthDip"] == 0 || sizeDip["heightDip"] == 0) return null val isNightModeOn = (context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES @@ -98,13 +118,16 @@ class HomeWidgetProvider : AppWidgetProvider() { FlutterUtils.runOnUiThread { channel.invokeMethod("drawWidget", hashMapOf( "widgetId" to widgetId, - "widthPx" to widthPx, - "heightPx" to heightPx, + "sizesDip" to sizesDip, "devicePixelRatio" to getDevicePixelRatio(), "drawEntryImage" to drawEntryImage, "reuseEntry" to reuseEntry, "isSystemThemeDark" to isNightModeOn, - ), object : MethodChannel.Result { + ).apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + put("cornerRadiusPx", context.resources.getDimension(android.R.dimen.system_app_widget_background_radius)) + } + }, object : MethodChannel.Result { override fun success(result: Any?) { cont.resume(result) } @@ -123,7 +146,7 @@ class HomeWidgetProvider : AppWidgetProvider() { @Suppress("unchecked_cast") return props as FieldMap? } catch (e: Exception) { - Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId widthPx=$widthPx heightPx=$heightPx", e) + Log.e(LOG_TAG, "failed to draw widget for widgetId=$widgetId sizesPx=$sizesDip", e) } return null } @@ -132,36 +155,83 @@ class HomeWidgetProvider : AppWidgetProvider() { context: Context, appWidgetManager: AppWidgetManager, widgetId: Int, - widgetInfo: Bundle, props: FieldMap?, ) { props ?: return - val bytes = props["bytes"] as ByteArray? + val bytesBySizeDip = (props["bytesBySizeDip"] as List<*>?)?.mapNotNull { + if (it is Map<*, *>) { + val widthDip = (it["widthDip"] as Number?)?.toFloat() + val heightDip = (it["heightDip"] as Number?)?.toFloat() + val bytes = it["bytes"] as ByteArray? + if (widthDip != null && heightDip != null && bytes != null) { + Pair(SizeF(widthDip, heightDip), bytes) + } else null + } else null + } val updateOnTap = props["updateOnTap"] as Boolean? - if (bytes == null || updateOnTap == null) { + if (bytesBySizeDip == null || updateOnTap == null) { Log.e(LOG_TAG, "missing arguments") return } - val (widthPx, heightPx) = getWidgetSizePx(context, widgetInfo) - if (widthPx == 0 || heightPx == 0) return + if (bytesBySizeDip.isEmpty()) { + Log.e(LOG_TAG, "empty image list") + return + } - try { - val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888) - bitmap.copyPixelsFromBuffer(ByteBuffer.wrap(bytes)) + val bitmaps = ArrayList() + + fun createRemoteViewsForSize( + context: Context, + widgetId: Int, + sizeDip: SizeF, + bytes: ByteArray, + updateOnTap: Boolean, + ): RemoteViews? { + val devicePixelRatio = getDevicePixelRatio() + val widthPx = (sizeDip.width * devicePixelRatio).roundToInt() + val heightPx = (sizeDip.height * devicePixelRatio).roundToInt() + + try { + val bitmap = Bitmap.createBitmap(widthPx, heightPx, Bitmap.Config.ARGB_8888).also { + bitmaps.add(it) + it.copyPixelsFromBuffer(ByteBuffer.wrap(bytes)) + } - val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId) + val pendingIntent = if (updateOnTap) buildUpdateIntent(context, widgetId) else buildOpenAppIntent(context, widgetId) - val views = RemoteViews(context.packageName, R.layout.app_widget).apply { - setImageViewBitmap(R.id.widget_img, bitmap) - setOnClickPendingIntent(R.id.widget_img, pendingIntent) + return RemoteViews(context.packageName, R.layout.app_widget).apply { + setImageViewBitmap(R.id.widget_img, bitmap) + setOnClickPendingIntent(R.id.widget_img, pendingIntent) + } + } catch (e: Exception) { + Log.e(LOG_TAG, "failed to draw widget", e) } + return null + } - appWidgetManager.updateAppWidget(widgetId, views) - bitmap.recycle() + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // multiple rendering for all possible sizes + val views = RemoteViews( + bytesBySizeDip.associateBy( + { (sizeDip, _) -> sizeDip }, + { (sizeDip, bytes) -> createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap) }, + ).filterValues { it != null }.mapValues { (_, view) -> view!! } + ) + appWidgetManager.updateAppWidget(widgetId, views) + } else { + // single rendering + val (sizeDip, bytes) = bytesBySizeDip.first() + val views = createRemoteViewsForSize(context, widgetId, sizeDip, bytes, updateOnTap) + appWidgetManager.updateAppWidget(widgetId, views) + } } catch (e: Exception) { Log.e(LOG_TAG, "failed to draw widget", e) + } finally { + bitmaps.forEach { it.recycle() } + bitmaps.clear() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt index 96dd14303..0166fdff4 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -10,6 +10,7 @@ import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper +import android.os.TransactionTooLargeException import android.provider.MediaStore import android.util.Log import androidx.annotation.RequiresApi @@ -21,6 +22,7 @@ import deckers.thibault.aves.channel.AvesByteSendingMethodCodec import deckers.thibault.aves.channel.calls.AccessibilityHandler import deckers.thibault.aves.channel.calls.AnalysisHandler import deckers.thibault.aves.channel.calls.AppAdapterHandler +import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.DebugHandler import deckers.thibault.aves.channel.calls.DeviceHandler import deckers.thibault.aves.channel.calls.EmbeddedDataHandler @@ -36,6 +38,7 @@ import deckers.thibault.aves.channel.calls.MetadataEditHandler import deckers.thibault.aves.channel.calls.MetadataFetchHandler import deckers.thibault.aves.channel.calls.SecurityHandler import deckers.thibault.aves.channel.calls.StorageHandler +import deckers.thibault.aves.channel.calls.WallpaperHandler import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler import deckers.thibault.aves.channel.calls.window.WindowHandler import deckers.thibault.aves.channel.streams.ActivityResultStreamHandler @@ -135,6 +138,7 @@ open class MainActivity : FlutterFragmentActivity() { MethodChannel(messenger, AccessibilityHandler.CHANNEL).setMethodCallHandler(AccessibilityHandler(this)) MethodChannel(messenger, MediaEditHandler.CHANNEL).setMethodCallHandler(MediaEditHandler(this)) MethodChannel(messenger, MetadataEditHandler.CHANNEL).setMethodCallHandler(MetadataEditHandler(this)) + MethodChannel(messenger, WallpaperHandler.CHANNEL).setMethodCallHandler(WallpaperHandler(this)) // - need Activity MethodChannel(messenger, WindowHandler.CHANNEL).setMethodCallHandler(ActivityWindowHandler(this)) @@ -168,7 +172,7 @@ open class MainActivity : FlutterFragmentActivity() { intentDataMap.clear() } - "submitPickedItems" -> submitPickedItems(call) + "submitPickedItems" -> safe(call, result, ::submitPickedItems) "submitPickedCollectionFilters" -> submitPickedCollectionFilters(call) } } @@ -301,16 +305,32 @@ open class MainActivity : FlutterFragmentActivity() { Intent.ACTION_VIEW, Intent.ACTION_SEND, MediaStore.ACTION_REVIEW, + MediaStore.ACTION_REVIEW_SECURE, "com.android.camera.action.REVIEW", "com.android.camera.action.SPLIT_SCREEN_REVIEW" -> { (intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri -> // MIME type is optional val type = intent.type ?: intent.resolveType(this) - return hashMapOf( + val fields = hashMapOf( INTENT_DATA_KEY_ACTION to INTENT_ACTION_VIEW, INTENT_DATA_KEY_MIME_TYPE to type, INTENT_DATA_KEY_URI to uri.toString(), ) + + if (action == MediaStore.ACTION_REVIEW_SECURE) { + val uris = ArrayList() + intent.clipData?.let { clipData -> + for (i in 0 until clipData.itemCount) { + clipData.getItemAt(i).uri?.let { uris.add(it.toString()) } + } + } + fields[INTENT_DATA_KEY_SECURE_URIS] = uris + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && intent.hasExtra(MediaStore.EXTRA_BRIGHTNESS)) { + fields[INTENT_DATA_KEY_BRIGHTNESS] = intent.getFloatExtra(MediaStore.EXTRA_BRIGHTNESS, 0f) + } + + return fields } } @@ -390,28 +410,36 @@ open class MainActivity : FlutterFragmentActivity() { return null } - private fun submitPickedItems(call: MethodCall) { + open fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) { val pickedUris = call.argument>("uris") - if (!pickedUris.isNullOrEmpty()) { - val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) } - val intent = Intent().apply { - val firstUri = toUri(pickedUris.first()) - if (pickedUris.size == 1) { - data = firstUri - } else { - clipData = ClipData.newUri(contentResolver, null, firstUri).apply { - pickedUris.drop(1).forEach { - addItem(ClipData.Item(toUri(it))) + try { + if (!pickedUris.isNullOrEmpty()) { + val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) } + val intent = Intent().apply { + val firstUri = toUri(pickedUris.first()) + if (pickedUris.size == 1) { + data = firstUri + } else { + clipData = ClipData.newUri(contentResolver, null, firstUri).apply { + pickedUris.drop(1).forEach { + addItem(ClipData.Item(toUri(it))) + } } } + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + setResult(RESULT_OK, intent) + } else { + setResult(RESULT_CANCELED) + } + finish() + } catch (e: Exception) { + if (e is TransactionTooLargeException || e.cause is TransactionTooLargeException) { + result.error("submitPickedItems-large", "transaction too large with ${pickedUris?.size} URIs", e) + } else { + result.error("submitPickedItems-exception", "failed to pick ${pickedUris?.size} URIs", e) } - setResult(RESULT_OK, intent) - } else { - setResult(RESULT_CANCELED) } - finish() } private fun submitPickedCollectionFilters(call: MethodCall) { @@ -498,11 +526,13 @@ open class MainActivity : FlutterFragmentActivity() { const val INTENT_DATA_KEY_ACTION = "action" const val INTENT_DATA_KEY_ALLOW_MULTIPLE = "allowMultiple" + const val INTENT_DATA_KEY_BRIGHTNESS = "brightness" const val INTENT_DATA_KEY_FILTERS = "filters" const val INTENT_DATA_KEY_MIME_TYPE = "mimeType" const val INTENT_DATA_KEY_PAGE = "page" const val INTENT_DATA_KEY_QUERY = "query" const val INTENT_DATA_KEY_SAFE_MODE = "safeMode" + const val INTENT_DATA_KEY_SECURE_URIS = "secureUris" const val INTENT_DATA_KEY_URI = "uri" const val INTENT_DATA_KEY_WIDGET_ID = "widgetId" diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt index 4aa32be1a..067ee8e34 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/WallpaperActivity.kt @@ -2,132 +2,54 @@ package deckers.thibault.aves import android.content.Intent import android.net.Uri -import android.os.Bundle -import android.os.Handler -import android.os.Looper -import android.util.Log -import app.loup.streams_channel.StreamsChannel -import deckers.thibault.aves.channel.AvesByteSendingMethodCodec -import deckers.thibault.aves.channel.calls.AccessibilityHandler -import deckers.thibault.aves.channel.calls.DeviceHandler -import deckers.thibault.aves.channel.calls.EmbeddedDataHandler -import deckers.thibault.aves.channel.calls.MediaFetchBytesHandler -import deckers.thibault.aves.channel.calls.MediaFetchObjectHandler -import deckers.thibault.aves.channel.calls.MediaSessionHandler -import deckers.thibault.aves.channel.calls.MetadataFetchHandler -import deckers.thibault.aves.channel.calls.StorageHandler -import deckers.thibault.aves.channel.calls.WallpaperHandler -import deckers.thibault.aves.channel.calls.window.ActivityWindowHandler -import deckers.thibault.aves.channel.calls.window.WindowHandler -import deckers.thibault.aves.channel.streams.ImageByteStreamHandler -import deckers.thibault.aves.channel.streams.MediaCommandStreamHandler +import deckers.thibault.aves.channel.calls.AppAdapterHandler import deckers.thibault.aves.model.FieldMap -import deckers.thibault.aves.utils.LogUtils import deckers.thibault.aves.utils.getParcelableExtraCompat -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 -class WallpaperActivity : FlutterFragmentActivity() { - private lateinit var intentDataMap: FieldMap - private lateinit var mediaSessionHandler: MediaSessionHandler - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - Log.i(LOG_TAG, "onCreate intent=$intent") - intent.extras?.takeUnless { it.isEmpty }?.let { - Log.i(LOG_TAG, "onCreate intent extras=$it") - } - intentDataMap = extractIntentData(intent) - } - - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - val messenger = flutterEngine.dartExecutor - - // notification: platform -> dart - val mediaCommandStreamHandler = MediaCommandStreamHandler().apply { - EventChannel(messenger, MediaCommandStreamHandler.CHANNEL).setStreamHandler(this) - } - - // dart -> platform -> dart - // - need Context - mediaSessionHandler = MediaSessionHandler(this, mediaCommandStreamHandler) - 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, MediaSessionHandler.CHANNEL).setMethodCallHandler(mediaSessionHandler) - 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() { - Log.i(LOG_TAG, "onStart") - super.onStart() - - // as of Flutter v3.0.1, the window `viewInsets` and `viewPadding` - // are incorrect on startup in some environments (e.g. API 29 emulator), - // so we manually request to apply the insets to update the window metrics - Handler(Looper.getMainLooper()).postDelayed({ - window.decorView.requestApplyInsets() - }, 100) - } - - override fun onDestroy() { - mediaSessionHandler.dispose() - super.onDestroy() - } - - private fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { - when (call.method) { - "getIntentData" -> { - result.success(intentDataMap) - intentDataMap.clear() +class WallpaperActivity : MainActivity() { + private var originalIntent: String? = null + + override fun extractIntentData(intent: Intent?): FieldMap { + if (intent != null) { + when (intent.action) { + Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> { + (intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri -> + // MIME type is optional + val type = intent.type ?: intent.resolveType(this) + return hashMapOf( + INTENT_DATA_KEY_ACTION to INTENT_ACTION_SET_WALLPAPER, + INTENT_DATA_KEY_MIME_TYPE to type, + INTENT_DATA_KEY_URI to uri.toString(), + ) + } + + // if the media URI is not provided we need to pick one first + originalIntent = intent.action + intent.action = Intent.ACTION_PICK + } } } + + return super.extractIntentData(intent) } - private fun extractIntentData(intent: Intent?): FieldMap { - when (intent?.action) { - Intent.ACTION_ATTACH_DATA, Intent.ACTION_SET_WALLPAPER -> { - (intent.data ?: intent.getParcelableExtraCompat(Intent.EXTRA_STREAM))?.let { uri -> - // MIME type is optional - 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, - MainActivity.INTENT_DATA_KEY_URI to uri.toString(), - ) - } - } - Intent.ACTION_RUN -> { - // flutter run - } - else -> { - Log.w(LOG_TAG, "unhandled intent action=${intent?.action}") + override fun submitPickedItems(call: MethodCall, result: MethodChannel.Result) { + if (originalIntent != null) { + val pickedUris = call.argument>("uris") + if (!pickedUris.isNullOrEmpty()) { + val toUri = { uriString: String -> AppAdapterHandler.getShareableUri(this, Uri.parse(uriString)) } + onNewIntent(Intent().apply { + action = originalIntent + data = toUri(pickedUris.first()) + }) + } else { + setResult(RESULT_CANCELED) + finish() } + } else { + super.submitPickedItems(call, result) } - return HashMap() - } - - companion object { - private val LOG_TAG = LogUtils.createTag() } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt index 4d0001ce0..baee7c5e8 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/StorageHandler.kt @@ -39,6 +39,7 @@ class StorageHandler(private val context: Context) : MethodCallHandler { "revokeDirectoryAccess" -> safe(call, result, ::revokeDirectoryAccess) "deleteEmptyDirectories" -> ioScope.launch { safe(call, result, ::deleteEmptyDirectories) } "deleteTempDirectory" -> ioScope.launch { safe(call, result, ::deleteTempDirectory) } + "deleteExternalCache" -> ioScope.launch { safe(call, result, ::deleteExternalCache) } "canRequestMediaFileBulkAccess" -> safe(call, result, ::canRequestMediaFileBulkAccess) "canInsertMedia" -> safe(call, result, ::canInsertMedia) else -> result.notImplemented() @@ -49,16 +50,17 @@ class StorageHandler(private val context: Context) : MethodCallHandler { var internalCache = getFolderSize(context.cacheDir) internalCache += getFolderSize(context.codeCacheDir) val externalCache = context.externalCacheDirs.map(::getFolderSize).sum() + val externalFilesDirs = context.getExternalFilesDirs(null) val dataDir = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.dataDir else File(context.applicationInfo.dataDir) val database = getFolderSize(File(dataDir, "databases")) val flutter = getFolderSize(File(PathUtils.getDataDirectory(context))) val vaults = getFolderSize(File(StorageUtils.getVaultRoot(context))) - val trash = context.getExternalFilesDirs(null).mapNotNull { StorageUtils.trashDirFor(context, it.path) }.map(::getFolderSize).sum() + val trash = externalFilesDirs.mapNotNull { StorageUtils.trashDirFor(context, it.path) }.map(::getFolderSize).sum() val internalData = getFolderSize(dataDir) - internalCache - val externalData = context.getExternalFilesDirs(null).map(::getFolderSize).sum() + val externalData = externalFilesDirs.map(::getFolderSize).sum() val miscData = internalData + externalData - (database + flutter + vaults + trash) result.success( @@ -224,6 +226,11 @@ class StorageHandler(private val context: Context) : MethodCallHandler { result.success(StorageUtils.deleteTempDirectory(context)) } + private fun deleteExternalCache(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { + context.externalCacheDirs.filter { it.exists() }.forEach { it.deleteRecursively() } + result.success(true) + } + private fun canRequestMediaFileBulkAccess(@Suppress("unused_parameter") call: MethodCall, result: MethodChannel.Result) { result.success(Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt index a6c3bb5a7..21f59a7db 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/NameConflictStrategy.kt @@ -1,5 +1,7 @@ package deckers.thibault.aves.model +import java.io.File + enum class NameConflictStrategy { RENAME, REPLACE, SKIP; @@ -9,4 +11,6 @@ enum class NameConflictStrategy { return valueOf(name.uppercase()) } } -} \ No newline at end of file +} + +class NameConflictResolution(var nameWithoutExtension: String?, var replacementFile: File?) \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt index be8dc5270..d992f1cef 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/ImageProvider.kt @@ -41,6 +41,7 @@ import deckers.thibault.aves.metadata.xmp.GoogleXMP import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.ExifOrientationOp import deckers.thibault.aves.model.FieldMap +import deckers.thibault.aves.model.NameConflictResolution import deckers.thibault.aves.model.NameConflictStrategy import deckers.thibault.aves.model.SourceEntry import deckers.thibault.aves.utils.BitmapUtils @@ -147,13 +148,14 @@ abstract class ImageProvider { val oldFile = File(sourcePath) if (oldFile.nameWithoutExtension != desiredNameWithoutExtension) { oldFile.parent?.let { dir -> - resolveTargetFileNameWithoutExtension( + val resolution = resolveTargetFileNameWithoutExtension( contextWrapper = activity, dir = dir, desiredNameWithoutExtension = desiredNameWithoutExtension, mimeType = mimeType, conflictStrategy = NameConflictStrategy.RENAME, - )?.let { targetNameWithoutExtension -> + ) + resolution.nameWithoutExtension?.let { targetNameWithoutExtension -> val targetFileName = "$targetNameWithoutExtension${extensionFor(mimeType)}" val newFile = File(dir, targetFileName) if (oldFile != newFile) { @@ -266,7 +268,7 @@ abstract class ImageProvider { exportMimeType: String, ): FieldMap { val sourceMimeType = sourceEntry.mimeType - val sourceUri = sourceEntry.uri + var sourceUri = sourceEntry.uri val pageId = sourceEntry.pageId var desiredNameWithoutExtension = if (sourceEntry.path != null) { @@ -279,13 +281,17 @@ abstract class ImageProvider { val page = if (sourceMimeType == MimeTypes.TIFF) pageId + 1 else pageId desiredNameWithoutExtension += "_${page.toString().padStart(3, '0')}" } - val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( + val resolution = resolveTargetFileNameWithoutExtension( contextWrapper = activity, dir = targetDir, desiredNameWithoutExtension = desiredNameWithoutExtension, mimeType = exportMimeType, conflictStrategy = nameConflictStrategy, - ) ?: return skippedFieldMap + ) + val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap + resolution.replacementFile?.let { file -> + sourceUri = Uri.fromFile(file) + } val targetMimeType: String val write: (OutputStream) -> Unit @@ -391,6 +397,8 @@ abstract class ImageProvider { } finally { // clearing Glide target should happen after effectively writing the bitmap Glide.with(activity).clear(target) + + resolution.replacementFile?.delete() } } @@ -470,7 +478,7 @@ abstract class ImageProvider { } val captureMimeType = MimeTypes.JPEG - val targetNameWithoutExtension = try { + val resolution = try { resolveTargetFileNameWithoutExtension( contextWrapper = contextWrapper, dir = targetDir, @@ -483,6 +491,7 @@ abstract class ImageProvider { return } + val targetNameWithoutExtension = resolution.nameWithoutExtension if (targetNameWithoutExtension == null) { // skip it callback.onSuccess(skippedFieldMap) @@ -568,10 +577,13 @@ abstract class ImageProvider { desiredNameWithoutExtension: String, mimeType: String, conflictStrategy: NameConflictStrategy, - ): String? { + ): NameConflictResolution { + var resolvedName: String? = desiredNameWithoutExtension + var replacementFile: File? = null + val extension = extensionFor(mimeType) val targetFile = File(dir, "$desiredNameWithoutExtension$extension") - return when (conflictStrategy) { + when (conflictStrategy) { NameConflictStrategy.RENAME -> { var nameWithoutExtension = desiredNameWithoutExtension var i = 0 @@ -579,24 +591,28 @@ abstract class ImageProvider { i++ nameWithoutExtension = "$desiredNameWithoutExtension ($i)" } - nameWithoutExtension + resolvedName = nameWithoutExtension } NameConflictStrategy.REPLACE -> { if (targetFile.exists()) { + // move replaced file to temp storage + // so that it can be used as a source for conversion or metadata copy + replacementFile = StorageUtils.createTempFile(contextWrapper).apply { + targetFile.transferTo(outputStream()) + } deletePath(contextWrapper, targetFile.path, mimeType) } - desiredNameWithoutExtension } NameConflictStrategy.SKIP -> { if (targetFile.exists()) { - null - } else { - desiredNameWithoutExtension + resolvedName = null } } } + + return NameConflictResolution(resolvedName, replacementFile) } // cf `MetadataFetchHandler.getCatalogMetadataByMetadataExtractor()` for a more thorough check diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt index faa55eef7..8fc3e5cef 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/model/provider/MediaStoreImageProvider.kt @@ -562,13 +562,14 @@ class MediaStoreImageProvider : ImageProvider() { } val desiredNameWithoutExtension = desiredName.substringBeforeLast(".") - val targetNameWithoutExtension = resolveTargetFileNameWithoutExtension( + val resolution = resolveTargetFileNameWithoutExtension( contextWrapper = activity, dir = targetDir, desiredNameWithoutExtension = desiredNameWithoutExtension, mimeType = mimeType, conflictStrategy = nameConflictStrategy, - ) ?: return skippedFieldMap + ) + val targetNameWithoutExtension = resolution.nameWithoutExtension ?: return skippedFieldMap val sourceDocFile = DocumentFileCompat.fromSingleUri(activity, sourceUri) val targetPath = createSingle( diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml index b3da51120..4155b3508 100644 --- a/android/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -6,32 +6,32 @@ diff --git a/android/app/src/main/res/drawable/ic_launcher_mono.xml b/android/app/src/main/res/drawable/ic_launcher_mono.xml index 605e3e3aa..9f78191d0 100644 --- a/android/app/src/main/res/drawable/ic_launcher_mono.xml +++ b/android/app/src/main/res/drawable/ic_launcher_mono.xml @@ -6,32 +6,32 @@ diff --git a/fastlane/metadata/android/en-US/changelogs/115.txt b/fastlane/metadata/android/en-US/changelogs/115.txt deleted file mode 100644 index 4c8d7cd42..000000000 --- a/fastlane/metadata/android/en-US/changelogs/115.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.6: -- detect HDR videos (but do not play them in their full HDR glory) -- removing animations also applies to pop up menus -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11501.txt b/fastlane/metadata/android/en-US/changelogs/11501.txt deleted file mode 100644 index 4c8d7cd42..000000000 --- a/fastlane/metadata/android/en-US/changelogs/11501.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.6: -- detect HDR videos (but do not play them in their full HDR glory) -- removing animations also applies to pop up menus -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/116.txt b/fastlane/metadata/android/en-US/changelogs/116.txt deleted file mode 100644 index 067ad57ef..000000000 --- a/fastlane/metadata/android/en-US/changelogs/116.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.7: -- detect HDR videos (but do not play them in their full HDR glory) -- removing animations also applies to pop up menus -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11601.txt b/fastlane/metadata/android/en-US/changelogs/11601.txt deleted file mode 100644 index 067ad57ef..000000000 --- a/fastlane/metadata/android/en-US/changelogs/11601.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.7: -- detect HDR videos (but do not play them in their full HDR glory) -- removing animations also applies to pop up menus -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/117.txt b/fastlane/metadata/android/en-US/changelogs/117.txt deleted file mode 100644 index 720ed4ce1..000000000 --- a/fastlane/metadata/android/en-US/changelogs/117.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.8: -- rename in bulk using tags -- repeat a section section section of a video -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11701.txt b/fastlane/metadata/android/en-US/changelogs/11701.txt deleted file mode 100644 index 720ed4ce1..000000000 --- a/fastlane/metadata/android/en-US/changelogs/11701.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.8: -- rename in bulk using tags -- repeat a section section section of a video -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/118.txt b/fastlane/metadata/android/en-US/changelogs/118.txt deleted file mode 100644 index 7e6274add..000000000 --- a/fastlane/metadata/android/en-US/changelogs/118.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.9: -- rename in bulk using tags -- repeat a section section section of a video -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11801.txt b/fastlane/metadata/android/en-US/changelogs/11801.txt deleted file mode 100644 index 7e6274add..000000000 --- a/fastlane/metadata/android/en-US/changelogs/11801.txt +++ /dev/null @@ -1,4 +0,0 @@ -In v1.10.9: -- rename in bulk using tags -- repeat a section section section of a video -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/119.txt b/fastlane/metadata/android/en-US/changelogs/119.txt deleted file mode 100644 index d0a2a5a14..000000000 --- a/fastlane/metadata/android/en-US/changelogs/119.txt +++ /dev/null @@ -1,3 +0,0 @@ -In v1.11.0: -- watch videos with SRT subtitle files -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11901.txt b/fastlane/metadata/android/en-US/changelogs/11901.txt deleted file mode 100644 index d0a2a5a14..000000000 --- a/fastlane/metadata/android/en-US/changelogs/11901.txt +++ /dev/null @@ -1,3 +0,0 @@ -In v1.11.0: -- watch videos with SRT subtitle files -Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/123.txt b/fastlane/metadata/android/en-US/changelogs/123.txt new file mode 100644 index 000000000..b1151387e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/123.txt @@ -0,0 +1,4 @@ +In v1.11.4: +- explore your collection with the... explorer +- convert your motion photos to stills in bulk +Full changelog available on GitHub \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/12301.txt b/fastlane/metadata/android/en-US/changelogs/12301.txt new file mode 100644 index 000000000..b1151387e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/12301.txt @@ -0,0 +1,4 @@ +In v1.11.4: +- explore your collection with the... explorer +- convert your motion photos to stills in bulk +Full changelog available on GitHub \ No newline at end of file diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 025a59a6a..d529fc668 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -1195,7 +1195,7 @@ "@collectionActionAddShortcut": {}, "settingsViewerShowMinimap": "إظهار الخريطة المصغرة", "@settingsViewerShowMinimap": {}, - "settingsCollectionBurstPatternsTile": "أنماط الانفجار", + "settingsCollectionBurstPatternsTile": "أنماط الصور المتتابعة", "@settingsCollectionBurstPatternsTile": {}, "viewerInfoLabelPath": "المسار", "@viewerInfoLabelPath": {}, @@ -1538,5 +1538,9 @@ "renameProcessorHash": "تجزئة", "@renameProcessorHash": {}, "chipActionShowCollection": "عرض في المجموعة", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "chipActionGoToExplorerPage": "عرض في المستكشف", + "@chipActionGoToExplorerPage": {}, + "explorerPageTitle": "المستكشف", + "@explorerPageTitle": {} } diff --git a/lib/l10n/app_be.arb b/lib/l10n/app_be.arb index 181c2f428..47c4a4934 100644 --- a/lib/l10n/app_be.arb +++ b/lib/l10n/app_be.arb @@ -5,7 +5,7 @@ "@welcomeTermsToggle": {}, "welcomeOptional": "Неабавязковыя", "@welcomeOptional": {}, - "welcomeMessage": "Сардэчна запрашаем ў Aves", + "welcomeMessage": "Сардэчна запрашаем у Aves", "@welcomeMessage": {}, "itemCount": "{count, plural, =1{{count} элемент} other{{count} элементаў}}", "@itemCount": { @@ -38,7 +38,7 @@ "@saveTooltip": {}, "doNotAskAgain": "Больш не пытайся", "@doNotAskAgain": {}, - "chipActionGoToCountryPage": "Паказаць ў Краінах", + "chipActionGoToCountryPage": "Паказаць у Краінах", "@chipActionGoToCountryPage": {}, "chipActionFilterOut": "Адфільтраваць", "@chipActionFilterOut": {}, @@ -56,27 +56,27 @@ "@sourceStateCataloguing": {}, "chipActionDelete": "Выдаліць", "@chipActionDelete": {}, - "chipActionGoToAlbumPage": "Паказаць ў Альбомах", + "chipActionGoToAlbumPage": "Паказаць у Альбомах", "@chipActionGoToAlbumPage": {}, "chipActionHide": "Схаваць", "@chipActionHide": {}, "chipActionCreateVault": "Стварыце сховішча", "@chipActionCreateVault": {}, - "chipActionGoToPlacePage": "Паказаць ў Лакацыях", + "chipActionGoToPlacePage": "Паказаць у Лакацыях", "@chipActionGoToPlacePage": {}, "chipActionUnpin": "Адмацаваць зверху", "@chipActionUnpin": {}, - "chipActionGoToTagPage": "Паказаць ў Тэгах", + "chipActionGoToTagPage": "Паказаць у Тэгах", "@chipActionGoToTagPage": {}, "chipActionLock": "Заблакаваць", "@chipActionLock": {}, - "chipActionSetCover": "Ўсталяваць вокладку", + "chipActionSetCover": "Усталяваць вокладку", "@chipActionSetCover": {}, "chipActionRename": "Перайменаваць", "@chipActionRename": {}, "chipActionConfigureVault": "Наладзіць сховішча", "@chipActionConfigureVault": {}, - "entryActionCopyToClipboard": "Скапіяваць ў буфер абмену", + "entryActionCopyToClipboard": "Скапіяваць у буфер абмену", "@entryActionCopyToClipboard": {}, "entryActionDelete": "Выдаліць", "@entryActionDelete": {}, @@ -120,15 +120,15 @@ "@entryActionRotateScreen": {}, "entryActionViewSource": "Паглядзець крыніцу", "@entryActionViewSource": {}, - "entryActionConvertMotionPhotoToStillImage": "Пераўтварыць ў нерухомую выяву", + "entryActionConvertMotionPhotoToStillImage": "Канвертаваць у статычны малюнак", "@entryActionConvertMotionPhotoToStillImage": {}, "entryActionViewMotionPhotoVideo": "Адкрыць відэа", "@entryActionViewMotionPhotoVideo": {}, - "entryActionSetAs": "Ўсталяваць як", + "entryActionSetAs": "Усталяваць як", "@entryActionSetAs": {}, - "entryActionAddFavourite": "Дадаць ў абранае", + "entryActionAddFavourite": "Дадаць у абранае", "@entryActionAddFavourite": {}, - "videoActionUnmute": "Ўключыць гук", + "videoActionUnmute": "Уключыць гук", "@videoActionUnmute": {}, "videoActionCaptureFrame": "Захоп кадра", "@videoActionCaptureFrame": {}, @@ -188,11 +188,11 @@ "@entryActionEdit": {}, "entryActionOpen": "Адкрыць з дапамогай", "@entryActionOpen": {}, - "entryActionOpenMap": "Паказаць ў праграме карты", + "entryActionOpenMap": "Паказаць у праграме карты", "@entryActionOpenMap": {}, "videoActionMute": "Адключыць гук", "@videoActionMute": {}, - "slideshowActionShowInCollection": "Паказаць ў Калекцыі", + "slideshowActionShowInCollection": "Паказаць у Калекцыі", "@slideshowActionShowInCollection": {}, "entryInfoActionEditDate": "Рэдагаваць дату і час", "@entryInfoActionEditDate": {}, @@ -228,7 +228,7 @@ "@filterTypeSphericalVideoLabel": {}, "filterNoTitleLabel": "Без назвы", "@filterNoTitleLabel": {}, - "filterOnThisDayLabel": "Ў гэты дзень", + "filterOnThisDayLabel": "У гэты дзень", "@filterOnThisDayLabel": {}, "filterRatingRejectedLabel": "Адхілена", "@filterRatingRejectedLabel": {}, @@ -363,7 +363,7 @@ "@vaultLockTypePassword": {}, "settingsVideoEnablePip": "Карцінка ў карцінцы", "@settingsVideoEnablePip": {}, - "videoControlsPlayOutside": "Адкрыць ў іншым прайгравальніку", + "videoControlsPlayOutside": "Адкрыць у іншым прайгравальніку", "@videoControlsPlayOutside": {}, "videoControlsPlay": "Прайграванне", "@videoControlsPlay": {}, @@ -449,7 +449,7 @@ "@wallpaperTargetHomeLock": {}, "widgetTapUpdateWidget": "Абнавіць віджэт", "@widgetTapUpdateWidget": {}, - "storageVolumeDescriptionFallbackPrimary": "Ўнутраная памяць", + "storageVolumeDescriptionFallbackPrimary": "Унутраная памяць", "@storageVolumeDescriptionFallbackPrimary": {}, "restrictedAccessDialogMessage": "Гэтай праграме забаронена змяняць файлы ў {directory} «{volume}».\n\nКаб перамясціць элементы ў іншую дырэкторыю, выкарыстоўвайце папярэдне ўсталяваны дыспетчар файлаў або праграму галерэі.", "@restrictedAccessDialogMessage": { @@ -465,7 +465,7 @@ } } }, - "missingSystemFilePickerDialogMessage": "Сродак выбару сістэмных файлаў адсутнічае або адключаны. Ўключыце яго і паўтарыце спробу.", + "missingSystemFilePickerDialogMessage": "Сістэмная праграма выбару файлаў адсутнічае ці адключана. Калі ласка, уключыце яе і паспрабуйце яшчэ раз.", "@missingSystemFilePickerDialogMessage": {}, "unsupportedTypeDialogMessage": "{count, plural, =1{Гэта аперацыя не падтрымліваецца для элементаў наступнага тыпу: {types}.} other{Гэта аперацыя не падтрымліваецца для элементаў наступных тыпаў: {types}.}}", "@unsupportedTypeDialogMessage": { @@ -488,7 +488,7 @@ "@moveUndatedConfirmationDialogMessage": {}, "moveUndatedConfirmationDialogSetDate": "Захаваць даты", "@moveUndatedConfirmationDialogSetDate": {}, - "videoResumeDialogMessage": "Вы хочаце аднавіць гульню ў {time}?", + "videoResumeDialogMessage": "Вы хочаце аднавіць прайграванне на {time}?", "@videoResumeDialogMessage": { "placeholders": { "time": { @@ -517,15 +517,15 @@ "@configureVaultDialogTitle": {}, "vaultDialogLockTypeLabel": "Тып блакіроўкі", "@vaultDialogLockTypeLabel": {}, - "pinDialogEnter": "Ўвядзіце PIN-код", + "pinDialogEnter": "Увядзіце PIN-код", "@pinDialogEnter": {}, - "patternDialogEnter": "Ўвядзіце графічны ключ", + "patternDialogEnter": "Увядзіце ключ", "@patternDialogEnter": {}, "patternDialogConfirm": "Пацвердзіце графічны ключ", "@patternDialogConfirm": {}, "pinDialogConfirm": "Пацвердзіце PIN-код", "@pinDialogConfirm": {}, - "passwordDialogEnter": "Ўвядзіце пароль", + "passwordDialogEnter": "Увядзіце пароль", "@passwordDialogEnter": {}, "passwordDialogConfirm": "Пацвердзіце пароль", "@passwordDialogConfirm": {}, @@ -551,7 +551,7 @@ "@mapPointNorthUpTooltip": {}, "viewerInfoLabelCoordinates": "Каардынаты", "@viewerInfoLabelCoordinates": {}, - "viewerInfoLabelOwner": "Ўладальнік", + "viewerInfoLabelOwner": "Уладальнік", "@viewerInfoLabelOwner": {}, "viewerInfoLabelDuration": "Працягласць", "@viewerInfoLabelDuration": {}, @@ -577,7 +577,7 @@ "@sourceViewerPageTitle": {}, "panoramaDisableSensorControl": "Адключыць сэнсарнае кіраванне", "@panoramaDisableSensorControl": {}, - "panoramaEnableSensorControl": "Ўключыць сэнсарнае кіраванне", + "panoramaEnableSensorControl": "Уключыць сэнсарнае кіраванне", "@panoramaEnableSensorControl": {}, "tagPlaceholderPlace": "Месца", "@tagPlaceholderPlace": {}, @@ -601,7 +601,7 @@ "@videoControlsNone": {}, "viewerErrorUnknown": "Ой!", "@viewerErrorUnknown": {}, - "viewerSetWallpaperButtonLabel": "ЎСТАНАВІЦЬ ШПАЛЕРЫ", + "viewerSetWallpaperButtonLabel": "УСТАНАВІЦЬ ШПАЛЕРЫ", "@viewerSetWallpaperButtonLabel": {}, "statsTopAlbumsSectionTitle": "Лепшыя альбомы", "@statsTopAlbumsSectionTitle": {}, @@ -625,7 +625,7 @@ "@mapZoomOutTooltip": {}, "openMapPageTooltip": "Паглядзець на старонцы карты", "@openMapPageTooltip": {}, - "mapEmptyRegion": "Ў гэтым рэгіёне няма малюнкаў", + "mapEmptyRegion": "Няма малюнкаў у гэтым рэгіёне", "@mapEmptyRegion": {}, "viewerInfoSearchEmpty": "Няма адпаведных ключоў", "@viewerInfoSearchEmpty": {}, @@ -685,19 +685,19 @@ "@aboutBugCopyInfoInstruction": {}, "vaultBinUsageDialogMessage": "Некаторыя сховішчы выкарыстоўваюць сметніцу.", "@vaultBinUsageDialogMessage": {}, - "aboutBugSaveLogInstruction": "Захаваць журналы праграмы ў файл", + "aboutBugSaveLogInstruction": "Захавайце логі праграмы ў файл", "@aboutBugSaveLogInstruction": {}, "aboutBugReportInstruction": "Адправіць справаздачу аб памылцы на GitHub разам з журналамі і сістэмнай інфармацыяй", "@aboutBugReportInstruction": {}, "entryActionCast": "Трансляцыя", "@entryActionCast": {}, - "hideFilterConfirmationDialogMessage": "Адпаведныя фота і відэа будуць схаваны з вашай калекцыі. Вы можаце убачыць іх зноў ў наладах «Прыватнасць».\n\nВы ўпэўнены, што хочаце іх схаваць?", + "hideFilterConfirmationDialogMessage": "Адпаведныя фота і відэа будуць схаваны з вашай калекцыі. Вы можаце паказаць іх зноў у наладах «Прыватнасць».\n\nВы ўпэўнены, што хочаце іх схаваць?", "@hideFilterConfirmationDialogMessage": {}, "renameEntrySetPagePatternFieldLabel": "Шаблон наймення", "@renameEntrySetPagePatternFieldLabel": {}, "renameAlbumDialogLabel": "Новая назва", "@renameAlbumDialogLabel": {}, - "renameAlbumDialogLabelAlreadyExistsHelper": "Каталог ўжо ёсць", + "renameAlbumDialogLabelAlreadyExistsHelper": "Каталог ужо існуе", "@renameAlbumDialogLabelAlreadyExistsHelper": {}, "aboutBugReportButton": "Адправіць справаздачу", "@aboutBugReportButton": {}, @@ -707,7 +707,7 @@ "@aboutBugSectionTitle": {}, "aboutBugCopyInfoButton": "Скапіяваць", "@aboutBugCopyInfoButton": {}, - "binEntriesConfirmationDialogMessage": "{count, plural, =1{Перамясціць гэты элемент ў сметніцу?} few{Перамясціць гэтыя {count} элемента ў сметніцу?} other{Перамясціць гэтыя {count} элементаў ў сметніцу?}}", + "binEntriesConfirmationDialogMessage": "{count, plural, =1{Перамясціць гэты элемент у сметніцу?} few{Перамясціць гэтыя {count} элемента ў сметніцу?} other{Перамясціць гэтыя {count} элементаў у сметніцу?}}", "@binEntriesConfirmationDialogMessage": { "placeholders": { "count": {} @@ -797,7 +797,7 @@ "@settingsCollectionTile": {}, "settingsThemeBrightnessDialogTitle": "Тэма", "@settingsThemeBrightnessDialogTitle": {}, - "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэты альбом і элемент ў ім?} few{Выдаліць гэты альбом і {count} элементы ў ім?} other{Выдаліць гэты альбом і {count} элементаў ў ім?}}", + "deleteSingleAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэты альбом і элемент у ім?} few{Выдаліць гэты альбом і {count} элементы ў ім?} other{Выдаліць гэты альбом і {count} элементаў у ім?}}", "@deleteSingleAlbumConfirmationDialogMessage": { "placeholders": { "count": {} @@ -819,7 +819,7 @@ "@aboutDataUsageMisc": {}, "albumVideoCaptures": "Відэазапісы", "@albumVideoCaptures": {}, - "editEntryDateDialogSetCustom": "Ўсталяваць карыстацкую дату", + "editEntryDateDialogSetCustom": "Устанавіць дату", "@editEntryDateDialogSetCustom": {}, "settingsSearchEmpty": "Няма адпаведнай налады", "@settingsSearchEmpty": {}, @@ -845,7 +845,7 @@ "@collectionSelectSectionTooltip": {}, "aboutLicensesBanner": "Гэта праграма выкарыстоўвае наступныя пакеты і бібліятэкі з адкрытым зыходным кодам.", "@aboutLicensesBanner": {}, - "dateYesterday": "Ўчора", + "dateYesterday": "Учора", "@dateYesterday": {}, "aboutDataUsageDatabase": "База дадзеных", "@aboutDataUsageDatabase": {}, @@ -853,7 +853,7 @@ "@tileLayoutMosaic": {}, "collectionDeselectSectionTooltip": "Адмяніць выбар раздзела", "@collectionDeselectSectionTooltip": {}, - "settingsKeepScreenOnTile": "Трымаць экран ўключаным", + "settingsKeepScreenOnTile": "Трымаць экран уключаным", "@settingsKeepScreenOnTile": {}, "tileLayoutGrid": "Сетка", "@tileLayoutGrid": {}, @@ -879,11 +879,11 @@ "@videoStreamSelectionDialogAudio": {}, "videoSpeedDialogLabel": "Хуткасць прайгравання", "@videoSpeedDialogLabel": {}, - "editEntryLocationDialogSetCustom": "Ўстанавіць карыстацкае месцазнаходжанне", + "editEntryLocationDialogSetCustom": "Рэдагаваць месцазнаходжанне", "@editEntryLocationDialogSetCustom": {}, "placeEmpty": "Няма месцаў", "@placeEmpty": {}, - "editEntryDateDialogExtractFromTitle": "Выняць з загалоўка", + "editEntryDateDialogExtractFromTitle": "Выняць з назвы", "@editEntryDateDialogExtractFromTitle": {}, "aboutLinkLicense": "Ліцэнзія", "@aboutLinkLicense": {}, @@ -925,7 +925,7 @@ "@drawerAlbumPage": {}, "settingsActionImport": "Імпарт", "@settingsActionImport": {}, - "locationPickerUseThisLocationButton": "Выкарыстоўваць гэтае месца", + "locationPickerUseThisLocationButton": "Выкарыстоўваць гэтае месцазнаходжанне", "@locationPickerUseThisLocationButton": {}, "collectionGroupNone": "Не групаваць", "@collectionGroupNone": {}, @@ -937,7 +937,7 @@ "@settingsActionImportDialogTitle": {}, "albumGroupTier": "Па ўзроўні", "@albumGroupTier": {}, - "drawerCollectionAll": "Ўся калекцыя", + "drawerCollectionAll": "Уся калекцыя", "@drawerCollectionAll": {}, "sortByItemCount": "Па колькасці элементаў", "@sortByItemCount": {}, @@ -953,7 +953,7 @@ "@albumPickPageTitlePick": {}, "menuActionMap": "Карта", "@menuActionMap": {}, - "collectionActionMove": "Перамясціць ў альбом", + "collectionActionMove": "Перамясціць у альбом", "@collectionActionMove": {}, "searchAlbumsSectionTitle": "Альбомы", "@searchAlbumsSectionTitle": {}, @@ -1013,9 +1013,9 @@ "@albumPageTitle": {}, "editEntryLocationDialogTitle": "Месцазнаходжанне", "@editEntryLocationDialogTitle": {}, - "albumPickPageTitleCopy": "Скапіяваць ў альбом", + "albumPickPageTitleCopy": "Капіяваць у альбом", "@albumPickPageTitleCopy": {}, - "collectionActionCopy": "Скапіяваць ў альбом", + "collectionActionCopy": "Скапіяваць у альбом", "@collectionActionCopy": {}, "viewDialogReverseSortOrder": "Адваротны парадак сартавання", "@viewDialogReverseSortOrder": {}, @@ -1033,7 +1033,7 @@ "@tagEmpty": {}, "collectionActionShowTitleSearch": "Паказаць фільтр загалоўка", "@collectionActionShowTitleSearch": {}, - "menuActionSelectAll": "Выбраць ўсё", + "menuActionSelectAll": "Выбраць усе", "@menuActionSelectAll": {}, "settingsConfirmationTile": "Дыялогі пацверджання", "@settingsConfirmationTile": {}, @@ -1059,7 +1059,7 @@ "@drawerCollectionAnimated": {}, "durationDialogHours": "Гадзіны", "@durationDialogHours": {}, - "settingsKeepScreenOnDialogTitle": "Трымаць экран ўключаным", + "settingsKeepScreenOnDialogTitle": "Трымаць экран уключаным", "@settingsKeepScreenOnDialogTitle": {}, "drawerPlacePage": "Месцы", "@drawerPlacePage": {}, @@ -1077,7 +1077,7 @@ "@appExportFavourites": {}, "collectionEmptyImages": "Няма выяў", "@collectionEmptyImages": {}, - "albumPickPageTitleExport": "Экспартаваць ў альбом", + "albumPickPageTitleExport": "Экспарт у альбом", "@albumPickPageTitleExport": {}, "settingsActionExportDialogTitle": "Экспарт", "@settingsActionExportDialogTitle": {}, @@ -1127,7 +1127,7 @@ "@viewDialogLayoutSectionTitle": {}, "searchStatesSectionTitle": "Штаты", "@searchStatesSectionTitle": {}, - "dateThisMonth": "Ў гэтым месяцы", + "dateThisMonth": "У гэтым месяцы", "@dateThisMonth": {}, "aboutPageTitle": "Пра нас", "@aboutPageTitle": {}, @@ -1141,7 +1141,7 @@ "@genericFailureFeedback": {}, "aboutDataUsageData": "Дадзеныя", "@aboutDataUsageData": {}, - "aboutDataUsageInternal": "Ўнутраны", + "aboutDataUsageInternal": "Унутранае", "@aboutDataUsageInternal": {}, "albumDownload": "Загрузкі", "@albumDownload": {}, @@ -1149,7 +1149,7 @@ "@coverDialogTabColor": {}, "genericSuccessFeedback": "Гатова!", "@genericSuccessFeedback": {}, - "aboutLicensesShowAllButtonLabel": "Паказаць ўсе ліцэнзіі", + "aboutLicensesShowAllButtonLabel": "Паказаць усе ліцэнзіі", "@aboutLicensesShowAllButtonLabel": {}, "sortOrderNewestFirst": "Спачатку самае новае", "@sortOrderNewestFirst": {}, @@ -1175,7 +1175,7 @@ "@menuActionStats": {}, "appPickDialogTitle": "Выбраць праграму", "@appPickDialogTitle": {}, - "albumPickPageTitleMove": "Перамясціць ў альбом", + "albumPickPageTitleMove": "Перамясціць у альбом", "@albumPickPageTitleMove": {}, "coverDialogTabCover": "Вокладка", "@coverDialogTabCover": {}, @@ -1183,7 +1183,7 @@ "@settingsConfirmationBeforeDeleteItems": {}, "settingsConfirmationBeforeMoveUndatedItems": "Спытаць, перш чым перамяшчаць прадметы без даты", "@settingsConfirmationBeforeMoveUndatedItems": {}, - "settingsConfirmationAfterMoveToBinItems": "Паказваць паведамленне пасля перамяшчэння элементаў ў сметніцу", + "settingsConfirmationAfterMoveToBinItems": "Паказваць паведамленне пасля перамяшчэння элементаў у сметніцу", "@settingsConfirmationAfterMoveToBinItems": {}, "settingsConfirmationBeforeMoveToBinItems": "Спытаць перад тым, як пераносіць элементы ў сметніцу", "@settingsConfirmationBeforeMoveToBinItems": {}, @@ -1387,7 +1387,7 @@ "@settingsNavigationDrawerTile": {}, "settingsHiddenItemsPageTitle": "Схаваныя элементы", "@settingsHiddenItemsPageTitle": {}, - "settingsHiddenPathsBanner": "Фатаграфіі і відэа ў гэтых папках або ў любой з іх укладзеных папак не будуць адлюстроўвацца ў вашай калекцыі.", + "settingsHiddenPathsBanner": "Фатаграфіі і відэа ў гэтых тэчках або ў любой з іх укладзеных тэчках не будуць адлюстроўвацца ў вашай калекцыі.", "@settingsHiddenPathsBanner": {}, "settingsViewerShowOverlayOnOpening": "Паказаць на адкрыцці", "@settingsViewerShowOverlayOnOpening": {}, @@ -1405,7 +1405,7 @@ "@settingsStorageAccessEmpty": {}, "settingsRemoveAnimationsTile": "Выдаліць анімацыі", "@settingsRemoveAnimationsTile": {}, - "settingsStorageAccessBanner": "Некаторыя каталогі патрабуюць відавочнага дазволу на змяненне файлаў ў іх. Тут вы можаце прагледзець каталогі, да якіх вы раней далі доступ.", + "settingsStorageAccessBanner": "Некаторыя каталогі патрабуюць відавочнага дазволу на змяненне файлаў у іх. Тут вы можаце прагледзець каталогі, да якіх вы раней далі доступ.", "@settingsStorageAccessBanner": {}, "collectionCopySuccessFeedback": "{count, plural, =1{1 элемент скапіяваны} few{{count} элементы скапіявана} other{{count} элементаў скапіявана}}", "@collectionCopySuccessFeedback": { @@ -1467,7 +1467,7 @@ "@settingsSubtitleThemeTextPositionTile": {}, "settingsVideoBackgroundModeDialogTitle": "Фонавы рэжым", "@settingsVideoBackgroundModeDialogTitle": {}, - "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэтыя альбомы і элемент ў іх?} few{Выдаліць гэтыя альбомы і {count} элементы ў іх?} other{Выдаліць гэтыя альбомы і {count} элементаў ў іх?}}", + "deleteMultiAlbumConfirmationDialogMessage": "{count, plural, =1{Выдаліць гэтыя альбомы і элемент у іх?} few{Выдаліць гэтыя альбомы і {count} элементы ў іх?} other{Выдаліць гэтыя альбомы і {count} элементаў у іх?}}", "@deleteMultiAlbumConfirmationDialogMessage": { "placeholders": { "count": {} @@ -1519,24 +1519,28 @@ "minutes": {} } }, - "collectionActionSetHome": "Ўсталяваць як галоўную", + "collectionActionSetHome": "Усталяваць як галоўную", "@collectionActionSetHome": {}, - "setHomeCustomCollection": "Ўласная калекцыя", + "setHomeCustomCollection": "Уласная калекцыя", "@setHomeCustomCollection": {}, "settingsThumbnailShowHdrIcon": "Паказаць значок HDR", "@settingsThumbnailShowHdrIcon": {}, - "videoRepeatActionSetEnd": "Ўсталяваць канец", + "videoRepeatActionSetEnd": "Усталяваць канец", "@videoRepeatActionSetEnd": {}, "stopTooltip": "Спыніць", "@stopTooltip": {}, "videoActionABRepeat": "Паўтарыць ад А да Б", "@videoActionABRepeat": {}, - "videoRepeatActionSetStart": "Ўсталяваць пачатак", + "videoRepeatActionSetStart": "Усталяваць пачатак", "@videoRepeatActionSetStart": {}, "renameProcessorHash": "Хэш", "@renameProcessorHash": {}, "settingsForceWesternArabicNumeralsTile": "Прымусовыя арабскія лічбы", "@settingsForceWesternArabicNumeralsTile": {}, - "chipActionShowCollection": "Паказаць ў Калекцыі", - "@chipActionShowCollection": {} + "chipActionShowCollection": "Паказаць у Калекцыі", + "@chipActionShowCollection": {}, + "chipActionGoToExplorerPage": "Паказаць у Правадыру", + "@chipActionGoToExplorerPage": {}, + "explorerPageTitle": "Правадыр", + "@explorerPageTitle": {} } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ceb286db..dae5ad074 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -90,6 +90,7 @@ "chipActionGoToCountryPage": "Show in Countries", "chipActionGoToPlacePage": "Show in Places", "chipActionGoToTagPage": "Show in Tags", + "chipActionGoToExplorerPage": "Show in Explorer", "chipActionFilterOut": "Filter out", "chipActionFilterIn": "Filter in", "chipActionHide": "Hide", @@ -771,6 +772,8 @@ "binPageTitle": "Recycle Bin", + "explorerPageTitle": "Explorer", + "searchCollectionFieldHint": "Search collection", "searchRecentSectionTitle": "Recent", "searchDateSectionTitle": "Date", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 94a36e8d2..db0359da9 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1380,5 +1380,9 @@ "renameProcessorHash": "Hash", "@renameProcessorHash": {}, "chipActionShowCollection": "Mostrar en Colección", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "explorerPageTitle": "Explorar", + "@explorerPageTitle": {}, + "chipActionGoToExplorerPage": "Mostrar en el explorador", + "@chipActionGoToExplorerPage": {} } diff --git a/lib/l10n/app_fi.arb b/lib/l10n/app_fi.arb index f36e81669..c8b8ad913 100644 --- a/lib/l10n/app_fi.arb +++ b/lib/l10n/app_fi.arb @@ -302,5 +302,137 @@ "filterNoDateLabel": "Päiväämätön", "@filterNoDateLabel": {}, "chipActionShowCollection": "Näytä kokoelmassa", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "widgetDisplayedItemMostRecent": "Viimeisin", + "@widgetDisplayedItemMostRecent": {}, + "otherDirectoryDescription": "“{name}” kansio", + "@otherDirectoryDescription": { + "placeholders": { + "name": { + "type": "String", + "example": "Pictures", + "description": "the name of a specific directory" + } + } + }, + "videoActionABRepeat": "A-B toisto", + "@videoActionABRepeat": {}, + "videoRepeatActionSetStart": "Aseta alku", + "@videoRepeatActionSetStart": {}, + "videoRepeatActionSetEnd": "Aseta loppu", + "@videoRepeatActionSetEnd": {}, + "filterTypeRawLabel": "Raw", + "@filterTypeRawLabel": {}, + "filterTypeSphericalVideoLabel": "360° Video", + "@filterTypeSphericalVideoLabel": {}, + "filterTypeGeotiffLabel": "GeoTIFF", + "@filterTypeGeotiffLabel": {}, + "filterMimeVideoLabel": "Video", + "@filterMimeVideoLabel": {}, + "coordinateFormatDms": "DMS", + "@coordinateFormatDms": {}, + "coordinateDms": "{coordinate} {direction}", + "@coordinateDms": { + "placeholders": { + "coordinate": { + "type": "String", + "example": "38° 41′ 47.72″" + }, + "direction": { + "type": "String", + "example": "S" + } + } + }, + "coordinateDmsNorth": "P", + "@coordinateDmsNorth": {}, + "lengthUnitPixel": "px", + "@lengthUnitPixel": {}, + "lengthUnitPercent": "%", + "@lengthUnitPercent": {}, + "mapStyleGoogleNormal": "Google Maps", + "@mapStyleGoogleNormal": {}, + "mapStyleHuaweiNormal": "Petal Maps", + "@mapStyleHuaweiNormal": {}, + "mapStyleHuaweiTerrain": "Petal Maps (Maasto)", + "@mapStyleHuaweiTerrain": {}, + "overlayHistogramRGB": "RGB", + "@overlayHistogramRGB": {}, + "subtitlePositionTop": "Ylhäällä", + "@subtitlePositionTop": {}, + "subtitlePositionBottom": "Alhaalla", + "@subtitlePositionBottom": {}, + "themeBrightnessLight": "Vaalea", + "@themeBrightnessLight": {}, + "themeBrightnessDark": "Tumma", + "@themeBrightnessDark": {}, + "themeBrightnessBlack": "Musta", + "@themeBrightnessBlack": {}, + "unitSystemMetric": "Metrinen", + "@unitSystemMetric": {}, + "unitSystemImperial": "Brittiläinen", + "@unitSystemImperial": {}, + "vaultLockTypePattern": "Kuvio", + "@vaultLockTypePattern": {}, + "vaultLockTypePin": "PIN", + "@vaultLockTypePin": {}, + "vaultLockTypePassword": "Salasana", + "@vaultLockTypePassword": {}, + "settingsVideoEnablePip": "Kuva kuvassa", + "@settingsVideoEnablePip": {}, + "videoControlsPlay": "Toista", + "@videoControlsPlay": {}, + "videoControlsPlayOutside": "Avaa toisella soittimella", + "@videoControlsPlayOutside": {}, + "videoControlsPlaySeek": "Toista & selaa eteen/taakse", + "@videoControlsPlaySeek": {}, + "videoControlsNone": "Ei mitään", + "@videoControlsNone": {}, + "videoLoopModeNever": "Ei koskaan", + "@videoLoopModeNever": {}, + "videoLoopModeShortOnly": "Vain lyhyissä videoissa", + "@videoLoopModeShortOnly": {}, + "videoPlaybackSkip": "Ohita", + "@videoPlaybackSkip": {}, + "videoPlaybackMuted": "Toista mykistettynä", + "@videoPlaybackMuted": {}, + "videoPlaybackWithSound": "Toista äänillä", + "@videoPlaybackWithSound": {}, + "videoResumptionModeAlways": "Aina", + "@videoResumptionModeAlways": {}, + "wallpaperTargetLock": "Lukitusnäyttö", + "@wallpaperTargetLock": {}, + "wallpaperTargetHomeLock": "Koti- ja lukitusnäyttö", + "@wallpaperTargetHomeLock": {}, + "widgetDisplayedItemRandom": "Satunnainen", + "@widgetDisplayedItemRandom": {}, + "focalLength": "{length} mm", + "@focalLength": { + "placeholders": { + "length": { + "type": "String", + "example": "5.4" + } + } + }, + "videoActionUnmute": "Poista mykistys", + "@videoActionUnmute": {}, + "coordinateDmsWest": "L", + "@coordinateDmsWest": {}, + "coordinateDmsSouth": "E", + "@coordinateDmsSouth": {}, + "coordinateDmsEast": "I", + "@coordinateDmsEast": {}, + "videoLoopModeAlways": "Aina", + "@videoLoopModeAlways": {}, + "videoResumptionModeNever": "Ei koskaan", + "@videoResumptionModeNever": {}, + "viewerTransitionNone": "Ei mitään", + "@viewerTransitionNone": {}, + "wallpaperTargetHome": "Kotinäyttö", + "@wallpaperTargetHome": {}, + "storageVolumeDescriptionFallbackPrimary": "Sisäinen tallennustila", + "@storageVolumeDescriptionFallbackPrimary": {}, + "storageVolumeDescriptionFallbackNonPrimary": "SD-kortti", + "@storageVolumeDescriptionFallbackNonPrimary": {} } diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index f0e39d841..60c7c600d 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1380,5 +1380,9 @@ "settingsForceWesternArabicNumeralsTile": "Toujours utiliser les chiffres arabes", "@settingsForceWesternArabicNumeralsTile": {}, "chipActionShowCollection": "Afficher dans Collection", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "explorerPageTitle": "Explorateur", + "@explorerPageTitle": {}, + "chipActionGoToExplorerPage": "Afficher dans Explorateur", + "@chipActionGoToExplorerPage": {} } diff --git a/lib/l10n/app_ko.arb b/lib/l10n/app_ko.arb index 5705a155d..8672c491d 100644 --- a/lib/l10n/app_ko.arb +++ b/lib/l10n/app_ko.arb @@ -1380,5 +1380,9 @@ "settingsForceWesternArabicNumeralsTile": "아라비아 숫자 항상 사용", "@settingsForceWesternArabicNumeralsTile": {}, "chipActionShowCollection": "미디어 페이지에서 보기", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "explorerPageTitle": "탐색기", + "@explorerPageTitle": {}, + "chipActionGoToExplorerPage": "탐색기 페이지에서 보기", + "@chipActionGoToExplorerPage": {} } diff --git a/lib/l10n/app_pl.arb b/lib/l10n/app_pl.arb index 5ab5253b2..7581069c6 100644 --- a/lib/l10n/app_pl.arb +++ b/lib/l10n/app_pl.arb @@ -1538,5 +1538,9 @@ "renameProcessorHash": "Skrót", "@renameProcessorHash": {}, "chipActionShowCollection": "Pokaż w Kolekcji", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "chipActionGoToExplorerPage": "Pokaż w przeglądarce", + "@chipActionGoToExplorerPage": {}, + "explorerPageTitle": "Przeglądarka", + "@explorerPageTitle": {} } diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9424307c2..c7018e5da 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -593,7 +593,7 @@ "@collectionCopySuccessFeedback": {}, "collectionMoveSuccessFeedback": "{count, plural, =1{Перемещён 1 объект} few{Перемещено {count} объекта} other{Перемещено {count} объектов}}", "@collectionMoveSuccessFeedback": {}, - "collectionRenameSuccessFeedback": "{count, plural, =1{Переименован 1 объект} few{Переименовао {count} объекта} other{Переименовано {count} объектов}}", + "collectionRenameSuccessFeedback": "{count, plural, =1{Переименован 1 объект} few{Переименовано {count} объекта} other{Переименовано {count} объектов}}", "@collectionRenameSuccessFeedback": {}, "collectionEditSuccessFeedback": "{count, plural, =1{Изменён 1 объект} few{Изменено {count} объекта} other{Изменено {count} объектов}}", "@collectionEditSuccessFeedback": {}, @@ -1380,5 +1380,9 @@ "settingsForceWesternArabicNumeralsTile": "Принудительные арабские цифры", "@settingsForceWesternArabicNumeralsTile": {}, "chipActionShowCollection": "Показать в Коллекции", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "chipActionGoToExplorerPage": "Показать в проводнике", + "@chipActionGoToExplorerPage": {}, + "explorerPageTitle": "Проводник", + "@explorerPageTitle": {} } diff --git a/lib/l10n/app_sk.arb b/lib/l10n/app_sk.arb index 28f294611..4a9c77544 100644 --- a/lib/l10n/app_sk.arb +++ b/lib/l10n/app_sk.arb @@ -1526,5 +1526,17 @@ "settingsThumbnailShowHdrIcon": "Zobraziť ikonu HDR", "@settingsThumbnailShowHdrIcon": {}, "chipActionShowCollection": "Zobraziť v kolekcií", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "videoActionABRepeat": "Opakovanie A-B", + "@videoActionABRepeat": {}, + "videoRepeatActionSetStart": "Nastaviť začiatok", + "@videoRepeatActionSetStart": {}, + "videoRepeatActionSetEnd": "Nastaviť koniec", + "@videoRepeatActionSetEnd": {}, + "settingsForceWesternArabicNumeralsTile": "Vynútiť arabské číslice", + "@settingsForceWesternArabicNumeralsTile": {}, + "stopTooltip": "Zastaviť", + "@stopTooltip": {}, + "renameProcessorHash": "Hash", + "@renameProcessorHash": {} } diff --git a/lib/l10n/app_tr.arb b/lib/l10n/app_tr.arb index 0ac680d5b..9d47e4fe5 100644 --- a/lib/l10n/app_tr.arb +++ b/lib/l10n/app_tr.arb @@ -1380,5 +1380,9 @@ "renameProcessorHash": "Sağlama", "@renameProcessorHash": {}, "chipActionShowCollection": "Koleksiyonda göster", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "chipActionGoToExplorerPage": "Gezginde göster", + "@chipActionGoToExplorerPage": {}, + "explorerPageTitle": "Gezgin", + "@explorerPageTitle": {} } diff --git a/lib/l10n/app_uk.arb b/lib/l10n/app_uk.arb index bd1bcf700..9ed85d2d8 100644 --- a/lib/l10n/app_uk.arb +++ b/lib/l10n/app_uk.arb @@ -1035,7 +1035,7 @@ "@settingsSubtitleThemeTextAlignmentCenter": {}, "settingsSubtitleThemeTextAlignmentRight": "Праворуч", "@settingsSubtitleThemeTextAlignmentRight": {}, - "settingsVideoControlsTile": "Управління", + "settingsVideoControlsTile": "Елементи керування", "@settingsVideoControlsTile": {}, "settingsVideoButtonsTile": "Кнопки", "@settingsVideoButtonsTile": {}, @@ -1049,7 +1049,7 @@ "@settingsSaveSearchHistory": {}, "settingsEnableBin": "Використовувати кошик", "@settingsEnableBin": {}, - "settingsAllowMediaManagement": "Дозволити управління медіа", + "settingsAllowMediaManagement": "Дозволити керування мультимедіа", "@settingsAllowMediaManagement": {}, "settingsHiddenItemsTile": "Приховані елементи", "@settingsHiddenItemsTile": {}, @@ -1297,7 +1297,7 @@ "@settingsSlideshowAnimatedZoomEffect": {}, "settingsSubtitleThemeSample": "Це зразок.", "@settingsSubtitleThemeSample": {}, - "settingsVideoControlsPageTitle": "Управління", + "settingsVideoControlsPageTitle": "Елементи керування", "@settingsVideoControlsPageTitle": {}, "settingsVideoSectionTitle": "Відео", "@settingsVideoSectionTitle": {}, @@ -1538,5 +1538,9 @@ "settingsForceWesternArabicNumeralsTile": "Примусові арабські цифри", "@settingsForceWesternArabicNumeralsTile": {}, "chipActionShowCollection": "Показати у Колекції", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "chipActionGoToExplorerPage": "Показати в провіднику", + "@chipActionGoToExplorerPage": {}, + "explorerPageTitle": "Провідник", + "@explorerPageTitle": {} } diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 80d0c3c6e..14bdd20fc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1380,5 +1380,9 @@ "settingsForceWesternArabicNumeralsTile": "强制使用阿拉伯数字", "@settingsForceWesternArabicNumeralsTile": {}, "chipActionShowCollection": "在媒体集中显示", - "@chipActionShowCollection": {} + "@chipActionShowCollection": {}, + "explorerPageTitle": "资源管理器", + "@explorerPageTitle": {}, + "chipActionGoToExplorerPage": "在资源管理器中显示", + "@chipActionGoToExplorerPage": {} } diff --git a/lib/model/app/contributors.dart b/lib/model/app/contributors.dart index 5614d2139..5c4204abc 100644 --- a/lib/model/app/contributors.dart +++ b/lib/model/app/contributors.dart @@ -91,11 +91,14 @@ class Contributors { Contributor('cheese', 'deanlemans5646@gmail.com'), Contributor('Owen Elderbroek', 'o.elderbroek@gmail.com'), Contributor('Maxi', 'maxitendo01@proton.me'), + Contributor('Jerguš Fonfer', 'caro.jf@protonmail.com'), + Contributor('elfriob', 'elfriob@ya.ru'), // Contributor('Alvi Khan', 'aveenalvi@gmail.com'), // Bengali // Contributor('Htet Oo Hlaing', 'htetoh2006@outlook.com'), // Burmese // Contributor('Khant', 'khant@users.noreply.hosted.weblate.org'), // Burmese // Contributor('Grooty12', 'Rasmus@rosendahl-kaa.name'), // Danish // Contributor('Åzze', 'laitinen.jere222@gmail.com'), // Finnish + // Contributor('Olli', 'ollinen@ollit.dev'), // Finnish // Contributor('Idj', 'joneltmp+goahn@gmail.com'), // Hebrew // Contributor('Rohit Burman', 'rohitburman31p@rediffmail.com'), // Hindi // Contributor('AJ07', 'ajaykumarmeena676@gmail.com'), // Hindi diff --git a/lib/model/device.dart b/lib/model/device.dart index e2c687e21..3c7a60d7a 100644 --- a/lib/model/device.dart +++ b/lib/model/device.dart @@ -63,14 +63,12 @@ class Device { final auth = LocalAuthentication(); _canAuthenticateUser = await auth.canCheckBiometrics || await auth.isDeviceSupported(); - final floating = Floating(); try { - _supportPictureInPicture = await floating.isPipAvailable; + _supportPictureInPicture = await Floating().isPipAvailable; } on PlatformException catch (_) { // as of floating v2.0.0, plugin assumes activity and fails when bound via service _supportPictureInPicture = false; } - floating.dispose(); final capabilities = await deviceService.getCapabilities(); _canGrantDirectoryAccess = capabilities['canGrantDirectoryAccess'] ?? false; diff --git a/lib/model/entry/entry.dart b/lib/model/entry/entry.dart index bdf810294..d7b91a58c 100644 --- a/lib/model/entry/entry.dart +++ b/lib/model/entry/entry.dart @@ -44,7 +44,8 @@ class AvesEntry with AvesEntryBase { AddressDetails? _addressDetails; TrashDetails? trashDetails; - List? burstEntries; + // synthetic stack of related entries, e.g. burst shots or raw/developed pairs + List? stackedEntries; @override final AChangeNotifier visualChangeNotifier = AChangeNotifier(); @@ -69,7 +70,7 @@ class AvesEntry with AvesEntryBase { required int? durationMillis, required this.trashed, required this.origin, - this.burstEntries, + this.stackedEntries, }) : id = id ?? 0 { if (kFlutterMemoryAllocationsEnabled) { FlutterMemoryAllocations.instance.dispatchObjectCreated( @@ -93,7 +94,7 @@ class AvesEntry with AvesEntryBase { int? dateAddedSecs, int? dateModifiedSecs, int? origin, - List? burstEntries, + List? stackedEntries, }) { final copyEntryId = id ?? this.id; final copied = AvesEntry( @@ -114,7 +115,7 @@ class AvesEntry with AvesEntryBase { durationMillis: durationMillis, trashed: trashed, origin: origin ?? this.origin, - burstEntries: burstEntries ?? this.burstEntries, + stackedEntries: stackedEntries ?? this.stackedEntries, ) ..catalogMetadata = _catalogMetadata?.copyWith(id: copyEntryId) ..addressDetails = _addressDetails?.copyWith(id: copyEntryId) diff --git a/lib/model/entry/extensions/multipage.dart b/lib/model/entry/extensions/multipage.dart index a978ef0f6..6b32a8e8b 100644 --- a/lib/model/entry/extensions/multipage.dart +++ b/lib/model/entry/extensions/multipage.dart @@ -7,9 +7,9 @@ import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; extension ExtraAvesEntryMultipage on AvesEntry { - bool get isMultiPage => isBurst || ((catalogMetadata?.isMultiPage ?? false) && (isMotionPhoto || !isHdr)); + bool get isMultiPage => isStack || ((catalogMetadata?.isMultiPage ?? false) && (isMotionPhoto || !isHdr)); - bool get isBurst => burstEntries?.isNotEmpty == true; + bool get isStack => stackedEntries?.isNotEmpty == true; bool get isMotionPhoto => catalogMetadata?.isMotionPhoto ?? false; @@ -19,10 +19,10 @@ extension ExtraAvesEntryMultipage on AvesEntry { } Future getMultiPageInfo() async { - if (isBurst) { + if (isStack) { return MultiPageInfo( mainEntry: this, - pages: burstEntries! + pages: stackedEntries! .mapIndexed((index, entry) => SinglePageInfo( index: index, pageId: entry.id, diff --git a/lib/model/filters/album.dart b/lib/model/filters/album.dart index d2126c938..53121a7ec 100644 --- a/lib/model/filters/album.dart +++ b/lib/model/filters/album.dart @@ -52,13 +52,13 @@ class AlbumFilter extends CoveredCollectionFilter { String getTooltip(BuildContext context) => album; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) { return IconUtils.getAlbumIcon( context: context, albumPath: album, size: size, ) ?? - (showGenericIcon ? Icon(AIcons.album, size: size) : null); + (allowGenericIcon ? Icon(AIcons.album, size: size) : null); } @override diff --git a/lib/model/filters/aspect_ratio.dart b/lib/model/filters/aspect_ratio.dart index b638ef7e4..5fcf6933f 100644 --- a/lib/model/filters/aspect_ratio.dart +++ b/lib/model/filters/aspect_ratio.dart @@ -68,7 +68,7 @@ class AspectRatioFilter extends CollectionFilter { } @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.aspectRatio, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.aspectRatio, size: size); @override String get category => type; diff --git a/lib/model/filters/coordinate.dart b/lib/model/filters/coordinate.dart index fff9df429..c8ba7f2a7 100644 --- a/lib/model/filters/coordinate.dart +++ b/lib/model/filters/coordinate.dart @@ -69,7 +69,7 @@ class CoordinateFilter extends CollectionFilter { } @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.geoBounds, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.geoBounds, size: size); @override String get category => type; diff --git a/lib/model/filters/date.dart b/lib/model/filters/date.dart index 2756de2e0..d73b8ab59 100644 --- a/lib/model/filters/date.dart +++ b/lib/model/filters/date.dart @@ -122,7 +122,7 @@ class DateFilter extends CollectionFilter { } @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.date, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.date, size: size); @override String get category => type; diff --git a/lib/model/filters/favourite.dart b/lib/model/filters/favourite.dart index 2bd813b89..fc8855260 100644 --- a/lib/model/filters/favourite.dart +++ b/lib/model/filters/favourite.dart @@ -45,7 +45,7 @@ class FavouriteFilter extends CollectionFilter { String getLabel(BuildContext context) => context.l10n.filterFavouriteLabel; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.favourite, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.favourite, size: size); @override Future color(BuildContext context) { diff --git a/lib/model/filters/filters.dart b/lib/model/filters/filters.dart index f22270c88..fe52ebbdf 100644 --- a/lib/model/filters/filters.dart +++ b/lib/model/filters/filters.dart @@ -133,7 +133,7 @@ abstract class CollectionFilter extends Equatable implements Comparable getLabel(context); - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => null; + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => null; Future color(BuildContext context) { final colors = context.read(); diff --git a/lib/model/filters/location.dart b/lib/model/filters/location.dart index 0b5c06d25..7c29eddf0 100644 --- a/lib/model/filters/location.dart +++ b/lib/model/filters/location.dart @@ -89,7 +89,7 @@ class LocationFilter extends CoveredCollectionFilter { String getLabel(BuildContext context) => _isUnlocated ? context.l10n.filterNoLocationLabel : _location; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) { if (_isUnlocated) { return Icon(AIcons.locationUnlocated, size: size); } diff --git a/lib/model/filters/mime.dart b/lib/model/filters/mime.dart index 86d4840d2..36176697d 100644 --- a/lib/model/filters/mime.dart +++ b/lib/model/filters/mime.dart @@ -77,7 +77,7 @@ class MimeFilter extends CollectionFilter { } @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(_icon, size: size); @override Future color(BuildContext context) { diff --git a/lib/model/filters/missing.dart b/lib/model/filters/missing.dart index d785bc3c2..1f9bc6bf1 100644 --- a/lib/model/filters/missing.dart +++ b/lib/model/filters/missing.dart @@ -70,7 +70,7 @@ class MissingFilter extends CollectionFilter { } @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(_icon, size: size); @override String get category => type; diff --git a/lib/model/filters/or.dart b/lib/model/filters/or.dart index 89e62751f..2b6c8d83f 100644 --- a/lib/model/filters/or.dart +++ b/lib/model/filters/or.dart @@ -60,8 +60,8 @@ class OrFilter extends CollectionFilter { String getLabel(BuildContext context) => _filters.map((v) => v.getLabel(context)).join(', '); @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { - return _genericIcon != null ? Icon(_genericIcon, size: size) : _first.iconBuilder(context, size, showGenericIcon: showGenericIcon); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) { + return _genericIcon != null ? Icon(_genericIcon, size: size) : _first.iconBuilder(context, size, allowGenericIcon: allowGenericIcon); } @override diff --git a/lib/model/filters/path.dart b/lib/model/filters/path.dart index f8b05afc7..4bb6c7cde 100644 --- a/lib/model/filters/path.dart +++ b/lib/model/filters/path.dart @@ -1,5 +1,9 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/view/view.dart'; +import 'package:flutter/widgets.dart'; class PathFilter extends CollectionFilter { static const type = 'path'; @@ -47,6 +51,19 @@ class PathFilter extends CollectionFilter { @override String get universalLabel => path; + @override + String getLabel(BuildContext context) { + final _directory = androidFileUtils.relativeDirectoryFromPath(path); + if (_directory == null) return universalLabel; + if (_directory.relativeDir.isEmpty) { + return _directory.getVolumeDescription(context); + } + return pContext.split(_directory.relativeDir).last; + } + + @override + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.explorer, size: size); + @override String get category => type; diff --git a/lib/model/filters/placeholder.dart b/lib/model/filters/placeholder.dart index 9e3263786..4f6145f7f 100644 --- a/lib/model/filters/placeholder.dart +++ b/lib/model/filters/placeholder.dart @@ -96,7 +96,7 @@ class PlaceholderFilter extends CollectionFilter { } @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(_icon, size: size); @override String get category => type; diff --git a/lib/model/filters/query.dart b/lib/model/filters/query.dart index af136e6ae..e5c47c42b 100644 --- a/lib/model/filters/query.dart +++ b/lib/model/filters/query.dart @@ -82,7 +82,7 @@ class QueryFilter extends CollectionFilter { String get universalLabel => query; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.text, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.text, size: size); @override Future color(BuildContext context) { diff --git a/lib/model/filters/rating.dart b/lib/model/filters/rating.dart index c9bd56290..ac296d365 100644 --- a/lib/model/filters/rating.dart +++ b/lib/model/filters/rating.dart @@ -64,7 +64,7 @@ class RatingFilter extends CollectionFilter { }; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) { return switch (rating) { -1 => Icon(AIcons.ratingRejected, size: size), 0 => Icon(AIcons.ratingUnrated, size: size), diff --git a/lib/model/filters/recent.dart b/lib/model/filters/recent.dart index 57d61e856..57d1b1e52 100644 --- a/lib/model/filters/recent.dart +++ b/lib/model/filters/recent.dart @@ -51,7 +51,7 @@ class RecentlyAddedFilter extends CollectionFilter { String getLabel(BuildContext context) => context.l10n.filterRecentlyAddedLabel; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.dateRecent, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.dateRecent, size: size); @override String get category => type; diff --git a/lib/model/filters/tag.dart b/lib/model/filters/tag.dart index cbb723e04..8282ad871 100644 --- a/lib/model/filters/tag.dart +++ b/lib/model/filters/tag.dart @@ -47,8 +47,8 @@ class TagFilter extends CoveredCollectionFilter { String getLabel(BuildContext context) => tag.isEmpty ? context.l10n.filterNoTagLabel : tag; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) { - return showGenericIcon ? Icon(tag.isEmpty ? AIcons.tagUntagged : AIcons.tag, size: size) : null; + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) { + return allowGenericIcon ? Icon(tag.isEmpty ? AIcons.tagUntagged : AIcons.tag, size: size) : null; } @override diff --git a/lib/model/filters/trash.dart b/lib/model/filters/trash.dart index fc7b10325..095a058f9 100644 --- a/lib/model/filters/trash.dart +++ b/lib/model/filters/trash.dart @@ -41,7 +41,7 @@ class TrashFilter extends CollectionFilter { String getLabel(BuildContext context) => context.l10n.filterBinLabel; @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(AIcons.bin, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(AIcons.bin, size: size); @override String get category => type; diff --git a/lib/model/filters/type.dart b/lib/model/filters/type.dart index 1299b484e..89e77762d 100644 --- a/lib/model/filters/type.dart +++ b/lib/model/filters/type.dart @@ -99,7 +99,7 @@ class TypeFilter extends CollectionFilter { } @override - Widget? iconBuilder(BuildContext context, double size, {bool showGenericIcon = true}) => Icon(_icon, size: size); + Widget? iconBuilder(BuildContext context, double size, {bool allowGenericIcon = true}) => Icon(_icon, size: size); @override Future color(BuildContext context) { diff --git a/lib/model/multipage.dart b/lib/model/multipage.dart index c987965b9..b8dcbb0aa 100644 --- a/lib/model/multipage.dart +++ b/lib/model/multipage.dart @@ -32,10 +32,10 @@ class MultiPageInfo { _pages.insert(0, firstPage.copyWith(isDefault: true)); } - final burstEntries = mainEntry.burstEntries; - if (burstEntries != null) { + final stackedEntries = mainEntry.stackedEntries; + if (stackedEntries != null) { _pageEntries.addEntries(pages.map((pageInfo) { - final pageEntry = burstEntries.firstWhere((entry) => entry.uri == pageInfo.uri); + final pageEntry = stackedEntries.firstWhere((entry) => entry.uri == pageInfo.uri); return MapEntry(pageInfo, pageEntry); })); } diff --git a/lib/model/settings/defaults.dart b/lib/model/settings/defaults.dart index 3efed1af6..1e341cdd4 100644 --- a/lib/model/settings/defaults.dart +++ b/lib/model/settings/defaults.dart @@ -1,6 +1,7 @@ import 'package:aves/model/filters/recent.dart'; import 'package:aves/model/naming_pattern.dart'; import 'package:aves/ref/mime_types.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; @@ -39,6 +40,7 @@ class SettingsDefaults { AlbumListPage.routeName, CountryListPage.routeName, TagListPage.routeName, + ExplorerPage.routeName, ]; // collection diff --git a/lib/model/settings/enums/home_page.dart b/lib/model/settings/enums/home_page.dart index cf33a0fc4..548e76f69 100644 --- a/lib/model/settings/enums/home_page.dart +++ b/lib/model/settings/enums/home_page.dart @@ -1,4 +1,5 @@ import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves_model/aves_model.dart'; @@ -12,6 +13,8 @@ extension ExtraHomePageSetting on HomePageSetting { return AlbumListPage.routeName; case HomePageSetting.tags: return TagListPage.routeName; + case HomePageSetting.explorer: + return ExplorerPage.routeName; } } } diff --git a/lib/model/settings/enums/widget_shape.dart b/lib/model/settings/enums/widget_shape.dart index 28a64f3ab..c38d40662 100644 --- a/lib/model/settings/enums/widget_shape.dart +++ b/lib/model/settings/enums/widget_shape.dart @@ -3,11 +3,13 @@ import 'package:aves_model/aves_model.dart'; import 'package:flutter/painting.dart'; extension ExtraWidgetShape on WidgetShape { - Path path(Size widgetSize, double devicePixelRatio) { + static const double _defaultCornerRadius = 24; + + Path path(Size widgetSize, double devicePixelRatio, {double? cornerRadiusPx}) { final rect = Offset.zero & widgetSize; switch (this) { case WidgetShape.rrect: - return Path()..addRRect(BorderRadius.circular(24 * devicePixelRatio).toRRect(rect)); + return Path()..addRRect(BorderRadius.circular(cornerRadiusPx ?? (_defaultCornerRadius * devicePixelRatio)).toRRect(rect)); case WidgetShape.circle: return Path() ..addOval(Rect.fromCircle( diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index 5e05948ff..e5a046b03 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -8,6 +8,7 @@ import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/settings/defaults.dart'; +import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/modules/app.dart'; import 'package:aves/model/settings/modules/collection.dart'; @@ -206,6 +207,8 @@ class Settings with ChangeNotifier, SettingsAccess, AppSettings, DisplaySettings AccessibilityAnimations get accessibilityAnimations => getEnumOrDefault(SettingKeys.accessibilityAnimationsKey, SettingsDefaults.accessibilityAnimations, AccessibilityAnimations.values); + bool get animate => accessibilityAnimations.animate; + set accessibilityAnimations(AccessibilityAnimations newValue) => set(SettingKeys.accessibilityAnimationsKey, newValue.toString()); AccessibilityTimeout get timeToTakeAction => getEnumOrDefault(SettingKeys.timeToTakeActionKey, SettingsDefaults.timeToTakeAction, AccessibilityTimeout.values); diff --git a/lib/model/source/collection_lens.dart b/lib/model/source/collection_lens.dart index 747e3bd1e..e6329dbd7 100644 --- a/lib/model/source/collection_lens.dart +++ b/lib/model/source/collection_lens.dart @@ -3,12 +3,14 @@ import 'dart:collection'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; +import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/entry/sort.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/trash.dart'; @@ -18,6 +20,7 @@ import 'package:aves/model/source/events.dart'; import 'package:aves/model/source/location/location.dart'; import 'package:aves/model/source/section_keys.dart'; import 'package:aves/model/source/tag.dart'; +import 'package:aves/ref/mime_types.dart'; import 'package:aves/utils/collection_utils.dart'; import 'package:aves_model/aves_model.dart'; import 'package:aves_utils/aves_utils.dart'; @@ -34,7 +37,7 @@ class CollectionLens with ChangeNotifier { final AChangeNotifier filterChangeNotifier = AChangeNotifier(), sortSectionChangeNotifier = AChangeNotifier(); final List _subscriptions = []; int? id; - bool listenToSource, groupBursts, fixedSort; + bool listenToSource, stackBursts, stackDevelopedRaws, fixedSort; List? fixedSelection; final Set _syntheticEntries = {}; @@ -47,7 +50,8 @@ class CollectionLens with ChangeNotifier { Set? filters, this.id, this.listenToSource = true, - this.groupBursts = true, + this.stackBursts = true, + this.stackDevelopedRaws = true, this.fixedSort = false, this.fixedSelection, }) : filters = (filters ?? {}).whereNotNull().toSet(), @@ -192,30 +196,59 @@ class CollectionLens with ChangeNotifier { _disposeSyntheticEntries(); _filteredSortedEntries = List.of(filters.isEmpty ? entries : entries.where((entry) => filters.every((filter) => filter.test(entry)))); - if (groupBursts) { - _groupBursts(); + if (stackBursts) { + _stackBursts(); + } + if (stackDevelopedRaws) { + _stackDevelopedRaws(); } } - void _groupBursts() { + void _stackBursts() { final byBurstKey = groupBy(_filteredSortedEntries, (entry) => entry.getBurstKey(burstPatterns)).whereNotNullKey(); byBurstKey.forEach((burstKey, entries) { if (entries.length > 1) { entries.sort(AvesEntrySort.compareByName); final mainEntry = entries.first; - final burstEntry = mainEntry.copyWith(burstEntries: entries); - _syntheticEntries.add(burstEntry); + final stackEntry = mainEntry.copyWith(stackedEntries: entries); + _syntheticEntries.add(stackEntry); - entries.skip(1).toList().forEach((subEntry) { + entries.skip(1).forEach((subEntry) { _filteredSortedEntries.remove(subEntry); }); final index = _filteredSortedEntries.indexOf(mainEntry); _filteredSortedEntries.removeAt(index); - _filteredSortedEntries.insert(index, burstEntry); + _filteredSortedEntries.insert(index, stackEntry); } }); } + void _stackDevelopedRaws() { + final allRawEntries = _filteredSortedEntries.where((entry) => entry.isRaw).toSet(); + if (allRawEntries.isNotEmpty) { + final allDevelopedEntries = _filteredSortedEntries.where(MimeFilter(MimeTypes.jpeg).test).toSet(); + final rawEntriesByDir = groupBy(allRawEntries, (entry) => entry.directory); + rawEntriesByDir.forEach((dir, dirRawEntries) { + if (dir != null) { + final dirDevelopedEntries = allDevelopedEntries.where((entry) => entry.directory == dir).toSet(); + for (final rawEntry in dirRawEntries) { + final rawFilename = rawEntry.filenameWithoutExtension; + final developedEntry = dirDevelopedEntries.firstWhereOrNull((entry) => entry.filenameWithoutExtension == rawFilename); + if (developedEntry != null) { + final stackEntry = rawEntry.copyWith(stackedEntries: [rawEntry, developedEntry]); + _syntheticEntries.add(stackEntry); + + _filteredSortedEntries.remove(developedEntry); + final index = _filteredSortedEntries.indexOf(rawEntry); + _filteredSortedEntries.removeAt(index); + _filteredSortedEntries.insert(0, stackEntry); + } + } + } + }); + } + } + void _applySort() { if (fixedSort) return; @@ -322,23 +355,52 @@ class CollectionLens with ChangeNotifier { } void _onEntryRemoved(Set entries) { - if (groupBursts) { - // find impacted burst groups - final obsoleteBurstEntries = {}; - final burstKeys = entries.map((entry) => entry.getBurstKey(burstPatterns)).whereNotNull().toSet(); - if (burstKeys.isNotEmpty) { - _filteredSortedEntries.where((entry) => entry.isBurst && burstKeys.contains(entry.getBurstKey(burstPatterns))).forEach((mainEntry) { - final subEntries = mainEntry.burstEntries!; + if (_syntheticEntries.isNotEmpty) { + // find impacted stacks + final obsoleteStacks = {}; + + void _replaceStack(AvesEntry stackEntry, AvesEntry entry) { + obsoleteStacks.add(stackEntry); + fixedSelection?.replace(stackEntry, entry); + _filteredSortedEntries.replace(stackEntry, entry); + _sortedEntries?.replace(stackEntry, entry); + sections.forEach((key, sectionEntries) => sectionEntries.replace(stackEntry, entry)); + } + + final stacks = _filteredSortedEntries.where((entry) => entry.isStack).toSet(); + stacks.forEach((stackEntry) { + final subEntries = stackEntry.stackedEntries!; + if (subEntries.any(entries.contains)) { + final mainEntry = subEntries.first; + // remove the deleted sub-entries subEntries.removeWhere(entries.contains); - if (subEntries.isEmpty) { - // remove the burst entry itself - obsoleteBurstEntries.add(mainEntry); + + switch (subEntries.length) { + case 0: + // remove the stack itself + obsoleteStacks.add(stackEntry); + break; + case 1: + // replace the stack by the last remaining sub-entry + _replaceStack(stackEntry, subEntries.first); + break; + default: + // keep the stack with the remaining sub-entries + if (!subEntries.contains(mainEntry)) { + // recreate the stack with the correct main entry + _replaceStack(stackEntry, subEntries.first.copyWith(stackedEntries: subEntries)); + } + break; } - // TODO TLAD [burst] recreate the burst main entry if the first sub-entry got deleted - }); - entries.addAll(obsoleteBurstEntries); - } + } + }); + + obsoleteStacks.forEach((stackEntry) { + _syntheticEntries.remove(stackEntry); + stackEntry.dispose(); + }); + entries.addAll(obsoleteStacks); } // we should remove obsolete entries and sections diff --git a/lib/ref/mime_types.dart b/lib/ref/mime_types.dart index 8d2b7ea6c..4c2619539 100644 --- a/lib/ref/mime_types.dart +++ b/lib/ref/mime_types.dart @@ -134,4 +134,15 @@ class MimeTypes { } return null; } + + static const Map _defaultExtensions = { + bmp: '.bmp', + gif: '.gif', + jpeg: '.jpg', + png: '.png', + svg: '.svg', + webp: '.webp', + }; + + static String? extensionFor(String mimeType) => _defaultExtensions[mimeType]; } diff --git a/lib/services/intent_service.dart b/lib/services/intent_service.dart index 47d46e691..51f18940a 100644 --- a/lib/services/intent_service.dart +++ b/lib/services/intent_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/services/app_service.dart'; import 'package:aves/services/common/services.dart'; import 'package:collection/collection.dart'; import 'package:flutter/services.dart'; @@ -27,7 +28,11 @@ class IntentService { 'uris': uris, }); } on PlatformException catch (e, stack) { - await reportService.recordError(e, stack); + if (e.code == 'submitPickedItems-large') { + throw TooManyItemsException(); + } else { + await reportService.recordError(e, stack); + } } } diff --git a/lib/services/media/media_edit_service.dart b/lib/services/media/media_edit_service.dart index 927c79e72..fd3810e97 100644 --- a/lib/services/media/media_edit_service.dart +++ b/lib/services/media/media_edit_service.dart @@ -193,15 +193,17 @@ class PlatformMediaEditService implements MediaEditService { @immutable class EntryConvertOptions extends Equatable { + final EntryConvertAction action; final String mimeType; final bool writeMetadata; final LengthUnit lengthUnit; final int width, height, quality; @override - List get props => [mimeType, writeMetadata, lengthUnit, width, height, quality]; + List get props => [action, mimeType, writeMetadata, lengthUnit, width, height, quality]; const EntryConvertOptions({ + required this.action, required this.mimeType, required this.writeMetadata, required this.lengthUnit, diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 6bf1b681d..78ac5cf97 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -33,6 +33,8 @@ abstract class StorageService { Future deleteTempDirectory(); + Future deleteExternalCache(); + // returns whether user granted access to a directory of his choosing Future requestDirectoryAccess(String path); @@ -202,6 +204,17 @@ class PlatformStorageService implements StorageService { return false; } + @override + Future deleteExternalCache() async { + try { + final result = await _platform.invokeMethod('deleteExternalCache'); + if (result != null) return result as bool; + } on PlatformException catch (e, stack) { + await reportService.recordError(e, stack); + } + return false; + } + @override Future canRequestMediaFileBulkAccess() async { try { diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 9ba4ab37c..5bd83f525 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -60,7 +60,7 @@ class ADurations { static const highlightJumpDelay = Duration(milliseconds: 400); static const highlightScrollInitDelay = Duration(milliseconds: 800); static const motionPhotoAutoPlayDelay = Duration(milliseconds: 700); - static const videoPauseAppInactiveDelay = Duration(milliseconds: 300); + static const appInactiveReactionDelay = Duration(milliseconds: 300); static const videoOverlayHideDelay = Duration(milliseconds: 500); static const videoProgressTimerInterval = Duration(milliseconds: 300); static const doubleBackTimerDelay = Duration(milliseconds: 1000); diff --git a/lib/theme/icons.dart b/lib/theme/icons.dart index df80978eb..1aa37db63 100644 --- a/lib/theme/icons.dart +++ b/lib/theme/icons.dart @@ -29,8 +29,10 @@ class AIcons { static const disc = Icons.fiber_manual_record; static const display = Icons.light_mode_outlined; static const error = Icons.error_outline; + static const explorer = Icons.account_tree_outlined; static const folder = Icons.folder_outlined; - static const grid = Icons.grid_on_outlined; + static const geoBounds = Icons.public_outlined; + static final github = MdiIcons.github; static const home = Icons.home_outlined; // as of Flutter v3.16.3, @@ -39,13 +41,15 @@ class AIcons { static const important = IconData(labelImportantOutlineCodePoint, fontFamily: materialIconsFontFamily, matchTextDirection: true); static const language = Icons.translate_outlined; + static final legal = MdiIcons.scaleBalance; static const location = Icons.place_outlined; static const locationUnlocated = Icons.location_off_outlined; static const country = Icons.flag_outlined; static const state = Icons.flag_outlined; static const place = Icons.place_outlined; - static const mainStorage = Icons.smartphone_outlined; static const mimeType = Icons.code_outlined; + static const name = Icons.abc_outlined; + static const newTier = Icons.fiber_new_outlined; static const opacity = Icons.opacity; static const palette = Icons.palette_outlined; static final privacy = MdiIcons.shieldAccountOutline; @@ -54,15 +58,20 @@ class AIcons { static final ratingRejected = MdiIcons.starMinusOutline; static final ratingUnrated = MdiIcons.starOffOutline; static const raw = Icons.raw_on_outlined; - static const shooting = Icons.camera_outlined; - static const removableStorage = Icons.sd_storage_outlined; static const sensorControlEnabled = Icons.explore_outlined; static const sensorControlDisabled = Icons.explore_off_outlined; static const settings = Icons.settings_outlined; + static const shooting = Icons.camera_outlined; static const size = Icons.data_usage_outlined; - static const text = Icons.format_quote_outlined; + static const storageCard = Icons.sd_storage_outlined; + static const storageMain = Icons.smartphone_outlined; + static const streamVideo = Icons.movie_outlined; + static const streamAudio = Icons.audiotrack_outlined; + static const streamText = Icons.closed_caption_outlined; static const tag = Icons.local_offer_outlined; static final tagUntagged = MdiIcons.tagOffOutline; + static const text = Icons.format_quote_outlined; + static const thumbnails = Icons.grid_on_outlined; static const volumeMin = Icons.volume_mute_outlined; static const volumeMax = Icons.volume_up_outlined; @@ -100,7 +109,6 @@ class AIcons { static const favouriteActive = Icons.favorite; static final filter = MdiIcons.filterOutline; static final filterOff = MdiIcons.filterOffOutline; - static const geoBounds = Icons.public_outlined; static const goUp = Icons.arrow_upward_outlined; static const hide = Icons.visibility_off_outlined; static const info = Icons.info_outlined; @@ -110,8 +118,7 @@ class AIcons { static final move = MdiIcons.fileMoveOutline; static const mute = Icons.volume_off_outlined; static const unmute = Icons.volume_up_outlined; - static const name = Icons.abc_outlined; - static const newTier = Icons.fiber_new_outlined; + static const rename = Icons.abc_outlined; static const openOutside = Icons.open_in_new_outlined; static final openVideo = MdiIcons.moviePlayOutline; static const pin = Icons.push_pin_outlined; @@ -133,20 +140,17 @@ class AIcons { static const rotateScreen = Icons.screen_rotation_outlined; static const search = Icons.search_outlined; static const select = Icons.select_all_outlined; + static const selectStreams = Icons.translate_outlined; static const setAs = Icons.wallpaper_outlined; + static final setBoundEnd = MdiIcons.rayEnd; + static final setBoundStart = MdiIcons.rayStart; static final setCover = MdiIcons.imageEditOutline; - static final setEnd = MdiIcons.rayEnd; - static final setStart = MdiIcons.rayStart; static const share = Icons.share_outlined; static const show = Icons.visibility_outlined; static final showFullscreen = MdiIcons.arrowExpand; static const slideshow = Icons.slideshow_outlined; static const speed = Icons.speed_outlined; static const stats = Icons.donut_small_outlined; - static const streams = Icons.translate_outlined; - static const streamVideo = Icons.movie_outlined; - static const streamAudio = Icons.audiotrack_outlined; - static const streamText = Icons.closed_caption_outlined; static const vaultLock = Icons.lock_outline; static const vaultAdd = Icons.enhanced_encryption_outlined; static final vaultConfigure = MdiIcons.shieldLockOutline; @@ -190,9 +194,6 @@ class AIcons { static const selected = Icons.check_circle_outline; static const unselected = Icons.radio_button_unchecked; - static final github = MdiIcons.github; - static final legal = MdiIcons.scaleBalance; - // Material Icons references to make constant instances of `IconData` // as non-constant instances of `IconData` prevent icon font tree shaking static const labelImportantOutlineCodePoint = 0xe362; diff --git a/lib/utils/collection_utils.dart b/lib/utils/collection_utils.dart index d464e1429..88c7b2ca1 100644 --- a/lib/utils/collection_utils.dart +++ b/lib/utils/collection_utils.dart @@ -1,5 +1,15 @@ import 'package:collection/collection.dart'; +extension ExtraList on List { + bool replace(E old, E newItem) { + final index = indexOf(old); + if (index == -1) return false; + + this[index] = newItem; + return true; + } +} + extension ExtraMapNullableKey on Map { Map whereNotNullKey() => {for (var v in keys.whereNotNull()) v: this[v] as V}; } diff --git a/lib/view/src/actions/chip.dart b/lib/view/src/actions/chip.dart index 6c6dcc6cc..75b0f5ed6 100644 --- a/lib/view/src/actions/chip.dart +++ b/lib/view/src/actions/chip.dart @@ -11,6 +11,7 @@ extension ExtraChipActionView on ChipAction { ChipAction.goToCountryPage => l10n.chipActionGoToCountryPage, ChipAction.goToPlacePage => l10n.chipActionGoToPlacePage, ChipAction.goToTagPage => l10n.chipActionGoToTagPage, + ChipAction.goToExplorerPage => l10n.chipActionGoToExplorerPage, ChipAction.ratingOrGreater || ChipAction.ratingOrLower => // different data depending on state @@ -30,6 +31,7 @@ extension ExtraChipActionView on ChipAction { ChipAction.goToCountryPage => AIcons.country, ChipAction.goToPlacePage => AIcons.place, ChipAction.goToTagPage => AIcons.tag, + ChipAction.goToExplorerPage => AIcons.explorer, ChipAction.ratingOrGreater || ChipAction.ratingOrLower => AIcons.rating, ChipAction.reverse => AIcons.reverse, ChipAction.hide => AIcons.hide, diff --git a/lib/view/src/actions/chip_set.dart b/lib/view/src/actions/chip_set.dart index bf2ab7768..0e95ccfbc 100644 --- a/lib/view/src/actions/chip_set.dart +++ b/lib/view/src/actions/chip_set.dart @@ -67,7 +67,7 @@ extension ExtraChipSetActionView on ChipSetAction { ChipSetAction.showCountryStates => AIcons.state, ChipSetAction.showCollection => AIcons.allCollection, // selecting (single filter) - ChipSetAction.rename => AIcons.name, + ChipSetAction.rename => AIcons.rename, ChipSetAction.setCover => AIcons.setCover, ChipSetAction.configureVault => AIcons.vaultConfigure, }; diff --git a/lib/view/src/actions/entry.dart b/lib/view/src/actions/entry.dart index 465644f2f..1c6809511 100644 --- a/lib/view/src/actions/entry.dart +++ b/lib/view/src/actions/entry.dart @@ -90,7 +90,7 @@ extension ExtraEntryActionView on EntryAction { EntryAction.restore => AIcons.restore, EntryAction.convert => AIcons.convert, EntryAction.print => AIcons.print, - EntryAction.rename => AIcons.name, + EntryAction.rename => AIcons.rename, EntryAction.copy => AIcons.copy, EntryAction.move => AIcons.move, EntryAction.share => AIcons.share, @@ -109,7 +109,7 @@ extension ExtraEntryActionView on EntryAction { EntryAction.videoToggleMute => // different data depending on toggle state AIcons.mute, - EntryAction.videoSelectStreams => AIcons.streams, + EntryAction.videoSelectStreams => AIcons.selectStreams, EntryAction.videoSetSpeed => AIcons.speed, EntryAction.videoABRepeat => AIcons.repeat, EntryAction.videoSettings => AIcons.videoSettings, diff --git a/lib/view/src/actions/entry_set.dart b/lib/view/src/actions/entry_set.dart index 06313a94e..d9105e0e2 100644 --- a/lib/view/src/actions/entry_set.dart +++ b/lib/view/src/actions/entry_set.dart @@ -5,45 +5,46 @@ import 'package:flutter/material.dart'; extension ExtraEntrySetActionView on EntrySetAction { String getText(BuildContext context) { + final l10n = context.l10n; return switch (this) { // general - EntrySetAction.configureView => context.l10n.menuActionConfigureView, - EntrySetAction.select => context.l10n.menuActionSelect, - EntrySetAction.selectAll => context.l10n.menuActionSelectAll, - EntrySetAction.selectNone => context.l10n.menuActionSelectNone, + EntrySetAction.configureView => l10n.menuActionConfigureView, + EntrySetAction.select => l10n.menuActionSelect, + EntrySetAction.selectAll => l10n.menuActionSelectAll, + EntrySetAction.selectNone => l10n.menuActionSelectNone, // browsing EntrySetAction.searchCollection => MaterialLocalizations.of(context).searchFieldLabel, EntrySetAction.toggleTitleSearch => // different data depending on toggle state - context.l10n.collectionActionShowTitleSearch, - EntrySetAction.addShortcut => context.l10n.collectionActionAddShortcut, - EntrySetAction.setHome => context.l10n.collectionActionSetHome, - EntrySetAction.emptyBin => context.l10n.collectionActionEmptyBin, + l10n.collectionActionShowTitleSearch, + EntrySetAction.addShortcut => l10n.collectionActionAddShortcut, + EntrySetAction.setHome => l10n.collectionActionSetHome, + EntrySetAction.emptyBin => l10n.collectionActionEmptyBin, // browsing or selecting - EntrySetAction.map => context.l10n.menuActionMap, - EntrySetAction.slideshow => context.l10n.menuActionSlideshow, - EntrySetAction.stats => context.l10n.menuActionStats, - EntrySetAction.rescan => context.l10n.collectionActionRescan, + EntrySetAction.map => l10n.menuActionMap, + EntrySetAction.slideshow => l10n.menuActionSlideshow, + EntrySetAction.stats => l10n.menuActionStats, + EntrySetAction.rescan => l10n.collectionActionRescan, // selecting - EntrySetAction.share => context.l10n.entryActionShare, - EntrySetAction.delete => context.l10n.entryActionDelete, - EntrySetAction.restore => context.l10n.entryActionRestore, - EntrySetAction.copy => context.l10n.collectionActionCopy, - EntrySetAction.move => context.l10n.collectionActionMove, - EntrySetAction.rename => context.l10n.entryActionRename, - EntrySetAction.convert => context.l10n.entryActionConvert, + EntrySetAction.share => l10n.entryActionShare, + EntrySetAction.delete => l10n.entryActionDelete, + EntrySetAction.restore => l10n.entryActionRestore, + EntrySetAction.copy => l10n.collectionActionCopy, + EntrySetAction.move => l10n.collectionActionMove, + EntrySetAction.rename => l10n.entryActionRename, + EntrySetAction.convert => l10n.entryActionConvert, EntrySetAction.toggleFavourite => // different data depending on toggle state - context.l10n.entryActionAddFavourite, - EntrySetAction.rotateCCW => context.l10n.entryActionRotateCCW, - EntrySetAction.rotateCW => context.l10n.entryActionRotateCW, - EntrySetAction.flip => context.l10n.entryActionFlip, - EntrySetAction.editDate => context.l10n.entryInfoActionEditDate, - EntrySetAction.editLocation => context.l10n.entryInfoActionEditLocation, - EntrySetAction.editTitleDescription => context.l10n.entryInfoActionEditTitleDescription, - EntrySetAction.editRating => context.l10n.entryInfoActionEditRating, - EntrySetAction.editTags => context.l10n.entryInfoActionEditTags, - EntrySetAction.removeMetadata => context.l10n.entryInfoActionRemoveMetadata, + l10n.entryActionAddFavourite, + EntrySetAction.rotateCCW => l10n.entryActionRotateCCW, + EntrySetAction.rotateCW => l10n.entryActionRotateCW, + EntrySetAction.flip => l10n.entryActionFlip, + EntrySetAction.editDate => l10n.entryInfoActionEditDate, + EntrySetAction.editLocation => l10n.entryInfoActionEditLocation, + EntrySetAction.editTitleDescription => l10n.entryInfoActionEditTitleDescription, + EntrySetAction.editRating => l10n.entryInfoActionEditRating, + EntrySetAction.editTags => l10n.entryInfoActionEditTags, + EntrySetAction.removeMetadata => l10n.entryInfoActionRemoveMetadata, }; } @@ -75,7 +76,7 @@ extension ExtraEntrySetActionView on EntrySetAction { EntrySetAction.restore => AIcons.restore, EntrySetAction.copy => AIcons.copy, EntrySetAction.move => AIcons.move, - EntrySetAction.rename => AIcons.name, + EntrySetAction.rename => AIcons.rename, EntrySetAction.convert => AIcons.convert, EntrySetAction.toggleFavourite => // different data depending on toggle state diff --git a/lib/view/src/metadata/convert_action.dart b/lib/view/src/metadata/convert_action.dart new file mode 100644 index 000000000..54f9f9872 --- /dev/null +++ b/lib/view/src/metadata/convert_action.dart @@ -0,0 +1,21 @@ +import 'package:aves/theme/icons.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/widgets.dart'; + +extension ExtraEntryConvertActionView on EntryConvertAction { + String getText(BuildContext context) { + final l10n = context.l10n; + return switch (this) { + EntryConvertAction.convert => l10n.entryActionConvert, + EntryConvertAction.convertMotionPhotoToStillImage => l10n.entryActionConvertMotionPhotoToStillImage, + }; + } + + IconData getIconData() { + return switch (this) { + EntryConvertAction.convert => AIcons.convert, + EntryConvertAction.convertMotionPhotoToStillImage => AIcons.convertToStillImage, + }; + } +} diff --git a/lib/view/src/settings/enums.dart b/lib/view/src/settings/enums.dart index 7e4277958..064c9619b 100644 --- a/lib/view/src/settings/enums.dart +++ b/lib/view/src/settings/enums.dart @@ -83,6 +83,7 @@ extension ExtraHomePageSettingView on HomePageSetting { HomePageSetting.collection => l10n.drawerCollectionAll, HomePageSetting.albums => l10n.drawerAlbumPage, HomePageSetting.tags => l10n.drawerTagPage, + HomePageSetting.explorer => l10n.explorerPageTitle, }; } } diff --git a/lib/view/src/source/group.dart b/lib/view/src/source/group.dart index 45e04767c..d9abaf867 100644 --- a/lib/view/src/source/group.dart +++ b/lib/view/src/source/group.dart @@ -39,7 +39,7 @@ extension ExtraAlbumChipGroupFactorView on AlbumChipGroupFactor { return switch (this) { AlbumChipGroupFactor.importance => AIcons.important, AlbumChipGroupFactor.mimeType => AIcons.mimeType, - AlbumChipGroupFactor.volume => AIcons.removableStorage, + AlbumChipGroupFactor.volume => AIcons.storageCard, AlbumChipGroupFactor.none => AIcons.clear, }; } diff --git a/lib/view/view.dart b/lib/view/view.dart index fd3cfd0ff..097448a81 100644 --- a/lib/view/view.dart +++ b/lib/view/view.dart @@ -7,6 +7,7 @@ export 'src/actions/map_cluster.dart'; export 'src/actions/share.dart'; export 'src/actions/slideshow.dart'; export 'src/editor/enums.dart'; +export 'src/metadata/convert_action.dart'; export 'src/metadata/date_edit_action.dart'; export 'src/metadata/date_field_source.dart'; export 'src/metadata/fields.dart'; diff --git a/lib/widget_common.dart b/lib/widget_common.dart index fd20ac7eb..34a8067cb 100644 --- a/lib/widget_common.dart +++ b/lib/widget_common.dart @@ -38,8 +38,10 @@ void widgetMainCommon(AppFlavor flavor) async { Future> _drawWidget(dynamic args) async { final widgetId = args['widgetId'] as int; - final widthPx = args['widthPx'] as int; - final heightPx = args['heightPx'] as int; + final sizesDip = (args['sizesDip'] as List).cast().map((kv) { + return Size(kv['widthDip'] as double, kv['heightDip'] as double); + }).toList(); + final cornerRadiusPx = args['cornerRadiusPx'] as double?; final devicePixelRatio = args['devicePixelRatio'] as double; final drawEntryImage = args['drawEntryImage'] as bool; final reuseEntry = args['reuseEntry'] as bool; @@ -53,14 +55,22 @@ Future> _drawWidget(dynamic args) async { entry: entry, devicePixelRatio: devicePixelRatio, ); - final bytes = await painter.drawWidget( - widthPx: widthPx, - heightPx: heightPx, - outline: outline, - shape: settings.getWidgetShape(widgetId), - ); + final bytesBySizeDip = >[]; + await Future.forEach(sizesDip, (sizeDip) async { + final bytes = await painter.drawWidget( + sizeDip: sizeDip, + cornerRadiusPx: cornerRadiusPx, + outline: outline, + shape: settings.getWidgetShape(widgetId), + ); + bytesBySizeDip.add({ + 'widthDip': sizeDip.width, + 'heightDip': sizeDip.height, + 'bytes': bytes, + }); + }); return { - 'bytes': bytes, + 'bytesBySizeDip': bytesBySizeDip, 'updateOnTap': settings.getWidgetOpenPage(widgetId) == WidgetOpenPage.updateWidget, }; } diff --git a/lib/widgets/about/data_usage.dart b/lib/widgets/about/data_usage.dart index 39a523762..9ab3dd2b7 100644 --- a/lib/widgets/about/data_usage.dart +++ b/lib/widgets/about/data_usage.dart @@ -87,6 +87,7 @@ class _AboutDataUsageState extends State with FeedbackMixin { label: context.l10n.aboutDataUsageClearCache, onPressed: () async { await storageService.deleteTempDirectory(); + await storageService.deleteExternalCache(); await mediaFetchService.clearSizedThumbnailDiskCache(); imageCache.clear(); _reload(); diff --git a/lib/widgets/about/tv_license_page.dart b/lib/widgets/about/tv_license_page.dart index e40b67d26..7efb673ee 100644 --- a/lib/widgets/about/tv_license_page.dart +++ b/lib/widgets/about/tv_license_page.dart @@ -233,13 +233,13 @@ class _PackageLicensePageState extends State<_PackageLicensePage> { if (!mounted) return; setState(() { _licenses.add(const Padding( - padding: EdgeInsets.all(18.0), + padding: EdgeInsets.all(18), child: Divider(), )); for (final LicenseParagraph paragraph in paragraphs) { if (paragraph.indent == LicenseParagraph.centeredIndent) { _licenses.add(Padding( - padding: const EdgeInsets.only(top: 16.0), + padding: const EdgeInsets.only(top: 16), child: Text( paragraph.text, style: const TextStyle(fontWeight: FontWeight.bold), @@ -249,7 +249,7 @@ class _PackageLicensePageState extends State<_PackageLicensePage> { } else { assert(paragraph.indent >= 0); _licenses.add(Padding( - padding: EdgeInsetsDirectional.only(top: 8.0, start: 16.0 * paragraph.indent), + padding: EdgeInsetsDirectional.only(top: 8, start: 16.0 * paragraph.indent), child: Text(paragraph.text), )); } @@ -278,7 +278,7 @@ class _PackageLicensePageState extends State<_PackageLicensePage> { ..._licenses, if (!_loaded) const Padding( - padding: EdgeInsets.symmetric(vertical: 24.0), + padding: EdgeInsets.symmetric(vertical: 24), child: Center( child: CircularProgressIndicator(), ), diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 9b1ac3a71..6ee414f49 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -8,7 +8,6 @@ import 'package:aves/model/apps.dart'; import 'package:aves/model/device.dart'; import 'package:aves/model/filters/recent.dart'; import 'package:aves/model/settings/defaults.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/display_refresh_rate_mode.dart'; import 'package:aves/model/settings/enums/screen_on.dart'; import 'package:aves/model/settings/enums/theme_brightness.dart'; @@ -325,7 +324,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addPostFrameCallback((_) => AvesApp.setSystemUIStyle(Theme.of(context))); } return Selector( - selector: (context, s) => s.initialized ? s.accessibilityAnimations.animate : true, + selector: (context, s) => s.initialized ? s.animate : true, builder: (context, areAnimationsEnabled, child) { return FutureBuilder( future: _shouldUseBoldFontLoader, @@ -668,7 +667,7 @@ class _AvesAppState extends State with WidgetsBindingObserver { class AvesScrollBehavior extends MaterialScrollBehavior { @override Widget buildOverscrollIndicator(BuildContext context, Widget child, ScrollableDetails details) { - final animate = context.select((v) => v.accessibilityAnimations.animate); + final animate = context.select((v) => v.animate); return animate ? StretchingOverscrollIndicator( axisDirection: details.direction, diff --git a/lib/widgets/collection/app_bar.dart b/lib/widgets/collection/app_bar.dart index 30b19b9d4..3efb2070f 100644 --- a/lib/widgets/collection/app_bar.dart +++ b/lib/widgets/collection/app_bar.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; @@ -171,7 +172,6 @@ class _CollectionAppBarState extends State with SingleTickerPr selector: (context, s) => s.collectionBrowsingQuickActions, builder: (context, _, child) { final useTvLayout = settings.useTvLayout; - final actions = _buildActions(context, selection); final onFilterTap = canRemoveFilters ? collection.removeFilter : null; return AvesAppBar( contentHeight: appBarContentHeight, @@ -181,7 +181,7 @@ class _CollectionAppBarState extends State with SingleTickerPr isSelecting: isSelecting, ), title: _buildAppBarTitle(isSelecting), - actions: useTvLayout ? [] : actions, + actions: (context, maxWidth) => useTvLayout ? [] : _buildActions(context, selection, maxWidth), bottom: Column( children: [ if (useTvLayout) @@ -190,7 +190,7 @@ class _CollectionAppBarState extends State with SingleTickerPr child: ListView( padding: const EdgeInsets.symmetric(horizontal: 8), scrollDirection: Axis.horizontal, - children: actions, + children: _buildActions(context, selection, double.infinity), ), ), if (showFilterBar) @@ -301,7 +301,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } } - List _buildActions(BuildContext context, Selection selection) { + List _buildActions(BuildContext context, Selection selection, double maxWidth) { final appMode = context.watch>().value; final isSelecting = selection.isSelecting; final selectedItemCount = selection.selectedItems.length; @@ -333,6 +333,7 @@ class _CollectionAppBarState extends State with SingleTickerPr context: context, appMode: appMode, selection: selection, + maxWidth: maxWidth, isVisible: isVisible, canApply: canApply, ); @@ -366,20 +367,29 @@ class _CollectionAppBarState extends State with SingleTickerPr }).toList(); } + static double _iconButtonWidth(BuildContext context) { + const defaultPadding = EdgeInsets.all(8); + const defaultIconSize = 24.0; + return defaultPadding.horizontal + MediaQuery.textScalerOf(context).scale(defaultIconSize); + } + List _buildMobileActions({ required BuildContext context, required AppMode appMode, required Selection selection, + required double maxWidth, required bool Function(EntrySetAction action) isVisible, required bool Function(EntrySetAction action) canApply, }) { + final availableCount = (maxWidth / _iconButtonWidth(context)).floor(); + final isSelecting = selection.isSelecting; final selectedItemCount = selection.selectedItems.length; final hasSelection = selectedItemCount > 0; final browsingQuickActions = settings.collectionBrowsingQuickActions; final selectionQuickActions = isTrash ? [EntrySetAction.delete, EntrySetAction.restore] : settings.collectionSelectionQuickActions; - final quickActions = isSelecting ? selectionQuickActions : browsingQuickActions; + final quickActions = (isSelecting ? selectionQuickActions : browsingQuickActions).take(max(0, availableCount - 1)).toList(); final quickActionButtons = quickActions.where(isVisible).map( (action) => _buildButtonIcon(context, action, enabled: canApply(action), selection: selection), ); @@ -396,7 +406,7 @@ class _CollectionAppBarState extends State with SingleTickerPr (action) => _toMenuItem(action, enabled: canApply(action), selection: selection), ); - final allContextualActions = isSelecting ? EntrySetActions.pageSelection: EntrySetActions.pageBrowsing; + final allContextualActions = isSelecting ? EntrySetActions.pageSelection : EntrySetActions.pageBrowsing; final contextualMenuActions = allContextualActions.where(_isValidForMenu).fold([], (prev, v) { if (v == null && (prev.isEmpty || prev.last == null)) return prev; return [...prev, v]; @@ -444,7 +454,7 @@ class _CollectionAppBarState extends State with SingleTickerPr } Set _getExpandedSelectedItems(Selection selection) { - return selection.selectedItems.expand((entry) => entry.burstEntries ?? {entry}).toSet(); + return selection.selectedItems.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); } // key is expected by test driver (e.g. 'menu-configureView', 'menu-map') diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index e2b263cb2..5a6812e99 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -7,10 +7,10 @@ import 'package:aves/model/filters/query.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/selection.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.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/services/app_service.dart'; import 'package:aves/services/intent_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; @@ -24,6 +24,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_fab.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/selection_provider.dart'; +import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/navigation/drawer/app_drawer.dart'; import 'package:aves/widgets/navigation/nav_bar/nav_bar.dart'; import 'package:aves/widgets/navigation/tv_rail.dart'; @@ -186,10 +187,21 @@ class _CollectionPageState extends State { return hasSelection ? AvesFab( tooltip: context.l10n.pickTooltip, - onPressed: () { + onPressed: () async { final items = context.read>().selectedItems; final uris = items.map((entry) => entry.uri).toList(); - IntentService.submitPickedItems(uris); + try { + await IntentService.submitPickedItems(uris); + } on TooManyItemsException catch (_) { + await showDialog( + context: context, + builder: (context) => AvesDialog( + content: Text(context.l10n.tooManyItemsErrorDialogMessage), + actions: const [OkButton()], + ), + routeSettings: const RouteSettings(name: AvesDialog.warningRouteName), + ); + } }, ) : null; @@ -217,7 +229,7 @@ class _CollectionPageState extends State { await Future.delayed(delayDuration + ADurations.highlightScrollInitDelay); if (!mounted) return; - final animate = context.read().accessibilityAnimations.animate; + final animate = context.read().animate; context.read().trackItem(item, animate: animate, highlightItem: item); } } diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 23f444808..db0504ad1 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -5,6 +5,7 @@ import 'package:aves/model/device.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/favourites.dart'; import 'package:aves/model/entry/extensions/metadata_edition.dart'; +import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/favourites.dart'; import 'package:aves/model/filters/filters.dart'; @@ -20,6 +21,7 @@ import 'package:aves/model/vaults/vaults.dart'; import 'package:aves/services/app_service.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; +import 'package:aves/services/media/media_edit_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/themes.dart'; import 'package:aves/utils/collection_utils.dart'; @@ -34,6 +36,7 @@ import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/dialogs/add_shortcut_dialog.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/convert_entry_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_set_page.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/location_pick_page.dart'; import 'package:aves/widgets/map/map_page.dart'; @@ -237,7 +240,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware Set _getTargetItems(BuildContext context) { final selection = context.read>(); final groupedEntries = (selection.isSelecting ? selection.selectedItems : context.read().sortedEntries); - return groupedEntries.expand((entry) => entry.burstEntries ?? {entry}).toSet(); + return groupedEntries.expand((entry) => entry.stackedEntries ?? {entry}).toSet(); } Future _share(BuildContext context) async { @@ -366,9 +369,23 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware _browse(context); } - void _convert(BuildContext context) { + Future _convert(BuildContext context) async { final entries = _getTargetItems(context); - convert(context, entries); + + final options = await showDialog( + context: context, + builder: (context) => ConvertEntryDialog(entries: entries), + routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName), + ); + if (options == null) return; + + switch (options.action) { + case EntryConvertAction.convert: + await doExport(context, entries, options); + case EntryConvertAction.convertMotionPhotoToStillImage: + final todoItems = entries.where((entry) => entry.isMotionPhoto).toSet(); + await _edit(context, todoItems, (entry) => entry.removeTrailerVideo()); + } _browse(context); } diff --git a/lib/widgets/collection/grid/headers/album.dart b/lib/widgets/collection/grid/headers/album.dart index d73692831..1a9e43d6e 100644 --- a/lib/widgets/collection/grid/headers/album.dart +++ b/lib/widgets/collection/grid/headers/album.dart @@ -39,7 +39,7 @@ class AlbumSectionHeader extends StatelessWidget { title: albumName ?? context.l10n.sectionUnknown, trailing: _directory != null && androidFileUtils.isOnRemovableStorage(_directory) ? const Icon( - AIcons.removableStorage, + AIcons.storageCard, size: 16, color: Color(0xFF757575), ) diff --git a/lib/widgets/collection/grid/list_details.dart b/lib/widgets/collection/grid/list_details.dart index 898cc9285..0c30a73e3 100644 --- a/lib/widgets/collection/grid/list_details.dart +++ b/lib/widgets/collection/grid/list_details.dart @@ -80,7 +80,7 @@ class EntryListDetails extends StatelessWidget { final date = entry.bestDate; final dateText = date != null ? formatDateTime(date, locale, use24hour) : AText.valueNotAvailable; - final size = entry.burstEntries?.map((v) => v.sizeBytes).sum ?? entry.sizeBytes; + final size = entry.stackedEntries?.map((v) => v.sizeBytes).sum ?? entry.sizeBytes; final sizeText = size != null ? formatFileSize(locale, size) : AText.valueNotAvailable; return Wrap( diff --git a/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart b/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart index 73e87d8dc..f1b5b7ee5 100644 --- a/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart +++ b/lib/widgets/common/action_controls/quick_choosers/album_chooser.dart @@ -35,7 +35,7 @@ class AlbumQuickChooser extends StatelessWidget { pointerGlobalPosition: pointerGlobalPosition, itemBuilder: (context, album) => AvesFilterChip( filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)), - showGenericIcon: false, + allowGenericIcon: false, ), ); } diff --git a/lib/widgets/common/action_controls/quick_choosers/tag_chooser.dart b/lib/widgets/common/action_controls/quick_choosers/tag_chooser.dart index fca80cea3..1947814f2 100644 --- a/lib/widgets/common/action_controls/quick_choosers/tag_chooser.dart +++ b/lib/widgets/common/action_controls/quick_choosers/tag_chooser.dart @@ -32,7 +32,7 @@ class TagQuickChooser extends StatelessWidget { pointerGlobalPosition: pointerGlobalPosition, itemBuilder: (context, filter) => AvesFilterChip( filter: filter, - showGenericIcon: false, + allowGenericIcon: false, ), ); } diff --git a/lib/widgets/common/action_mixins/entry_storage.dart b/lib/widgets/common/action_mixins/entry_storage.dart index 40c720146..958b273ff 100644 --- a/lib/widgets/common/action_mixins/entry_storage.dart +++ b/lib/widgets/common/action_mixins/entry_storage.dart @@ -13,6 +13,7 @@ import 'package:aves/model/multipage.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/ref/mime_types.dart'; import 'package:aves/services/common/image_op_events.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/services/media/enums.dart'; @@ -27,7 +28,6 @@ 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/extensions/build_context.dart'; import 'package:aves/widgets/dialogs/aves_confirmation_dialog.dart'; -import 'package:aves/widgets/dialogs/convert_entry_dialog.dart'; import 'package:aves/widgets/dialogs/pick_dialogs/album_pick_page.dart'; import 'package:aves/widgets/dialogs/selection_dialogs/single_selection.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart'; @@ -37,14 +37,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { - Future convert(BuildContext context, Set targetEntries) async { - final options = await showDialog( - context: context, - builder: (context) => ConvertEntryDialog(entries: targetEntries), - routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName), - ); - if (options == null) return; - + Future doExport(BuildContext context, Set targetEntries, EntryConvertOptions options) async { final destinationAlbum = await pickAlbum(context: context, moveType: MoveType.export); if (destinationAlbum == null) return; if (!await checkStoragePermissionForAlbums(context, {destinationAlbum})) return; @@ -70,6 +63,34 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { } }); + final l10n = context.l10n; + + var nameConflictStrategy = NameConflictStrategy.rename; + final destinationDirectory = Directory(destinationAlbum); + final destinationExtension = MimeTypes.extensionFor(options.mimeType); + final names = [ + ...selection.map((v) => '${v.filenameWithoutExtension}$destinationExtension'), + // do not guard up front based on directory existence, + // as conflicts could be within moved entries scattered across multiple albums + if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), + ].map((v) => v.toLowerCase()).toList(); + // case insensitive comparison + final uniqueNames = names.toSet(); + if (uniqueNames.length < names.length) { + final value = await showDialog( + context: context, + builder: (context) => AvesSingleSelectionDialog( + initialValue: nameConflictStrategy, + options: Map.fromEntries(NameConflictStrategy.values.map((v) => MapEntry(v, v.getName(context)))), + message: l10n.nameConflictDialogSingleSourceMessage, + confirmationButtonLabel: l10n.continueButtonLabel, + ), + routeSettings: const RouteSettings(name: AvesSingleSelectionDialog.routeName), + ); + if (value == null) return; + nameConflictStrategy = value; + } + final selectionCount = selection.length; final source = context.read(); source.pauseMonitoring(); @@ -79,7 +100,7 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { selection, options: options, destinationAlbum: destinationAlbum, - nameConflictStrategy: NameConflictStrategy.rename, + nameConflictStrategy: nameConflictStrategy, ), itemCount: selectionCount, onDone: (processed) async { @@ -91,7 +112,6 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { source.resumeMonitoring(); unawaited(source.refreshUris(newUris)); - final l10n = context.l10n; // get navigator beforehand because // local context may be deactivated when action is triggered after navigation final navigator = Navigator.maybeOf(context); @@ -173,7 +193,8 @@ mixin EntryStorageMixin on FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { // do not guard up front based on directory existence, // as conflicts could be within moved entries scattered across multiple albums if (await destinationDirectory.exists()) ...destinationDirectory.listSync().map((v) => pContext.basename(v.path)), - ]; + ].map((v) => v.toLowerCase()).toList(); + // case insensitive comparison final uniqueNames = names.toSet(); if (uniqueNames.length < names.length) { final value = await showDialog( diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index ef145603f..cf29fccaf 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:math'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/accessibility_timeout.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/colors.dart'; @@ -122,7 +121,7 @@ mixin FeedbackMixin { static double snackBarHorizontalPadding(SnackBarThemeData snackBarTheme) { final isFloatingSnackBar = (snackBarTheme.behavior ?? SnackBarBehavior.fixed) == SnackBarBehavior.floating; - final horizontalPadding = isFloatingSnackBar ? 16.0 : 24.0; + final double horizontalPadding = isFloatingSnackBar ? 16.0 : 24.0; return horizontalPadding; } @@ -182,9 +181,9 @@ class _ReportOverlayState extends State> with SingleTickerPr Stream get opStream => widget.opStream; - static const fontSize = 18.0; - static const diameter = 160.0; - static const strokeWidth = 8.0; + static const double fontSize = 18.0; + static const double diameter = 160.0; + static const double strokeWidth = 8.0; @override void initState() { @@ -224,7 +223,7 @@ class _ReportOverlayState extends State> with SingleTickerPr final theme = Theme.of(context); final colorScheme = theme.colorScheme; final progressColor = colorScheme.primary; - final animate = context.select((v) => v.accessibilityAnimations.animate); + final animate = context.select((v) => v.animate); return PopScope( canPop: false, child: StreamBuilder( diff --git a/lib/widgets/common/action_mixins/overlay_snack_bar.dart b/lib/widgets/common/action_mixins/overlay_snack_bar.dart index ed2d5a710..ecd88f143 100644 --- a/lib/widgets/common/action_mixins/overlay_snack_bar.dart +++ b/lib/widgets/common/action_mixins/overlay_snack_bar.dart @@ -212,7 +212,7 @@ class _OverlaySnackBarState extends State { final IconButton? iconButton = showCloseIcon ? IconButton( icon: const Icon(Icons.close), - iconSize: 24.0, + iconSize: 24, color: widget.closeIconColor ?? snackBarTheme.closeIconColor ?? defaults.closeIconColor, onPressed: () => ScaffoldMessenger.of(context).hideCurrentSnackBar(reason: SnackBarClosedReason.dismiss), tooltip: MaterialLocalizations.of(context).closeButtonTooltip, diff --git a/lib/widgets/common/basic/draggable_scrollbar/arrow_clipper.dart b/lib/widgets/common/basic/draggable_scrollbar/arrow_clipper.dart index e665bcc68..94deba547 100644 --- a/lib/widgets/common/basic/draggable_scrollbar/arrow_clipper.dart +++ b/lib/widgets/common/basic/draggable_scrollbar/arrow_clipper.dart @@ -10,7 +10,7 @@ class ArrowClipper extends CustomClipper { path.lineTo(0.0, 0.0); path.close(); - const arrowWidth = 8.0; + const double arrowWidth = 8.0; final startPointX = (size.width - arrowWidth) / 2; var startPointY = size.height / 2 - arrowWidth / 2; path.moveTo(startPointX, startPointY); diff --git a/lib/widgets/common/basic/link_chip.dart b/lib/widgets/common/basic/link_chip.dart index 35422ed05..53ddc6553 100644 --- a/lib/widgets/common/basic/link_chip.dart +++ b/lib/widgets/common/basic/link_chip.dart @@ -30,7 +30,7 @@ class LinkChip extends StatelessWidget { borderRadius: borderRadius, onTap: onTap ?? () => AvesApp.launchUrl(urlString), child: Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/common/basic/popup/menu_button.dart b/lib/widgets/common/basic/popup/menu_button.dart index 6e69368fd..f0f3d178b 100644 --- a/lib/widgets/common/basic/popup/menu_button.dart +++ b/lib/widgets/common/basic/popup/menu_button.dart @@ -11,7 +11,7 @@ class AvesPopupMenuButton extends PopupMenuButton { super.onCanceled, super.tooltip, super.elevation, - super.padding = const EdgeInsets.all(8.0), + super.padding = const EdgeInsets.all(8), super.child, super.icon, super.offset = Offset.zero, diff --git a/lib/widgets/common/behaviour/pop/tv_navigation.dart b/lib/widgets/common/behaviour/pop/tv_navigation.dart index a8a5250ba..b55953eb8 100644 --- a/lib/widgets/common/behaviour/pop/tv_navigation.dart +++ b/lib/widgets/common/behaviour/pop/tv_navigation.dart @@ -4,6 +4,7 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves_model/aves_model.dart'; @@ -32,7 +33,7 @@ class TvNavigationPopHandler { return switch (homePage) { HomePageSetting.collection => context.read().filters.isEmpty, - HomePageSetting.albums || HomePageSetting.tags => true, + HomePageSetting.albums || HomePageSetting.tags || HomePageSetting.explorer => true, }; } @@ -47,6 +48,7 @@ class TvNavigationPopHandler { HomePageSetting.collection => buildRoute((context) => CollectionPage(source: context.read(), filters: null)), HomePageSetting.albums => buildRoute((context) => const AlbumListPage()), HomePageSetting.tags => buildRoute((context) => const TagListPage()), + HomePageSetting.explorer => buildRoute((context) => const ExplorerPage()), }; } } diff --git a/lib/widgets/common/expandable_filter_row.dart b/lib/widgets/common/expandable_filter_row.dart index f7e3e8e0c..f96cdf7e0 100644 --- a/lib/widgets/common/expandable_filter_row.dart +++ b/lib/widgets/common/expandable_filter_row.dart @@ -170,7 +170,7 @@ class ExpandableFilterRow extends StatelessWidget { // key `album-{path}` is expected by test driver key: Key(filter.key), filter: filter, - showGenericIcon: showGenericIcon, + allowGenericIcon: showGenericIcon, leadingOverride: leadingBuilder?.call(filter), heroType: heroTypeBuilder?.call(filter) ?? HeroType.onTap, onTap: onTap, diff --git a/lib/widgets/common/grid/theme.dart b/lib/widgets/common/grid/theme.dart index a9c4c73a7..9050e7b5b 100644 --- a/lib/widgets/common/grid/theme.dart +++ b/lib/widgets/common/grid/theme.dart @@ -29,7 +29,7 @@ class GridTheme extends StatelessWidget { return ProxyProvider2( update: (context, settings, mq, previous) { final margin = OverlayIcon.defaultMargin.vertical; - var iconSize = min(24.0, ((extent - margin) / 5).floorToDouble() - margin); + var iconSize = min(24.0, ((extent - margin) / 5).floorToDouble() - margin); final fontSize = (iconSize * .7).floorToDouble(); iconSize = mq.textScaler.scale(iconSize); final highlightBorderWidth = extent * .1; diff --git a/lib/widgets/common/identity/aves_app_bar.dart b/lib/widgets/common/identity/aves_app_bar.dart index 357c502d7..d0d7e6ba9 100644 --- a/lib/widgets/common/identity/aves_app_bar.dart +++ b/lib/widgets/common/identity/aves_app_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math'; + import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/themes.dart'; @@ -13,12 +15,13 @@ class AvesAppBar extends StatelessWidget { final bool pinned; final Widget? leading; final Widget title; - final List actions; + final List Function(BuildContext context, double maxWidth) actions; final Widget? bottom; final Object? transitionKey; static const leadingHeroTag = 'appbar-leading'; static const titleHeroTag = 'appbar-title'; + static const double _titleMinWidth = 96; const AvesAppBar({ super.key, @@ -90,12 +93,16 @@ class AvesAppBar extends StatelessWidget { child: AnimatedSwitcher( duration: context.read().iconAnimation, child: FontSizeIconTheme( - child: Row( - key: ValueKey(transitionKey), - children: [ - Expanded(child: title), - ...actions, - ], + child: LayoutBuilder( + builder: (context, constraints) { + return Row( + key: ValueKey(transitionKey), + children: [ + Expanded(child: title), + ...(actions(context, max(0, constraints.maxWidth - _titleMinWidth))), + ], + ); + }, ), ), ), diff --git a/lib/widgets/common/identity/aves_donut.dart b/lib/widgets/common/identity/aves_donut.dart index 50c967356..8fc3e82ed 100644 --- a/lib/widgets/common/identity/aves_donut.dart +++ b/lib/widgets/common/identity/aves_donut.dart @@ -1,6 +1,5 @@ import 'dart:math'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/icons.dart'; @@ -107,7 +106,7 @@ class _AvesDonutState extends State with AutomaticKeepAliveClientMixi ), charts.PieChart( series, - animate: context.select((v) => v.accessibilityAnimations.animate), + animate: context.select((v) => v.animate), animationDuration: widget.animationDuration, defaultRenderer: charts.ArcRendererConfig( arcWidth: 16, diff --git a/lib/widgets/common/identity/aves_filter_chip.dart b/lib/widgets/common/identity/aves_filter_chip.dart index 6cd848ca5..def2ceaf6 100644 --- a/lib/widgets/common/identity/aves_filter_chip.dart +++ b/lib/widgets/common/identity/aves_filter_chip.dart @@ -6,6 +6,7 @@ import 'package:aves/model/covers.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; +import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; import 'package:aves/model/settings/enums/accessibility_animations.dart'; @@ -47,7 +48,7 @@ class AvesFilterDecoration { class AvesFilterChip extends StatefulWidget { final CollectionFilter filter; - final bool showText, showGenericIcon, useFilterColor; + final bool showLeading, showText, allowGenericIcon, useFilterColor; final AvesFilterDecoration? decoration; final Color? background; final String? banner; @@ -61,7 +62,7 @@ class AvesFilterChip extends StatefulWidget { static const double defaultRadius = 32; static const double outlineWidth = 2; static const double minChipHeight = kMinInteractiveDimension; - static const double minChipWidth = 80; + static const double minChipWidth = kMinInteractiveDimension; static const double iconSize = 18; static const double fontSize = 14; static const double decoratedContentVerticalPadding = 5; @@ -69,8 +70,9 @@ class AvesFilterChip extends StatefulWidget { const AvesFilterChip({ super.key, required this.filter, + this.showLeading = true, this.showText = true, - this.showGenericIcon = true, + this.allowGenericIcon = true, this.useFilterColor = true, this.decoration, this.background, @@ -98,11 +100,15 @@ class AvesFilterChip extends StatefulWidget { static Future showDefaultLongPressMenu(BuildContext context, CollectionFilter filter, Offset tapPosition) async { if (context.read>().value.canNavigate) { - final actions = [ - if (filter is AlbumFilter) ChipAction.goToAlbumPage, + final actions = [ + if (filter is AlbumFilter) ...[ + ChipAction.goToAlbumPage, + ChipAction.goToExplorerPage, + ], if ((filter is LocationFilter && filter.level == LocationLevel.country)) ChipAction.goToCountryPage, if ((filter is LocationFilter && filter.level == LocationLevel.place)) ChipAction.goToPlacePage, if (filter is TagFilter) ChipAction.goToTagPage, + if (filter is PathFilter) ChipAction.goToExplorerPage, if (filter is RatingFilter && 1 < filter.rating && filter.rating < 5) ...[ if (filter.op != RatingFilter.opOrGreater) ChipAction.ratingOrGreater, if (filter.op != RatingFilter.opOrLower) ChipAction.ratingOrLower, @@ -255,10 +261,12 @@ class _AvesFilterChipState extends State { : null; Widget? content; - if (widget.showText) { + final showLeading = widget.showLeading; + final showText = widget.showText; + if (showLeading || showText) { final textScaler = MediaQuery.textScalerOf(context); final iconSize = textScaler.scale(AvesFilterChip.iconSize); - final leading = widget.leadingOverride ?? filter.iconBuilder(context, iconSize, showGenericIcon: widget.showGenericIcon); + final leading = showLeading ? widget.leadingOverride ?? filter.iconBuilder(context, iconSize, allowGenericIcon: widget.allowGenericIcon) : null; final trailing = onRemove != null ? Theme( data: Theme.of(context).copyWith( @@ -278,22 +286,21 @@ class _AvesFilterChipState extends State { mainAxisSize: decoration != null ? MainAxisSize.max : MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - if (leading != null) ...[ - leading, - SizedBox(width: padding), - ], - Flexible( - child: Text( - filter.getLabel(context), - style: TextStyle( - fontSize: AvesFilterChip.fontSize, - decoration: filter.reversed ? TextDecoration.lineThrough : null, - decorationThickness: 2, + if (leading != null) leading, + if (leading != null && showText) SizedBox(width: padding), + if (showText) + Flexible( + child: Text( + filter.getLabel(context), + style: TextStyle( + fontSize: AvesFilterChip.fontSize, + decoration: filter.reversed ? TextDecoration.lineThrough : null, + decorationThickness: 2, + ), + softWrap: false, + overflow: TextOverflow.fade, ), - softWrap: false, - overflow: TextOverflow.fade, ), - ), if (trailing != null) ...[ SizedBox(width: padding), trailing, @@ -414,7 +421,7 @@ class _AvesFilterChipState extends State { ), ); - final animate = context.select((v) => v.accessibilityAnimations.animate); + final animate = context.select((v) => v.animate); if (animate && (widget.heroType == HeroType.always || widget.heroType == HeroType.onTap && _tapped)) { chip = Hero( tag: filter, diff --git a/lib/widgets/common/identity/aves_icons.dart b/lib/widgets/common/identity/aves_icons.dart index 3b3584be7..b17ddb640 100644 --- a/lib/widgets/common/identity/aves_icons.dart +++ b/lib/widgets/common/identity/aves_icons.dart @@ -181,8 +181,8 @@ class MultiPageIcon extends StatelessWidget { @override Widget build(BuildContext context) { String? text; - if (entry.isBurst) { - text = '${entry.burstEntries?.length}'; + if (entry.isStack) { + text = '${entry.stackedEntries?.length}'; } final child = OverlayIcon( icon: AIcons.multiPage, diff --git a/lib/widgets/common/identity/empty.dart b/lib/widgets/common/identity/empty.dart index dc3a074b3..ccefa90f7 100644 --- a/lib/widgets/common/identity/empty.dart +++ b/lib/widgets/common/identity/empty.dart @@ -1,5 +1,8 @@ +import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; import 'package:provider/provider.dart'; class EmptyContent extends StatelessWidget { @@ -23,6 +26,7 @@ class EmptyContent extends StatelessWidget { @override Widget build(BuildContext context) { final color = Theme.of(context).colorScheme.primary.withOpacity(.5); + final durations = context.watch(); return Padding( padding: safeBottom ? EdgeInsets.only( @@ -33,25 +37,36 @@ class EmptyContent extends StatelessWidget { alignment: alignment, child: Column( mainAxisSize: MainAxisSize.min, - children: [ - if (icon != null) ...[ - Icon( - icon, - size: 64, - color: color, + children: AnimationConfiguration.toStaggeredList( + duration: durations.staggeredAnimation, + delay: durations.staggeredAnimationDelay * timeDilation, + childAnimationBuilder: (child) => SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: child, ), - const SizedBox(height: 16) - ], - Text( - text, - style: TextStyle( - color: color, - fontSize: fontSize, - ), - textAlign: TextAlign.center, ), - if (bottom != null) bottom!, - ], + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 64, + color: color, + ), + const SizedBox(height: 16) + ], + if (text.isNotEmpty) + Text( + text, + style: TextStyle( + color: color, + fontSize: fontSize, + ), + textAlign: TextAlign.center, + ), + if (bottom != null) bottom!, + ], + ), ), ), ); diff --git a/lib/widgets/common/providers/durations_provider.dart b/lib/widgets/common/providers/durations_provider.dart index 4e1382e8f..230af4e49 100644 --- a/lib/widgets/common/providers/durations_provider.dart +++ b/lib/widgets/common/providers/durations_provider.dart @@ -1,4 +1,3 @@ -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; import 'package:provider/provider.dart'; @@ -9,8 +8,7 @@ class DurationsProvider extends ProxyProvider { super.child, }) : super( update: (context, settings, __) { - final enabled = settings.accessibilityAnimations.animate; - return enabled ? DurationsData() : DurationsData.noAnimation(); + return settings.animate ? DurationsData() : DurationsData.noAnimation(); }, ); } diff --git a/lib/widgets/common/thumbnail/image.dart b/lib/widgets/common/thumbnail/image.dart index 4c19eabcd..25f6c7f68 100644 --- a/lib/widgets/common/thumbnail/image.dart +++ b/lib/widgets/common/thumbnail/image.dart @@ -4,7 +4,6 @@ import 'package:aves/image_providers/thumbnail_provider.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/images.dart'; import 'package:aves/model/entry/extensions/props.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/entry_background.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; @@ -190,7 +189,7 @@ class _ThumbnailImageState extends State { @override Widget build(BuildContext context) { - final animate = context.select((v) => v.accessibilityAnimations.animate); + final animate = context.select((v) => v.animate); if (!entry.canDecode || _lastException != null) { return _buildError(context, animate); } diff --git a/lib/widgets/dialogs/convert_entry_dialog.dart b/lib/widgets/dialogs/convert_entry_dialog.dart index 5576aace9..885116cd3 100644 --- a/lib/widgets/dialogs/convert_entry_dialog.dart +++ b/lib/widgets/dialogs/convert_entry_dialog.dart @@ -1,5 +1,6 @@ import 'package:aves/model/app/support.dart'; import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/ref/mime_types.dart'; import 'package:aves/services/media/media_edit_service.dart'; @@ -34,6 +35,8 @@ class ConvertEntryDialog extends StatefulWidget { } class _ConvertEntryDialogState extends State { + late List _actionOptions; + EntryConvertAction _action = EntryConvertAction.convert; final TextEditingController _widthController = TextEditingController(), _heightController = TextEditingController(); final ValueNotifier _isValidNotifier = ValueNotifier(false); late ValueNotifier _mimeTypeNotifier; @@ -44,14 +47,16 @@ class _ConvertEntryDialogState extends State { Set get entries => widget.entries; - static const imageExportFormats = [ + EdgeInsets get contentHorizontalPadding => const EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); + + static const _imageExportFormats = [ MimeTypes.bmp, MimeTypes.jpeg, MimeTypes.png, MimeTypes.webp, ]; - static const qualityFormats = [ + static const _qualityFormats = [ MimeTypes.jpeg, MimeTypes.webp, ]; @@ -59,6 +64,10 @@ class _ConvertEntryDialogState extends State { @override void initState() { super.initState(); + _actionOptions = [ + EntryConvertAction.convert, + if (entries.any((entry) => entry.isMotionPhoto)) EntryConvertAction.convertMotionPhotoToStillImage, + ]; _mimeTypeNotifier = ValueNotifier(settings.convertMimeType); _quality = settings.convertQuality; _writeMetadata = settings.convertWriteMetadata; @@ -95,192 +104,41 @@ class _ConvertEntryDialogState extends State { @override Widget build(BuildContext context) { - final l10n = context.l10n; - const contentHorizontalPadding = EdgeInsets.symmetric(horizontal: AvesDialog.defaultHorizontalContentPadding); - final colorScheme = Theme.of(context).colorScheme; - final trailingStyle = TextStyle(color: colorScheme.onSurfaceVariant); - final trailingChangeShadowColor = colorScheme.onSurface; - - // used by the drop down to match input decoration - final textFieldDecorationBorder = Border( - bottom: BorderSide( - color: colorScheme.onSurface.withOpacity(0.38), - width: 1.0, - ), - ); - return AvesDialog( scrollableContent: [ const SizedBox(height: 16), - Padding( - padding: contentHorizontalPadding, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text(l10n.exportEntryDialogFormat), - const SizedBox(width: AvesDialog.controlCaptionPadding), - TextDropdownButton( - values: imageExportFormats, - valueText: MimeUtils.displayType, - value: _mimeTypeNotifier.value, - onChanged: (selected) { - if (selected != null) { - setState(() => _mimeTypeNotifier.value = selected); - } - }, - ), - ], + if (_actionOptions.length > 1) + Padding( + padding: contentHorizontalPadding, + child: TextDropdownButton( + values: _actionOptions, + valueText: (v) => v.getText(context), + valueIcon: (v) => v.getIconData(), + value: _action, + onChanged: (v) { + _action = v!; + _validate(); + setState(() {}); + }, + isExpanded: true, + dropdownColor: Themes.thirdLayerColor(context), + ), ), - ), - Padding( - padding: contentHorizontalPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, + AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: AvesTransitions.formTransitionBuilder, + child: Column( + key: ValueKey(_action), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: TextField( - controller: _widthController, - decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth), - keyboardType: TextInputType.number, - onChanged: (value) { - final width = int.tryParse(value); - if (width != null) { - switch (_lengthUnit) { - case LengthUnit.px: - _heightController.text = '${(width / entries.first.displayAspectRatio).round()}'; - case LengthUnit.percent: - _heightController.text = '$width'; - } - } else { - _heightController.text = ''; - } - _validate(); - }, - ), - ), - const SizedBox(width: 8), - const Text(AText.resolutionSeparator), - const SizedBox(width: 8), - Expanded( - child: TextField( - controller: _heightController, - decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight), - keyboardType: TextInputType.number, - onChanged: (value) { - final height = int.tryParse(value); - if (height != null) { - switch (_lengthUnit) { - case LengthUnit.px: - _widthController.text = '${(height * entries.first.displayAspectRatio).round()}'; - case LengthUnit.percent: - _widthController.text = '$height'; - } - } else { - _widthController.text = ''; - } - _validate(); - }, - ), - ), - const SizedBox(width: 16), - TextDropdownButton( - values: _lengthUnitOptions, - valueText: (v) => v.getText(context), - value: _lengthUnit, - onChanged: _lengthUnitOptions.length > 1 - ? (v) { - if (v != null && _lengthUnit != v) { - _lengthUnit = v; - _initDimensions(); - _validate(); - setState(() {}); - } - } - : null, - underline: Container( - height: 1.0, - decoration: BoxDecoration( - border: textFieldDecorationBorder, - ), - ), - itemHeight: 60, - dropdownColor: Themes.thirdLayerColor(context), - ), + if (_action == EntryConvertAction.convert) ..._buildConvertContent(context), + if (_action == EntryConvertAction.convertMotionPhotoToStillImage) const SizedBox(height: 16), ], ), ), - ValueListenableBuilder( - valueListenable: _mimeTypeNotifier, - builder: (context, mimeType, child) { - Widget child; - if (qualityFormats.contains(mimeType)) { - child = SliderListTile( - value: _quality.toDouble(), - onChanged: (v) => setState(() => _quality = v.round()), - min: 0, - max: 100, - title: context.l10n.exportEntryDialogQuality, - titlePadding: contentHorizontalPadding, - titleTrailing: (context, value) => ChangeHighlightText( - '${value.round()}', - style: trailingStyle.copyWith( - shadows: [ - Shadow( - color: trailingChangeShadowColor.withOpacity(0), - blurRadius: 0, - ) - ], - ), - changedStyle: trailingStyle.copyWith( - shadows: [ - Shadow( - color: trailingChangeShadowColor, - blurRadius: 3, - ) - ], - ), - duration: context.read().formTextStyleTransition, - ), - ); - } else { - child = const SizedBox(); - } - return AnimatedSwitcher( - duration: context.read().formTransition, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: AvesTransitions.formTransitionBuilder, - child: child, - ); - }, - ), - ValueListenableBuilder( - valueListenable: _mimeTypeNotifier, - builder: (context, mimeType, child) { - Widget child; - if (AppSupport.canEditExif(mimeType) || AppSupport.canEditIptc(mimeType) || AppSupport.canEditXmp(mimeType)) { - child = SwitchListTile( - value: _writeMetadata, - onChanged: (v) => setState(() => _writeMetadata = v), - title: Text(context.l10n.exportEntryDialogWriteMetadata), - contentPadding: const EdgeInsetsDirectional.only( - start: AvesDialog.defaultHorizontalContentPadding, - end: AvesDialog.defaultHorizontalContentPadding - 8, - ), - ); - } else { - child = const SizedBox(height: 16); - } - return AnimatedSwitcher( - duration: context.read().formTransition, - switchInCurve: Curves.easeInOutCubic, - switchOutCurve: Curves.easeInOutCubic, - transitionBuilder: AvesTransitions.formTransitionBuilder, - child: child, - ); - }, - ), ], actions: [ const CancelButton(), @@ -294,6 +152,7 @@ class _ConvertEntryDialogState extends State { final height = int.tryParse(_heightController.text); final options = (width != null && height != null) ? EntryConvertOptions( + action: _action, mimeType: _mimeTypeNotifier.value, writeMetadata: _writeMetadata, lengthUnit: _lengthUnit, @@ -312,7 +171,7 @@ class _ConvertEntryDialogState extends State { Navigator.maybeOf(context)?.pop(options); } : null, - child: Text(l10n.applyButtonLabel), + child: Text(context.l10n.applyButtonLabel), ); }, ), @@ -320,6 +179,193 @@ class _ConvertEntryDialogState extends State { ); } + List _buildConvertContent(BuildContext context) { + final l10n = context.l10n; + final colorScheme = Theme.of(context).colorScheme; + final trailingStyle = TextStyle(color: colorScheme.onSurfaceVariant); + final trailingChangeShadowColor = colorScheme.onSurface; + + // used by the drop down to match input decoration + final textFieldDecorationBorder = Border( + bottom: BorderSide( + color: colorScheme.onSurface.withOpacity(0.38), + width: 1.0, + ), + ); + + return [ + Padding( + padding: contentHorizontalPadding, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(l10n.exportEntryDialogFormat), + const SizedBox(width: AvesDialog.controlCaptionPadding), + TextDropdownButton( + values: _imageExportFormats, + valueText: MimeUtils.displayType, + value: _mimeTypeNotifier.value, + onChanged: (selected) { + if (selected != null) { + setState(() => _mimeTypeNotifier.value = selected); + } + }, + ), + ], + ), + ), + Padding( + padding: contentHorizontalPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Expanded( + child: TextField( + controller: _widthController, + decoration: InputDecoration(labelText: l10n.exportEntryDialogWidth), + keyboardType: TextInputType.number, + onChanged: (value) { + final width = int.tryParse(value); + if (width != null) { + switch (_lengthUnit) { + case LengthUnit.px: + _heightController.text = '${(width / entries.first.displayAspectRatio).round()}'; + case LengthUnit.percent: + _heightController.text = '$width'; + } + } else { + _heightController.text = ''; + } + _validate(); + }, + ), + ), + const SizedBox(width: 8), + const Text(AText.resolutionSeparator), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _heightController, + decoration: InputDecoration(labelText: l10n.exportEntryDialogHeight), + keyboardType: TextInputType.number, + onChanged: (value) { + final height = int.tryParse(value); + if (height != null) { + switch (_lengthUnit) { + case LengthUnit.px: + _widthController.text = '${(height * entries.first.displayAspectRatio).round()}'; + case LengthUnit.percent: + _widthController.text = '$height'; + } + } else { + _widthController.text = ''; + } + _validate(); + }, + ), + ), + const SizedBox(width: 16), + TextDropdownButton( + values: _lengthUnitOptions, + valueText: (v) => v.getText(context), + value: _lengthUnit, + onChanged: _lengthUnitOptions.length > 1 + ? (v) { + if (v != null && _lengthUnit != v) { + _lengthUnit = v; + _initDimensions(); + _validate(); + setState(() {}); + } + } + : null, + underline: Container( + height: 1.0, + decoration: BoxDecoration( + border: textFieldDecorationBorder, + ), + ), + itemHeight: 60, + dropdownColor: Themes.thirdLayerColor(context), + ), + ], + ), + ), + ValueListenableBuilder( + valueListenable: _mimeTypeNotifier, + builder: (context, mimeType, child) { + Widget child; + if (_qualityFormats.contains(mimeType)) { + child = SliderListTile( + value: _quality.toDouble(), + onChanged: (v) => setState(() => _quality = v.round()), + min: 0, + max: 100, + title: context.l10n.exportEntryDialogQuality, + titlePadding: contentHorizontalPadding, + titleTrailing: (context, value) => ChangeHighlightText( + '${value.round()}', + style: trailingStyle.copyWith( + shadows: [ + Shadow( + color: trailingChangeShadowColor.withOpacity(0), + blurRadius: 0, + ) + ], + ), + changedStyle: trailingStyle.copyWith( + shadows: [ + Shadow( + color: trailingChangeShadowColor, + blurRadius: 3, + ) + ], + ), + duration: context.read().formTextStyleTransition, + ), + ); + } else { + child = const SizedBox(); + } + return AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: AvesTransitions.formTransitionBuilder, + child: child, + ); + }, + ), + ValueListenableBuilder( + valueListenable: _mimeTypeNotifier, + builder: (context, mimeType, child) { + Widget child; + if (AppSupport.canEditExif(mimeType) || AppSupport.canEditIptc(mimeType) || AppSupport.canEditXmp(mimeType)) { + child = SwitchListTile( + value: _writeMetadata, + onChanged: (v) => setState(() => _writeMetadata = v), + title: Text(context.l10n.exportEntryDialogWriteMetadata), + contentPadding: const EdgeInsetsDirectional.only( + start: AvesDialog.defaultHorizontalContentPadding, + end: AvesDialog.defaultHorizontalContentPadding - 8, + ), + ); + } else { + child = const SizedBox(height: 16); + } + return AnimatedSwitcher( + duration: context.read().formTransition, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: AvesTransitions.formTransitionBuilder, + child: child, + ); + }, + ), + ]; + } + Future _validate() async { final width = int.tryParse(_widthController.text); final height = int.tryParse(_heightController.text); diff --git a/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart b/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart index b46652c4c..b3962c5fc 100644 --- a/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart +++ b/lib/widgets/dialogs/entry_editors/rename_entry_set_page.dart @@ -6,7 +6,7 @@ import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/theme/styles.dart'; -import 'package:aves/view/src/metadata/fields.dart'; +import 'package:aves/view/view.dart'; import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; import 'package:aves/widgets/common/basic/popup/expansion_panel.dart'; @@ -122,11 +122,10 @@ class _RenameEntrySetPageState extends State { ...[ MetadataField.exifMake, MetadataField.exifModel, - ] - .map((field) => PopupMenuItem( - value: MetadataFieldNamingProcessor.keyWithField(field), - child: MenuRow(text: field.title), - )), + ].map((field) => PopupMenuItem( + value: MetadataFieldNamingProcessor.keyWithField(field), + child: MenuRow(text: field.title), + )), PopupMenuItem( value: HashNamingProcessor.key, child: MenuRow(text: l10n.renameProcessorHash), diff --git a/lib/widgets/explorer/app_bar.dart b/lib/widgets/explorer/app_bar.dart new file mode 100644 index 000000000..061b04435 --- /dev/null +++ b/lib/widgets/explorer/app_bar.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:aves/app_mode.dart'; +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/theme/icons.dart'; +import 'package:aves/theme/themes.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/view/view.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_subtitle.dart'; +import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; +import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; +import 'package:aves/widgets/common/basic/popup/menu_row.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/aves_app_bar.dart'; +import 'package:aves/widgets/common/search/route.dart'; +import 'package:aves/widgets/search/search_delegate.dart'; +import 'package:aves/widgets/settings/privacy/file_picker/crumb_line.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:provider/provider.dart'; + +class ExplorerAppBar extends StatefulWidget { + final ValueNotifier directoryNotifier; + final void Function(String path) goTo; + + const ExplorerAppBar({ + super.key, + required this.directoryNotifier, + required this.goTo, + }); + + @override + State createState() => _ExplorerAppBarState(); +} + +class _ExplorerAppBarState extends State with WidgetsBindingObserver { + Set get _volumes => androidFileUtils.storageVolumes; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AvesAppBar( + contentHeight: appBarContentHeight, + pinned: true, + leading: const DrawerButton(), + title: _buildAppBarTitle(context), + actions: _buildActions, + bottom: LayoutBuilder( + builder: (context, constraints) { + return SizedBox( + width: constraints.maxWidth, + height: CrumbLine.getPreferredHeight(MediaQuery.textScalerOf(context)), + child: ValueListenableBuilder( + valueListenable: widget.directoryNotifier, + builder: (context, directory, child) { + return CrumbLine( + key: const Key('crumbs'), + directory: directory, + onTap: widget.goTo, + ); + }, + ), + ); + }, + ), + ); + } + + InteractiveAppBarTitle _buildAppBarTitle(BuildContext context) { + final appMode = context.watch>().value; + Widget title = Text( + context.l10n.explorerPageTitle, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + if (appMode == AppMode.main) { + title = SourceStateAwareAppBarTitle( + title: title, + source: context.read(), + ); + } + return InteractiveAppBarTitle( + onTap: () => _goToSearch(context), + child: title, + ); + } + + List _buildActions(BuildContext context, double maxWidth) { + final animations = context.select((s) => s.accessibilityAnimations); + return [ + IconButton( + icon: const Icon(AIcons.search), + onPressed: () => _goToSearch(context), + tooltip: MaterialLocalizations.of(context).searchFieldLabel, + ), + if (_volumes.length > 1) + FontSizeIconTheme( + child: PopupMenuButton( + itemBuilder: (context) { + return _volumes.map((v) { + final selected = widget.directoryNotifier.value.volumePath == v.path; + final icon = v.isRemovable ? AIcons.storageCard : AIcons.storageMain; + return PopupMenuItem( + value: v, + enabled: !selected, + child: MenuRow( + text: v.getDescription(context), + icon: Icon(icon), + ), + ); + }).toList(); + }, + onSelected: (volume) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(animations.popUpAnimationDelay * timeDilation); + widget.goTo(volume.path); + }, + popUpAnimationStyle: animations.popUpAnimationStyle, + ), + ), + ]; + } + + double get appBarContentHeight { + final textScaler = MediaQuery.textScalerOf(context); + return textScaler.scale(kToolbarHeight) + CrumbLine.getPreferredHeight(textScaler); + } + + void _goToSearch(BuildContext context) { + Navigator.maybeOf(context)?.push( + SearchPageRoute( + delegate: CollectionSearchDelegate( + searchFieldLabel: context.l10n.searchCollectionFieldHint, + searchFieldStyle: Themes.searchFieldStyle(context), + source: context.read(), + ), + ), + ); + } +} diff --git a/lib/widgets/explorer/explorer_page.dart b/lib/widgets/explorer/explorer_page.dart new file mode 100644 index 000000000..6119e7098 --- /dev/null +++ b/lib/widgets/explorer/explorer_page.dart @@ -0,0 +1,287 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:aves/model/filters/album.dart'; +import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/path.dart'; +import 'package:aves/model/source/album.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/utils/android_file_utils.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/common/basic/scaffold.dart'; +import 'package:aves/widgets/common/behaviour/pop/double_back.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; +import 'package:aves/widgets/common/behaviour/pop/tv_navigation.dart'; +import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:aves/widgets/explorer/app_bar.dart'; +import 'package:aves/widgets/navigation/drawer/app_drawer.dart'; +import 'package:aves_model/aves_model.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_staggered_animations/flutter_staggered_animations.dart'; +import 'package:provider/provider.dart'; + +class ExplorerPage extends StatefulWidget { + static const routeName = '/explorer'; + + final String? path; + + const ExplorerPage({super.key, this.path}); + + @override + State createState() => _ExplorerPageState(); +} + +class _ExplorerPageState extends State { + final List _subscriptions = []; + final ValueNotifier _directory = ValueNotifier(const VolumeRelativeDirectory(volumePath: '', relativeDir: '')); + final ValueNotifier> _contents = ValueNotifier([]); + final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); + + Set get _volumes => androidFileUtils.storageVolumes; + + String get _currentDirectoryPath { + final dir = _directory.value; + return pContext.join(dir.volumePath, dir.relativeDir); + } + + @override + void initState() { + super.initState(); + final path = widget.path; + if (path != null) { + _goTo(path); + } else { + final primaryVolume = _volumes.firstWhereOrNull((v) => v.isPrimary); + if (primaryVolume != null) { + _goTo(primaryVolume.path); + } + } + _contents.addListener(() => PrimaryScrollController.of(context).jumpTo(0)); + WidgetsBinding.instance.addPostFrameCallback((_) { + final source = context.read(); + _subscriptions.add(source.eventBus.on().listen((event) => _updateContents())); + }); + } + + @override + void dispose() { + _subscriptions + ..forEach((sub) => sub.cancel()) + ..clear(); + _directory.dispose(); + _contents.dispose(); + _doubleBackPopHandler.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AvesPopScope( + handlers: [ + (context) { + if (_directory.value.relativeDir.isNotEmpty) { + final parent = pContext.dirname(_currentDirectoryPath); + _goTo(parent); + return false; + } + return true; + }, + TvNavigationPopHandler.pop, + _doubleBackPopHandler.pop, + ], + child: AvesScaffold( + drawer: const AppDrawer(), + body: GestureAreaProtectorStack( + child: Column( + children: [ + Expanded( + child: ValueListenableBuilder>( + valueListenable: _contents, + builder: (context, contents, child) { + final durations = context.watch(); + return CustomScrollView( + // workaround to prevent scrolling the app bar away + // when there is no content and we use `SliverFillRemaining` + physics: contents.isEmpty ? const NeverScrollableScrollPhysics() : null, + slivers: [ + ExplorerAppBar( + key: const Key('appbar'), + directoryNotifier: _directory, + goTo: _goTo, + ), + AnimationLimiter( + // animation limiter should not be above the app bar + // so that the crumb line can automatically scroll + key: ValueKey(_currentDirectoryPath), + child: SliverList.builder( + itemBuilder: (context, index) { + return AnimationConfiguration.staggeredList( + position: index, + duration: durations.staggeredAnimation, + delay: durations.staggeredAnimationDelay * timeDilation, + child: SlideAnimation( + verticalOffset: 50.0, + child: FadeInAnimation( + child: _buildContentLine(context, contents[index]), + ), + ), + ); + }, + itemCount: contents.length, + ), + ), + contents.isEmpty + ? SliverFillRemaining( + child: _buildEmptyContent(), + ) + : const SliverPadding(padding: EdgeInsets.only(bottom: 8)), + ], + ); + }, + ), + ), + const Divider(height: 0), + SafeArea( + top: false, + bottom: true, + child: Padding( + padding: const EdgeInsets.all(8), + child: ValueListenableBuilder( + valueListenable: _directory, + builder: (context, directory, child) { + return AvesFilterChip( + filter: PathFilter(_currentDirectoryPath), + maxWidth: double.infinity, + onTap: (filter) => _goToCollectionPage(context, filter), + onLongPress: null, + ); + }, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildEmptyContent() { + return Selector( + selector: (context, source) => source.state == SourceState.loading, + builder: (context, loading, child) { + Widget? bottom; + if (loading) { + bottom = const CircularProgressIndicator(); + } else { + final source = context.read(); + final album = _getAlbumPath(source, Directory(_currentDirectoryPath)); + if (album != null) { + bottom = AvesFilterChip( + filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)), + maxWidth: double.infinity, + onTap: (filter) => _goToCollectionPage(context, filter), + onLongPress: null, + ); + } + } + + return SafeArea( + top: false, + bottom: false, + child: Padding( + padding: const EdgeInsets.all(8), + child: Center( + child: EmptyContent( + icon: AIcons.folder, + text: '', + bottom: bottom, + ), + ), + ), + ); + }, + ); + } + + String? _getAlbumPath(CollectionSource source, FileSystemEntity content) { + final contentPath = content.path.toLowerCase(); + return source.rawAlbums.firstWhereOrNull((v) => v.toLowerCase() == contentPath); + } + + Widget _buildContentLine(BuildContext context, FileSystemEntity content) { + final source = context.read(); + final album = _getAlbumPath(source, content); + final baseIconTheme = IconTheme.of(context); + + const leadingDim = AvesFilterChip.minChipWidth; + return ListTile( + leading: album != null + ? IconTheme.merge( + data: baseIconTheme, + child: AvesFilterChip( + filter: AlbumFilter(album, source.getAlbumDisplayName(context, album)), + showText: false, + maxWidth: leadingDim, + onTap: (filter) => _goToCollectionPage(context, filter), + onLongPress: null, + ), + ) + : const SizedBox( + width: leadingDim, + height: leadingDim, + child: Icon(AIcons.folder), + ), + title: Text('${Unicode.FSI}${pContext.split(content.path).last}${Unicode.PDI}'), + onTap: () => _goTo(content.path), + ); + } + + void _goTo(String path) { + _directory.value = androidFileUtils.relativeDirectoryFromPath(path)!; + _updateContents(); + } + + void _updateContents() { + final contents = []; + + final source = context.read(); + final albums = source.rawAlbums.map((v) => v.toLowerCase()).toSet(); + Directory(_currentDirectoryPath).list().listen((event) { + final entity = event.absolute; + if (entity is Directory) { + final dirPath = entity.path.toLowerCase(); + if (albums.any((v) => v.startsWith(dirPath))) { + contents.add(entity); + } + } + }, onDone: () { + _contents.value = contents + ..sort((a, b) { + final nameA = pContext.split(a.path).last; + final nameB = pContext.split(b.path).last; + return compareAsciiUpperCaseNatural(nameA, nameB); + }); + }); + } + + void _goToCollectionPage(BuildContext context, CollectionFilter filter) { + Navigator.maybeOf(context)?.push( + MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: context.read(), + filters: {filter}, + ), + ), + ); + } +} diff --git a/lib/widgets/filter_grids/common/action_delegates/chip.dart b/lib/widgets/filter_grids/common/action_delegates/chip.dart index e9bfcff9f..c0d8c4797 100644 --- a/lib/widgets/filter_grids/common/action_delegates/chip.dart +++ b/lib/widgets/filter_grids/common/action_delegates/chip.dart @@ -1,5 +1,6 @@ import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; +import 'package:aves/model/filters/path.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/settings/settings.dart'; @@ -9,6 +10,7 @@ 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/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/places_page.dart'; @@ -27,6 +29,7 @@ class ChipActionDelegate with FeedbackMixin, VaultAwareMixin { case ChipAction.goToCountryPage: case ChipAction.goToPlacePage: case ChipAction.goToTagPage: + case ChipAction.goToExplorerPage: case ChipAction.ratingOrGreater: case ChipAction.ratingOrLower: case ChipAction.reverse: @@ -49,6 +52,22 @@ class ChipActionDelegate with FeedbackMixin, VaultAwareMixin { _goTo(context, filter, PlaceListPage.routeName, (context) => const PlaceListPage()); case ChipAction.goToTagPage: _goTo(context, filter, TagListPage.routeName, (context) => const TagListPage()); + case ChipAction.goToExplorerPage: + String? path; + if (filter is AlbumFilter) { + path = filter.album; + } else if (filter is PathFilter) { + path = filter.path; + } + if (path != null) { + Navigator.maybeOf(context)?.pushAndRemoveUntil( + MaterialPageRoute( + settings: const RouteSettings(name: ExplorerPage.routeName), + builder: (context) => ExplorerPage(path: path), + ), + (route) => false, + ); + } case ChipAction.ratingOrGreater: FilterNotification((filter as RatingFilter).copyWith(RatingFilter.opOrGreater)).dispatch(context); case ChipAction.ratingOrLower: diff --git a/lib/widgets/filter_grids/common/app_bar.dart b/lib/widgets/filter_grids/common/app_bar.dart index a9cf68144..9b3388588 100644 --- a/lib/widgets/filter_grids/common/app_bar.dart +++ b/lib/widgets/filter_grids/common/app_bar.dart @@ -141,9 +141,9 @@ class _FilterGridAppBarState( selector: (context, query) => query.enabled, builder: (context, queryEnabled, child) { - ActionsBuilder actionsBuilder = widget.actionsBuilder ?? _buildActions; + final actionDelegate = widget.actionDelegate; + final ActionsBuilder actionsBuilder = widget.actionsBuilder ?? _buildActions; final useTvLayout = settings.useTvLayout; - final actions = actionsBuilder(context, appMode, selection, widget.actionDelegate); return AvesAppBar( contentHeight: appBarContentHeight, pinned: context.select>, bool>((selection) => selection.isSelecting), @@ -152,7 +152,7 @@ class _FilterGridAppBarState useTvLayout ? [] : actionsBuilder(context, appMode, selection, actionDelegate), bottom: Column( children: [ if (useTvLayout) @@ -161,7 +161,7 @@ class _FilterGridAppBarState extends StatelessWidget { return AvesFilterChip( key: chipKey, filter: _filter, + showLeading: showText, showText: showText, - showGenericIcon: false, + allowGenericIcon: false, decoration: AvesFilterDecoration( radius: radius(extent), widget: Padding( @@ -194,7 +195,7 @@ class CoveredFilterChip extends StatelessWidget { padding: EdgeInsetsDirectional.only(end: padding), duration: ADurations.chipDecorationAnimation, child: Icon( - AIcons.removableStorage, + AIcons.storageCard, color: _detailColor(context), size: iconSize, ), diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index c6ea9775e..1d69ff71f 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -5,7 +5,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/highlight.dart'; import 'package:aves/model/query.dart'; import 'package:aves/model/selection.dart'; -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/vaults/vaults.dart'; @@ -544,7 +543,7 @@ class _FilterSectionedContentState extends State<_Fi await Future.delayed(ADurations.highlightScrollInitDelay); - final animate = context.read().accessibilityAnimations.animate; + final animate = context.read().animate; highlightInfo.trackItem(item, animate: animate, highlightItem: filter); } } diff --git a/lib/widgets/filter_grids/common/list_details.dart b/lib/widgets/filter_grids/common/list_details.dart index 1124afaac..3f974cdcb 100644 --- a/lib/widgets/filter_grids/common/list_details.dart +++ b/lib/widgets/filter_grids/common/list_details.dart @@ -33,7 +33,7 @@ class FilterListDetails extends StatelessWidget { Widget build(BuildContext context) { final detailsTheme = context.watch(); - final leading = filter.iconBuilder(context, detailsTheme.titleIconSize, showGenericIcon: false); + final leading = filter.iconBuilder(context, detailsTheme.titleIconSize, allowGenericIcon: false); final hasTitleLeading = leading != null; return Container( @@ -120,7 +120,7 @@ class FilterListDetails extends StatelessWidget { List leadingIcons = [ if (pinned) const Icon(AIcons.pin), - if (removableStorage) const Icon(AIcons.removableStorage), + if (removableStorage) const Icon(AIcons.storageCard), ]; Widget? leading; diff --git a/lib/widgets/filter_grids/common/section_keys.dart b/lib/widgets/filter_grids/common/section_keys.dart index abca16740..507dcc57c 100644 --- a/lib/widgets/filter_grids/common/section_keys.dart +++ b/lib/widgets/filter_grids/common/section_keys.dart @@ -62,5 +62,5 @@ class StorageVolumeSectionKey extends ChipSectionKey { StorageVolumeSectionKey(BuildContext context, this.volume) : super(title: volume?.getDescription(context) ?? context.l10n.sectionUnknown); @override - Widget? get leading => (volume?.isRemovable ?? false) ? const Icon(AIcons.removableStorage) : null; + Widget? get leading => (volume?.isRemovable ?? false) ? const Icon(AIcons.storageCard) : null; } diff --git a/lib/widgets/home_page.dart b/lib/widgets/home_page.dart index 969aca967..64fc2ae08 100644 --- a/lib/widgets/home_page.dart +++ b/lib/widgets/home_page.dart @@ -25,6 +25,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/search/page.dart'; import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/editor/entry_editor_page.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/tags_page.dart'; import 'package:aves/widgets/intent.dart'; @@ -60,6 +61,7 @@ class _HomePageState extends State { int? _widgetId; String? _initialRouteName, _initialSearchQuery; Set? _initialFilters; + List? _secureUris; static const allowedShortcutRoutes = [ CollectionPage.routeName, @@ -90,6 +92,7 @@ class _HomePageState extends State { final safeMode = intentData[IntentDataKeys.safeMode] ?? false; final intentAction = intentData[IntentDataKeys.action]; _initialFilters = null; + _secureUris = null; await androidFileUtils.init(); if (!{ @@ -126,6 +129,7 @@ class _HomePageState extends State { uri = intentData[IntentDataKeys.uri]; mimeType = intentData[IntentDataKeys.mimeType]; } + _secureUris = intentData[IntentDataKeys.secureUris]; if (uri != null) { _viewerEntry = await _initViewerEntry( uri: uri, @@ -207,7 +211,7 @@ class _HomePageState extends State { canAnalyze: false, ); case AppMode.view: - if (_isViewerSourceable(_viewerEntry)) { + if (_isViewerSourceable(_viewerEntry) && _secureUris == null) { final directory = _viewerEntry?.directory; if (directory != null) { unawaited(AnalysisService.registerCallback()); @@ -300,7 +304,7 @@ class _HomePageState extends State { // if we group bursts, opening a burst sub-entry should: // - identify and select the containing main entry, // - select the sub-entry in the Viewer page. - groupBursts: false, + stackBursts: false, ); final viewerEntryPath = viewerEntry.path; final collectionEntry = collection.sortedEntries.firstWhereOrNull((entry) => entry.path == viewerEntryPath); @@ -346,12 +350,14 @@ class _HomePageState extends State { return buildRoute((context) => const AlbumListPage()); case TagListPage.routeName: return buildRoute((context) => const TagListPage()); + case ExplorerPage.routeName: + return buildRoute((context) => const ExplorerPage()); + case HomeWidgetSettingsPage.routeName: + return buildRoute((context) => HomeWidgetSettingsPage(widgetId: _widgetId!)); case ScreenSaverPage.routeName: return buildRoute((context) => ScreenSaverPage(source: source)); case ScreenSaverSettingsPage.routeName: return buildRoute((context) => const ScreenSaverSettingsPage()); - case HomeWidgetSettingsPage.routeName: - return buildRoute((context) => HomeWidgetSettingsPage(widgetId: _widgetId!)); case SearchPage.routeName: return SearchPageRoute( delegate: CollectionSearchDelegate( diff --git a/lib/widgets/home_widget.dart b/lib/widgets/home_widget.dart index 38f6dca7f..6f6d4a509 100644 --- a/lib/widgets/home_widget.dart +++ b/lib/widgets/home_widget.dart @@ -27,13 +27,16 @@ class HomeWidgetPainter { }); Future drawWidget({ - required int widthPx, - required int heightPx, + required Size sizeDip, + required double? cornerRadiusPx, required Color? outline, required WidgetShape shape, ui.ImageByteFormat format = ui.ImageByteFormat.rawRgba, }) async { - final widgetSizePx = Size(widthPx.toDouble(), heightPx.toDouble()); + final widthPx = sizeDip.width * devicePixelRatio; + final heightPx = sizeDip.height * devicePixelRatio; + final widgetSizePx = Size(widthPx, heightPx); + debugPrint('draw widget for $sizeDip dp ($widgetSizePx px), entry=$entry'); final ui.Image? entryImage; if (entry != null) { final extent = shape.extentPx(widgetSizePx, entry!) / devicePixelRatio; @@ -45,7 +48,7 @@ class HomeWidgetPainter { final recorder = ui.PictureRecorder(); final rect = Offset.zero & widgetSizePx; final canvas = Canvas(recorder, rect); - final path = shape.path(widgetSizePx, devicePixelRatio); + final path = shape.path(widgetSizePx, devicePixelRatio, cornerRadiusPx: cornerRadiusPx); canvas.clipPath(path); if (entryImage != null) { canvas.drawImage(entryImage, Offset(widgetSizePx.width - entryImage.width, widgetSizePx.height - entryImage.height) / 2, Paint()); @@ -56,7 +59,7 @@ class HomeWidgetPainter { if (outline != null) { drawOutline(canvas, path, devicePixelRatio, outline); } - final widgetImage = await recorder.endRecording().toImage(widthPx, heightPx); + final widgetImage = await recorder.endRecording().toImage(widthPx.round(), heightPx.round()); final byteData = await widgetImage.toByteData(format: format); return byteData?.buffer.asUint8List() ?? Uint8List(0); } diff --git a/lib/widgets/intent.dart b/lib/widgets/intent.dart index 639cd5526..e0a97587e 100644 --- a/lib/widgets/intent.dart +++ b/lib/widgets/intent.dart @@ -14,11 +14,13 @@ class IntentActions { class IntentDataKeys { static const action = 'action'; static const allowMultiple = 'allowMultiple'; + static const brightness = 'brightness'; static const filters = 'filters'; static const mimeType = 'mimeType'; static const page = 'page'; static const query = 'query'; static const safeMode = 'safeMode'; + static const secureUris = 'secureUris'; static const uri = 'uri'; static const widgetId = 'widgetId'; } diff --git a/lib/widgets/navigation/drawer/collection_nav_tile.dart b/lib/widgets/navigation/drawer/collection_nav_tile.dart index e0a5029f9..18f51d3b7 100644 --- a/lib/widgets/navigation/drawer/collection_nav_tile.dart +++ b/lib/widgets/navigation/drawer/collection_nav_tile.dart @@ -87,7 +87,7 @@ class AlbumNavTile extends StatelessWidget { title: DrawerFilterTitle(filter: filter), trailing: androidFileUtils.isOnRemovableStorage(album) ? const Icon( - AIcons.removableStorage, + AIcons.storageCard, size: 16, ) : null, diff --git a/lib/widgets/navigation/drawer/page_nav_tile.dart b/lib/widgets/navigation/drawer/page_nav_tile.dart index fedcaffa9..a6ff3f63b 100644 --- a/lib/widgets/navigation/drawer/page_nav_tile.dart +++ b/lib/widgets/navigation/drawer/page_nav_tile.dart @@ -6,6 +6,7 @@ import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/search/page.dart'; import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/debug/app_debug_page.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/places_page.dart'; @@ -95,12 +96,14 @@ class PageNavTile extends StatelessWidget { return (_) => const PlaceListPage(); case TagListPage.routeName: return (_) => const TagListPage(); - case SettingsPage.routeName: - return (_) => const SettingsPage(); case AboutPage.routeName: return (_) => const AboutPage(); case AppDebugPage.routeName: return (_) => const AppDebugPage(); + case ExplorerPage.routeName: + return (_) => const ExplorerPage(); + case SettingsPage.routeName: + return (_) => const SettingsPage(); default: throw Exception('unknown route=$routeName'); } diff --git a/lib/widgets/navigation/nav_display.dart b/lib/widgets/navigation/nav_display.dart index 7a0d3fb14..343cad986 100644 --- a/lib/widgets/navigation/nav_display.dart +++ b/lib/widgets/navigation/nav_display.dart @@ -7,6 +7,7 @@ import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/search/page.dart'; import 'package:aves/widgets/debug/app_debug_page.dart'; +import 'package:aves/widgets/explorer/explorer_page.dart'; import 'package:aves/widgets/filter_grids/albums_page.dart'; import 'package:aves/widgets/filter_grids/countries_page.dart'; import 'package:aves/widgets/filter_grids/places_page.dart'; @@ -40,14 +41,16 @@ class NavigationDisplay { return l10n.drawerPlacePage; case TagListPage.routeName: return l10n.drawerTagPage; - case SettingsPage.routeName: - return l10n.settingsPageTitle; case AboutPage.routeName: return l10n.aboutPageTitle; - case SearchPage.routeName: - return MaterialLocalizations.of(context).searchFieldLabel; case AppDebugPage.routeName: return 'Debug'; + case ExplorerPage.routeName: + return l10n.explorerPageTitle; + case SearchPage.routeName: + return MaterialLocalizations.of(context).searchFieldLabel; + case SettingsPage.routeName: + return l10n.settingsPageTitle; default: return route; } @@ -63,14 +66,16 @@ class NavigationDisplay { return AIcons.place; case TagListPage.routeName: return AIcons.tag; - case SettingsPage.routeName: - return AIcons.settings; case AboutPage.routeName: return AIcons.info; - case SearchPage.routeName: - return AIcons.search; case AppDebugPage.routeName: return AIcons.debug; + case ExplorerPage.routeName: + return AIcons.explorer; + case SearchPage.routeName: + return AIcons.search; + case SettingsPage.routeName: + return AIcons.settings; default: return null; } diff --git a/lib/widgets/settings/common/quick_actions/editor_page.dart b/lib/widgets/settings/common/quick_actions/editor_page.dart index e31b2d7e1..27248437c 100644 --- a/lib/widgets/settings/common/quick_actions/editor_page.dart +++ b/lib/widgets/settings/common/quick_actions/editor_page.dart @@ -98,7 +98,7 @@ class _QuickActionEditorBodyState extends State CountryListPage.routeName, PlaceListPage.routeName, TagListPage.routeName, + ExplorerPage.routeName, SearchPage.routeName, }; diff --git a/lib/widgets/settings/navigation/navigation.dart b/lib/widgets/settings/navigation/navigation.dart index 606079dd7..a23493848 100644 --- a/lib/widgets/settings/navigation/navigation.dart +++ b/lib/widgets/settings/navigation/navigation.dart @@ -73,6 +73,7 @@ class SettingsTileNavigationHomePage extends SettingsTile { const _HomeOption(HomePageSetting.collection), const _HomeOption(HomePageSetting.albums), const _HomeOption(HomePageSetting.tags), + const _HomeOption(HomePageSetting.explorer), if (settings.homeCustomCollection.isNotEmpty) _HomeOption(HomePageSetting.collection, customCollection: settings.homeCustomCollection), ], getName: (context, v) => v.getName(context), diff --git a/lib/widgets/settings/privacy/file_picker/crumb_line.dart b/lib/widgets/settings/privacy/file_picker/crumb_line.dart index 7ad896d88..d334807cc 100644 --- a/lib/widgets/settings/privacy/file_picker/crumb_line.dart +++ b/lib/widgets/settings/privacy/file_picker/crumb_line.dart @@ -1,8 +1,10 @@ +import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/view/view.dart'; import 'package:aves_model/aves_model.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class CrumbLine extends StatefulWidget { final VolumeRelativeDirectory directory; @@ -16,6 +18,8 @@ class CrumbLine extends StatefulWidget { @override State createState() => _CrumbLineState(); + + static double getPreferredHeight(TextScaler textScaler) => textScaler.scale(kToolbarHeight); } class _CrumbLineState extends State { @@ -23,18 +27,29 @@ class _CrumbLineState extends State { VolumeRelativeDirectory get directory => widget.directory; + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + @override void didUpdateWidget(covariant CrumbLine oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.directory.relativeDir.length < widget.directory.relativeDir.length) { // scroll to show last crumb WidgetsBinding.instance.addPostFrameCallback((_) { + final animate = context.read().animate; final extent = _scrollController.position.maxScrollExtent; - _scrollController.animateTo( - extent, - duration: const Duration(milliseconds: 500), - curve: Curves.easeOutQuad, - ); + if (animate) { + _scrollController.animateTo( + extent, + duration: const Duration(milliseconds: 500), + curve: Curves.easeOutQuad, + ); + } else { + _scrollController.jumpTo(extent); + } }); } } @@ -45,63 +60,56 @@ class _CrumbLineState extends State { directory.getVolumeDescription(context), ...pContext.split(directory.relativeDir), ]; - final crumbStyle = Theme.of(context).textTheme.bodyMedium; - final crumbColor = crumbStyle!.color!.withOpacity(.4); - return DefaultTextStyle( - style: crumbStyle.copyWith( - color: crumbColor, - fontWeight: FontWeight.w500, - ), - child: ListView.builder( - scrollDirection: Axis.horizontal, - controller: _scrollController, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemBuilder: (context, index) { - Widget _buildText(String text) => Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text(text), - ); - - if (index >= parts.length) return const SizedBox(); - final text = parts[index]; - if (index == parts.length - 1) { - return Center( - child: DefaultTextStyle.merge( - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - ), - child: _buildText(text), - ), + final crumbColor = DefaultTextStyle.of(context).style.color; + return ListView.builder( + scrollDirection: Axis.horizontal, + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + Widget _buildText(String text) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text(text), ); - } - return GestureDetector( - onTap: () { - final path = pContext.joinAll([ - directory.volumePath, - ...parts.skip(1).take(index), - ]); - widget.onTap(path); - }, - child: Container( - // use a `Container` with a dummy color to make it expand - // so that we can also detect taps around the title `Text` - color: Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _buildText(text), - Icon( - AIcons.next, - color: crumbColor, - ), - ], + + if (index >= parts.length) return const SizedBox(); + final text = parts[index]; + if (index == parts.length - 1) { + return Center( + child: DefaultTextStyle.merge( + style: TextStyle( + color: Theme.of(context).colorScheme.primary, ), + child: _buildText(text), ), ); - }, - itemCount: parts.length, - ), + } + return GestureDetector( + onTap: () { + final path = pContext.joinAll([ + directory.volumePath, + ...parts.skip(1).take(index), + ]); + widget.onTap(path); + }, + child: Container( + // use a `Container` with a dummy color to make it expand + // so that we can also detect taps around the title `Text` + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildText(text), + Icon( + AIcons.next, + color: crumbColor, + ), + ], + ), + ), + ); + }, + itemCount: parts.length, ); } } diff --git a/lib/widgets/settings/privacy/file_picker/file_picker_page.dart b/lib/widgets/settings/privacy/file_picker/file_picker_page.dart index 668f3d2d8..d8d5c09b2 100644 --- a/lib/widgets/settings/privacy/file_picker/file_picker_page.dart +++ b/lib/widgets/settings/privacy/file_picker/file_picker_page.dart @@ -101,16 +101,7 @@ class _FilePickerPageState extends State { body: SafeArea( child: Column( children: [ - SizedBox( - height: kMinInteractiveDimension, - child: CrumbLine( - directory: _directory, - onTap: (path) { - _goTo(path); - setState(() {}); - }, - ), - ), + _buildCrumbLine(context), const Divider(height: 0), Expanded( child: visibleContents == null @@ -144,6 +135,26 @@ class _FilePickerPageState extends State { ); } + Widget _buildCrumbLine(BuildContext context) { + final crumbStyle = Theme.of(context).textTheme.bodyMedium!; + return SizedBox( + height: kMinInteractiveDimension, + child: DefaultTextStyle( + style: crumbStyle.copyWith( + color: crumbStyle.color!.withOpacity(.4), + fontWeight: FontWeight.w500, + ), + child: CrumbLine( + directory: _directory, + onTap: (path) { + _goTo(path); + setState(() {}); + }, + ), + ), + ); + } + String _getTitle(BuildContext context) { if (_directory.relativeDir.isEmpty) { return _directory.getVolumeDescription(context); @@ -165,7 +176,7 @@ class _FilePickerPageState extends State { ), ), ...volumes.map((v) { - final icon = v.isRemovable ? AIcons.removableStorage : AIcons.mainStorage; + final icon = v.isRemovable ? AIcons.storageCard : AIcons.storageMain; return ListTile( leading: Icon(icon), title: Text(v.getDescription(context)), diff --git a/lib/widgets/settings/privacy/hidden_items_page.dart b/lib/widgets/settings/privacy/hidden_items_page.dart index 2d88c27d3..3a5e88619 100644 --- a/lib/widgets/settings/privacy/hidden_items_page.dart +++ b/lib/widgets/settings/privacy/hidden_items_page.dart @@ -101,20 +101,22 @@ class _HiddenFilters extends StatelessWidget { child: Row( children: [ Expanded( - child: LayoutBuilder(builder: (context, constraints) { - return Row( - children: [ - AvesFilterChip( - filter: filter, - maxWidth: constraints.maxWidth, - onTap: onRemove, - onRemove: onRemove, - onLongPress: null, - ), - const Spacer(), - ], - ); - }), + child: LayoutBuilder( + builder: (context, constraints) { + return Row( + children: [ + AvesFilterChip( + filter: filter, + maxWidth: constraints.maxWidth, + onTap: onRemove, + onRemove: onRemove, + onLongPress: null, + ), + const Spacer(), + ], + ); + }, + ), ), const SizedBox(width: 8), Switch( diff --git a/lib/widgets/settings/thumbnails/thumbnails.dart b/lib/widgets/settings/thumbnails/thumbnails.dart index b74f55dce..361b41b93 100644 --- a/lib/widgets/settings/thumbnails/thumbnails.dart +++ b/lib/widgets/settings/thumbnails/thumbnails.dart @@ -18,7 +18,7 @@ class ThumbnailsSection extends SettingsSection { @override Widget icon(BuildContext context) => SettingsTileLeading( - icon: AIcons.grid, + icon: AIcons.thumbnails, color: context.select((v) => v.thumbnails), ); diff --git a/lib/widgets/stats/filter_table.dart b/lib/widgets/stats/filter_table.dart index a450fa1fb..59b5234c7 100644 --- a/lib/widgets/stats/filter_table.dart +++ b/lib/widgets/stats/filter_table.dart @@ -1,5 +1,4 @@ import 'package:aves/model/filters/filters.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/themes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -36,7 +35,7 @@ class FilterTable extends StatelessWidget { @override Widget build(BuildContext context) { final countFormatter = NumberFormat.decimalPattern(context.locale); - final animate = context.select((v) => v.accessibilityAnimations.animate); + final animate = context.select((v) => v.animate); final sortedEntries = entryCountMap.entries.toList(); if (sortByCount) { diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index 3bfaa0e85..56678c60b 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -6,7 +6,6 @@ import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/location.dart'; import 'package:aves/model/filters/rating.dart'; import 'package:aves/model/filters/tag.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; @@ -439,7 +438,7 @@ class _LocationIndicator extends StatelessWidget { lineHeight: lineHeight, backgroundColor: Themes.secondLayerColor(context), progressColor: theme.colorScheme.primary, - animation: context.select((v) => v.accessibilityAnimations.animate), + animation: context.select((v) => v.animate), isRTL: context.isRtl, barRadius: barRadius, padding: EdgeInsets.symmetric(horizontal: lineHeight), diff --git a/lib/widgets/viewer/action/entry_action_delegate.dart b/lib/widgets/viewer/action/entry_action_delegate.dart index b2f7d5a4f..5b6da5965 100644 --- a/lib/widgets/viewer/action/entry_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_action_delegate.dart @@ -15,6 +15,7 @@ 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/services.dart'; +import 'package:aves/services/media/media_edit_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/common/action_mixins/entry_storage.dart'; @@ -26,6 +27,7 @@ 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'; import 'package:aves/widgets/dialogs/aves_dialog.dart'; +import 'package:aves/widgets/dialogs/convert_entry_dialog.dart'; import 'package:aves/widgets/dialogs/entry_editors/rename_entry_dialog.dart'; import 'package:aves/widgets/viewer/action/entry_info_action_delegate.dart'; import 'package:aves/widgets/viewer/action/printer.dart'; @@ -163,7 +165,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix } AvesEntry _getTargetEntry(BuildContext context, EntryAction action) { - if (mainEntry.isMultiPage && (mainEntry.isBurst || EntryActions.pageActions.contains(action))) { + if (mainEntry.isMultiPage && (mainEntry.isStack || EntryActions.pageActions.contains(action))) { final multiPageController = context.read().getController(mainEntry); if (multiPageController != null) { final multiPageInfo = multiPageController.info; @@ -198,7 +200,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix case EntryAction.restore: _move(context, targetEntry, moveType: MoveType.fromBin); case EntryAction.convert: - convert(context, {targetEntry}); + _convert(context, targetEntry); case EntryAction.print: EntryPrinter(targetEntry).print(context); case EntryAction.rename: @@ -444,6 +446,22 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix entries: {targetEntry}, ); + Future _convert(BuildContext context, AvesEntry targetEntry) async { + final options = await showDialog( + context: context, + builder: (context) => ConvertEntryDialog(entries: {targetEntry}), + routeSettings: const RouteSettings(name: ConvertEntryDialog.routeName), + ); + if (options == null) return; + + switch (options.action) { + case EntryConvertAction.convert: + await doExport(context, {targetEntry}, options); + case EntryConvertAction.convertMotionPhotoToStillImage: + await _metadataActionDelegate.onActionSelected(context, targetEntry, collection, EntryAction.convertMotionPhotoToStillImage); + } + } + Future _rename(BuildContext context, AvesEntry targetEntry) async { final newName = await showDialog( context: context, diff --git a/lib/widgets/viewer/action/entry_info_action_delegate.dart b/lib/widgets/viewer/action/entry_info_action_delegate.dart index 5517d70cf..b63fd1dd2 100644 --- a/lib/widgets/viewer/action/entry_info_action_delegate.dart +++ b/lib/widgets/viewer/action/entry_info_action_delegate.dart @@ -92,7 +92,7 @@ class EntryInfoActionDelegate with FeedbackMixin, PermissionAwareMixin, EntryEdi } } - void onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async { + Future onActionSelected(BuildContext context, AvesEntry targetEntry, CollectionLens? collection, EntryAction action) async { await reportService.log('$action'); _eventStreamController.add(ActionStartedEvent(action)); switch (action) { diff --git a/lib/widgets/viewer/debug/debug_page.dart b/lib/widgets/viewer/debug/debug_page.dart index 7dc76d47d..ef7d1497a 100644 --- a/lib/widgets/viewer/debug/debug_page.dart +++ b/lib/widgets/viewer/debug/debug_page.dart @@ -113,7 +113,7 @@ class ViewerDebugPage extends StatelessWidget { 'isRotated': '${entry.isRotated}', 'isFlipped': '${entry.isFlipped}', 'displayAspectRatio': '${entry.displayAspectRatio}', - 'displaySize': '${entry.displaySize}', + 'displaySize': '${entry.displaySize.width}x${entry.displaySize.height}', }, ), const Divider(), diff --git a/lib/widgets/viewer/entry_horizontal_pager.dart b/lib/widgets/viewer/entry_horizontal_pager.dart index c6a11a9f2..7ba47aa63 100644 --- a/lib/widgets/viewer/entry_horizontal_pager.dart +++ b/lib/widgets/viewer/entry_horizontal_pager.dart @@ -1,7 +1,6 @@ import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/multipage.dart'; import 'package:aves/model/entry/extensions/props.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/viewer_transition.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -71,7 +70,7 @@ class _MultiEntryScrollerState extends State with AutomaticK : _buildViewer(mainEntry); return Selector( - selector: (context, s) => s.accessibilityAnimations.animate, + selector: (context, s) => s.animate, builder: (context, animate, child) { if (!animate) return child!; return AnimatedBuilder( diff --git a/lib/widgets/viewer/entry_viewer_stack.dart b/lib/widgets/viewer/entry_viewer_stack.dart index 16188cf7d..5eb4dfbe0 100644 --- a/lib/widgets/viewer/entry_viewer_stack.dart +++ b/lib/widgets/viewer/entry_viewer_stack.dart @@ -9,7 +9,6 @@ import 'package:aves/model/entry/extensions/props.dart'; import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/trash.dart'; import 'package:aves/model/highlight.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/enums/accessibility_timeout.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/source/collection_lens.dart'; @@ -63,7 +62,6 @@ class EntryViewerStack extends StatefulWidget { } class _EntryViewerStackState extends State with EntryViewControllerMixin, FeedbackMixin, TickerProviderStateMixin, RouteAware { - final Floating _floating = Floating(); late int _currentEntryIndex; late ValueNotifier _currentVerticalPage; late PageController _horizontalPager, _verticalPager; @@ -79,7 +77,7 @@ class _EntryViewerStackState extends State with EntryViewContr late VideoActionDelegate _videoActionDelegate; final ValueNotifier _heroInfoNotifier = ValueNotifier(null); bool _isEntryTracked = true; - Timer? _overlayHidingTimer, _videoPauseTimer; + Timer? _overlayHidingTimer, _appInactiveReactionTimer; @override bool get isViewingImage => _currentVerticalPage.value == imagePage; @@ -185,7 +183,6 @@ class _EntryViewerStackState extends State with EntryViewContr @override void dispose() { AvesApp.pageRouteObserver.unsubscribe(this); - _floating.dispose(); cleanEntryControllers(entryNotifier.value); _videoActionDelegate.dispose(); _verticalPageAnimationController.dispose(); @@ -203,7 +200,7 @@ class _EntryViewerStackState extends State with EntryViewContr _verticalScrollNotifier.dispose(); _heroInfoNotifier.dispose(); _stopOverlayHidingTimer(); - _stopVideoPauseTimer(); + _stopAppInactiveTimer(); AvesApp.lifecycleStateNotifier.removeListener(_onAppLifecycleStateChanged); _unregisterWidget(widget); super.dispose(); @@ -253,7 +250,7 @@ class _EntryViewerStackState extends State with EntryViewContr return StreamBuilder( // as of floating v2.0.0, plugin assumes activity and fails when bound via service // so we do not access status stream directly, but check for support first - stream: device.supportPictureInPicture ? _floating.pipStatusStream : Stream.value(PiPStatus.disabled), + stream: device.supportPictureInPicture ? Floating().pipStatusStream : Stream.value(PiPStatus.disabled), builder: (context, snapshot) { var pipEnabled = snapshot.data == PiPStatus.enabled; return ValueListenableBuilder( @@ -334,42 +331,43 @@ class _EntryViewerStackState extends State with EntryViewContr switch (AvesApp.lifecycleStateNotifier.value) { case AppLifecycleState.inactive: // inactive: when losing focus - _onAppInactive(); + // also triggered when app is rotated on Android API >=33 + _startAppInactiveTimer(); case AppLifecycleState.paused: case AppLifecycleState.detached: // paused: when switching to another app // detached: when app is without a view viewerController.autopilot = false; - _stopVideoPauseTimer(); + _stopAppInactiveTimer(); pauseVideoControllers(); case AppLifecycleState.resumed: - _stopVideoPauseTimer(); + _stopAppInactiveTimer(); case AppLifecycleState.hidden: // hidden: transient state between `inactive` and `paused` break; } } - Future _onAppInactive() async { - final playingController = context.read().getPlayingController(); + Future _onAppInactive(AvesVideoController? playingController) async { bool enabledPip = false; if (settings.videoBackgroundMode == VideoBackgroundMode.pip) { - enabledPip |= await _enablePictureInPicture(); + enabledPip |= await _enablePictureInPicture(playingController); } if (enabledPip) { // ensure playback, in case lifecycle paused/resumed events happened when switching to PiP await playingController?.play(); } else { - _startVideoPauseTimer(); + await pauseVideoControllers(); } } - void _startVideoPauseTimer() { - _stopVideoPauseTimer(); - _videoPauseTimer = Timer(ADurations.videoPauseAppInactiveDelay, pauseVideoControllers); + void _startAppInactiveTimer() { + _stopAppInactiveTimer(); + final playingController = context.read().getPlayingController(); + _appInactiveReactionTimer = Timer(ADurations.appInactiveReactionDelay, () => _onAppInactive(playingController)); } - void _stopVideoPauseTimer() => _videoPauseTimer?.cancel(); + void _stopAppInactiveTimer() => _appInactiveReactionTimer?.cancel(); Widget _decorateOverlay(Widget overlay) { return ValueListenableBuilder( @@ -685,7 +683,7 @@ class _EntryViewerStackState extends State with EntryViewContr final baseCollection = collection; if (baseCollection == null) return; - unawaited(_onLeave()); + await _onLeave(); final uri = entryNotifier.value?.uri; unawaited(Navigator.maybeOf(context)?.pushAndRemoveUntil( MaterialPageRoute( @@ -701,7 +699,7 @@ class _EntryViewerStackState extends State with EntryViewContr } Future _goToVerticalPage(int page) async { - if (settings.accessibilityAnimations.animate) { + if (settings.animate) { final start = _verticalPager.offset; final end = _verticalPager.position.viewportDimension * page; final simulation = ScrollSpringSimulation(ViewerVerticalPageView.spring, start, end, 0); @@ -795,11 +793,11 @@ class _EntryViewerStackState extends State with EntryViewContr if (collectionEntries.remove(removedEntry)) return; // remove from burst - final mainEntry = collectionEntries.firstWhereOrNull((entry) => entry.burstEntries?.contains(removedEntry) == true); + final mainEntry = collectionEntries.firstWhereOrNull((entry) => entry.stackedEntries?.contains(removedEntry) == true); if (mainEntry != null) { final multiPageController = context.read().getController(mainEntry); if (multiPageController != null) { - mainEntry.burstEntries!.remove(removedEntry); + mainEntry.stackedEntries!.remove(removedEntry); multiPageController.reset(); } } @@ -854,7 +852,7 @@ class _EntryViewerStackState extends State with EntryViewContr void _popVisual() { if (Navigator.canPop(context)) { Future pop() async { - unawaited(_onLeave()); + await _onLeave(); Navigator.maybeOf(context)?.pop(); } @@ -918,12 +916,15 @@ class _EntryViewerStackState extends State with EntryViewContr if (!settings.useTvLayout) { await windowService.requestOrientation(); } + // delay to prevent white/black flash on page transition + // from a viewer with a transparent background and no system UI + // to a regular page with system UI + await Future.delayed(const Duration(milliseconds: 50)); } - Future _enablePictureInPicture() async { - final videoController = context.read().getPlayingController(); - if (videoController != null) { - final entrySize = videoController.entry.displaySize; + Future _enablePictureInPicture(AvesVideoController? playingController) async { + if (playingController != null) { + final entrySize = playingController.entry.displaySize; final aspectRatio = Rational(entrySize.width.round(), entrySize.height.round()); final viewSize = MediaQuery.sizeOf(context) * MediaQuery.devicePixelRatioOf(context); @@ -936,7 +937,7 @@ class _EntryViewerStackState extends State with EntryViewContr ); try { - final status = await _floating.enable(EnableManual( + final status = await Floating().enable(ImmediatePiP( aspectRatio: aspectRatio, sourceRectHint: sourceRectHint, )); diff --git a/lib/widgets/viewer/info/color_section.dart b/lib/widgets/viewer/info/color_section.dart index 087e15f3e..2dec25bd7 100644 --- a/lib/widgets/viewer/info/color_section.dart +++ b/lib/widgets/viewer/info/color_section.dart @@ -63,7 +63,7 @@ class _ColorSectionSliverState extends State { const SectionRow(icon: AIcons.palette), ...colors.map( (v) => Padding( - padding: const EdgeInsets.all(8.0), + padding: const EdgeInsets.all(8), child: Row( mainAxisSize: MainAxisSize.min, children: [ diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index a6e8195f0..866b8f4cf 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -94,7 +94,7 @@ class InfoAppBar extends StatelessWidget { onSelected: (action) async { // wait for the popup menu to hide before proceeding with the action await Future.delayed(animations.popUpAnimationDelay * timeDilation); - actionDelegate.onActionSelected(context, entry, collection, action); + await actionDelegate.onActionSelected(context, entry, collection, action); }, popUpAnimationStyle: animations.popUpAnimationStyle, ), diff --git a/lib/widgets/viewer/info/info_page.dart b/lib/widgets/viewer/info/info_page.dart index 85cb274c7..ca342a284 100644 --- a/lib/widgets/viewer/info/info_page.dart +++ b/lib/widgets/viewer/info/info_page.dart @@ -85,7 +85,7 @@ class _InfoPageState extends State { ); } - return mainEntry.isBurst + return mainEntry.isStack ? PageEntryBuilder( multiPageController: context.read().getController(mainEntry), builder: (pageEntry) => _buildContent(pageEntry: pageEntry), diff --git a/lib/widgets/viewer/overlay/bottom.dart b/lib/widgets/viewer/overlay/bottom.dart index 9a6024f17..06f00808b 100644 --- a/lib/widgets/viewer/overlay/bottom.dart +++ b/lib/widgets/viewer/overlay/bottom.dart @@ -302,7 +302,7 @@ class ExtraBottomOverlay extends StatelessWidget { Widget build(BuildContext context) { final viewInsets = this.viewInsets ?? MediaQuery.viewInsetsOf(context); final viewPadding = this.viewPadding ?? MediaQuery.viewPaddingOf(context); - final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + const EdgeInsets.symmetric(horizontal: 8.0); + final safePadding = (viewInsets + viewPadding).copyWith(bottom: 8) + const EdgeInsets.symmetric(horizontal: 8); return Padding( padding: safePadding, diff --git a/lib/widgets/viewer/overlay/video/ab_repeat.dart b/lib/widgets/viewer/overlay/video/ab_repeat.dart index 4aaf184d4..4b7ee76a9 100644 --- a/lib/widgets/viewer/overlay/video/ab_repeat.dart +++ b/lib/widgets/viewer/overlay/video/ab_repeat.dart @@ -38,13 +38,13 @@ class _VideoABRepeatOverlayState extends State { Widget boundButton; if (abRepeat.start == null) { boundButton = IconButton( - icon: Icon(AIcons.setStart), + icon: Icon(AIcons.setBoundStart), onPressed: controller?.setABRepeatStart, tooltip: l10n.videoRepeatActionSetStart, ); } else if (abRepeat.end == null) { boundButton = IconButton( - icon: Icon(AIcons.setEnd), + icon: Icon(AIcons.setBoundEnd), onPressed: controller?.setABRepeatEnd, tooltip: l10n.videoRepeatActionSetEnd, ); diff --git a/lib/widgets/viewer/overlay/video/progress_bar.dart b/lib/widgets/viewer/overlay/video/progress_bar.dart index 310b363a2..1c71ad503 100644 --- a/lib/widgets/viewer/overlay/video/progress_bar.dart +++ b/lib/widgets/viewer/overlay/video/progress_bar.dart @@ -61,13 +61,13 @@ class _VideoProgressBarState extends State { }, onHorizontalDragStart: (details) { _playingOnDragStart = isPlaying; - if (_playingOnDragStart) controller!.pause(); + if (_playingOnDragStart) controller?.pause(); }, onHorizontalDragUpdate: (details) { _seekFromTap(details.globalPosition); }, onHorizontalDragEnd: (details) { - if (_playingOnDragStart) controller!.play(); + if (_playingOnDragStart) controller?.play(); }, child: ConstrainedBox( constraints: const BoxConstraints(minHeight: kMinInteractiveDimension), diff --git a/lib/widgets/viewer/overlay/viewer_buttons.dart b/lib/widgets/viewer/overlay/viewer_buttons.dart index 6b2e3a237..bcf6287d3 100644 --- a/lib/widgets/viewer/overlay/viewer_buttons.dart +++ b/lib/widgets/viewer/overlay/viewer_buttons.dart @@ -181,7 +181,7 @@ class _TvButtonRowContent extends StatelessWidget { }) { switch (action) { case EntryAction.toggleFavourite: - final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry; + final favouriteTargetEntry = mainEntry.isStack ? pageEntry : mainEntry; return FavouriteTogglerCaption( entries: {favouriteTargetEntry}, enabled: enabled, @@ -236,7 +236,7 @@ class _ViewerButtonRowContentState extends State { AvesEntry get pageEntry => widget.pageEntry; - AvesEntry get favouriteTargetEntry => mainEntry.isBurst ? pageEntry : mainEntry; + AvesEntry get favouriteTargetEntry => mainEntry.isStack ? pageEntry : mainEntry; static const double padding = ViewerButtonRowContent.padding; @@ -487,7 +487,7 @@ class _ViewerButtonRowContentState extends State { onPressed: onPressed, ); case EntryAction.toggleFavourite: - final favouriteTargetEntry = mainEntry.isBurst ? pageEntry : mainEntry; + final favouriteTargetEntry = mainEntry.isStack ? pageEntry : mainEntry; child = FavouriteToggler( entries: {favouriteTargetEntry}, focusNode: focusNode, diff --git a/lib/widgets/viewer/view/conductor.dart b/lib/widgets/viewer/view/conductor.dart index 644c0d6c9..cae0f16cd 100644 --- a/lib/widgets/viewer/view/conductor.dart +++ b/lib/widgets/viewer/view/conductor.dart @@ -71,7 +71,7 @@ class ViewStateConductor { void reset(AvesEntry entry) { final uris = { entry, - ...?entry.burstEntries, + ...?entry.stackedEntries, }.map((v) => v.uri).toSet(); final entryControllers = _controllers.where((v) => uris.contains(v.entry.uri)).toSet(); entryControllers.forEach((controller) { diff --git a/lib/widgets/viewer/visual/entry_page_view.dart b/lib/widgets/viewer/visual/entry_page_view.dart index 8f97f7fb4..dab017301 100644 --- a/lib/widgets/viewer/visual/entry_page_view.dart +++ b/lib/widgets/viewer/visual/entry_page_view.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:aves/app_mode.dart'; import 'package:aves/model/entry/entry.dart'; import 'package:aves/model/entry/extensions/props.dart'; -import 'package:aves/model/settings/enums/accessibility_animations.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/view_state.dart'; import 'package:aves/services/common/services.dart'; @@ -149,7 +148,7 @@ class _EntryPageViewState extends State with TickerProviderStateM ); } - final animate = context.select((v) => v.accessibilityAnimations.animate); + final animate = context.select((v) => v.animate); if (animate) { child = Consumer( builder: (context, info, child) => Hero( diff --git a/lib/widgets/welcome_page.dart b/lib/widgets/welcome_page.dart index 1bde23fa2..1ad184eab 100644 --- a/lib/widgets/welcome_page.dart +++ b/lib/widgets/welcome_page.dart @@ -137,7 +137,7 @@ class _WelcomePageState extends State { context.l10n.welcomeMessage, style: Theme.of(context).textTheme.headlineSmall, ); - final padding = isPortrait ? 16.0 : 8.0; + final double padding = isPortrait ? 16.0 : 8.0; return [ SizedBox(height: padding), ...(isPortrait diff --git a/plugins/aves_map/pubspec.lock b/plugins/aves_map/pubspec.lock index d6fd2d7b7..c28447595 100644 --- a/plugins/aves_map/pubspec.lock +++ b/plugins/aves_map/pubspec.lock @@ -89,10 +89,10 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: ead3532d99548140346684cf737a4c0a6f59f02f62ee4e406597f8364afbf1a2 + sha256: cc102fe5eeab1a740c321e1982706061e46259cd75115b3b3779195f21d57cc3 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" http: dependency: transitive description: diff --git a/plugins/aves_model/lib/src/actions/chip.dart b/plugins/aves_model/lib/src/actions/chip.dart index 46fc4cf10..a035ef5cf 100644 --- a/plugins/aves_model/lib/src/actions/chip.dart +++ b/plugins/aves_model/lib/src/actions/chip.dart @@ -3,6 +3,7 @@ enum ChipAction { goToCountryPage, goToPlacePage, goToTagPage, + goToExplorerPage, ratingOrGreater, ratingOrLower, reverse, diff --git a/plugins/aves_model/lib/src/metadata/enums.dart b/plugins/aves_model/lib/src/metadata/enums.dart index 70849fd55..061cf02b1 100644 --- a/plugins/aves_model/lib/src/metadata/enums.dart +++ b/plugins/aves_model/lib/src/metadata/enums.dart @@ -15,6 +15,8 @@ enum DateFieldSource { exifGpsDate, } +enum EntryConvertAction { convert, convertMotionPhotoToStillImage } + enum LengthUnit { px, percent } enum LocationEditAction { diff --git a/plugins/aves_model/lib/src/settings/enums.dart b/plugins/aves_model/lib/src/settings/enums.dart index e8088d359..fce0ea57e 100644 --- a/plugins/aves_model/lib/src/settings/enums.dart +++ b/plugins/aves_model/lib/src/settings/enums.dart @@ -14,7 +14,7 @@ enum DisplayRefreshRateMode { auto, highest, lowest } enum EntryBackground { black, white, checkered } -enum HomePageSetting { collection, albums, tags } +enum HomePageSetting { collection, albums, tags, explorer } enum KeepScreenOn { never, videoPlayback, viewerOnly, always } diff --git a/plugins/aves_report_crashlytics/pubspec.lock b/plugins/aves_report_crashlytics/pubspec.lock index 1f2ba2161..9aba64a77 100644 --- a/plugins/aves_report_crashlytics/pubspec.lock +++ b/plugins/aves_report_crashlytics/pubspec.lock @@ -5,10 +5,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "13e611501ef36044655852215b4f30aed81123654a4f55193d0051a0e8705658" + sha256: a315d1c444402c3fa468de626d33a1c666041c87e9e195e8fb355b7084aefcc1 url: "https://pub.dev" source: hosted - version: "1.3.36" + version: "1.3.38" async: dependency: transitive description: @@ -68,42 +68,42 @@ packages: dependency: "direct main" description: name: firebase_core - sha256: "0d436d29b16fd9844a098ece2a3ce75efc290e5fe0844d282c5e8987173b0d02" + sha256: "1e06b0538ab3108a61d895ee16951670b491c4a94fce8f2d30e5de7a5eca4b28" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.1" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + sha256: "1003a5a03a61fc9a22ef49f37cbcb9e46c86313a7b2e7029b9390cf8c6fc32cb" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "22fcb352744908224fc7be3caae254836099786acfe5df6e9fe901e9c2575a41" + sha256: "6643fe3dbd021e6ccfb751f7882b39df355708afbdeb4130fc50f9305a9d1a3d" url: "https://pub.dev" source: hosted - version: "2.17.1" + version: "2.17.2" firebase_crashlytics: dependency: "direct main" description: name: firebase_crashlytics - sha256: bc95715ae12f885a77a7f8d13eeee2e8cb3ea26c2b466c590f8cf4f5645a23b7 + sha256: "54c06fa45585ed77e978b049f8e488db7677313d5dc808c54d24384a6e5bf0c8" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: fdee44039a994250577179d792060ac9a0a211ee186e2ec59ef2007a138ba30f + sha256: "8ec63ebefe9233d3cdc744f75d5b88cf16b6241d8680e6284c2d272bcb0a10af" url: "https://pub.dev" source: hosted - version: "3.6.36" + version: "3.6.38" flutter: dependency: "direct main" description: flutter diff --git a/plugins/aves_services/pubspec.lock b/plugins/aves_services/pubspec.lock index d9007cb8d..bcb0ffe48 100644 --- a/plugins/aves_services/pubspec.lock +++ b/plugins/aves_services/pubspec.lock @@ -96,10 +96,10 @@ packages: dependency: transitive description: name: flutter_map - sha256: ead3532d99548140346684cf737a4c0a6f59f02f62ee4e406597f8364afbf1a2 + sha256: cc102fe5eeab1a740c321e1982706061e46259cd75115b3b3779195f21d57cc3 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" http: dependency: transitive description: diff --git a/plugins/aves_services_google/lib/src/map.dart b/plugins/aves_services_google/lib/src/map.dart index 0b6579804..26b29f345 100644 --- a/plugins/aves_services_google/lib/src/map.dart +++ b/plugins/aves_services_google/lib/src/map.dart @@ -142,7 +142,10 @@ class _EntryGoogleMapState extends State> { mediaMarkers.add(Marker( markerId: MarkerId(geoEntry.markerId!), consumeTapEvents: true, - icon: BitmapDescriptor.fromBytes(bytes), + icon: BytesMapBitmap( + bytes, + bitmapScaling: MapBitmapScaling.none, + ), position: point, onTap: () => widget.onMarkerTap?.call(geoEntry), // TODO TLAD [map] GoogleMap.onLongPress is not appropriate for mediaMarkers, so the call should be here when this is fixed: https://github.com/flutter/flutter/issues/107148 @@ -198,7 +201,10 @@ class _EntryGoogleMapState extends State> { markerId: const MarkerId('dot'), anchor: const Offset(.5, .5), consumeTapEvents: true, - icon: BitmapDescriptor.fromBytes(_dotMarkerBitmap!), + icon: BytesMapBitmap( + _dotMarkerBitmap!, + bitmapScaling: MapBitmapScaling.none, + ), position: _toServiceLatLng(dotLocation), zIndex: 1, ) diff --git a/plugins/aves_services_google/pubspec.lock b/plugins/aves_services_google/pubspec.lock index 87a4927f8..8ec1235dd 100644 --- a/plugins/aves_services_google/pubspec.lock +++ b/plugins/aves_services_google/pubspec.lock @@ -150,10 +150,10 @@ packages: dependency: transitive description: name: flutter_map - sha256: ead3532d99548140346684cf737a4c0a6f59f02f62ee4e406597f8364afbf1a2 + sha256: cc102fe5eeab1a740c321e1982706061e46259cd75115b3b3779195f21d57cc3 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -211,26 +211,26 @@ packages: dependency: "direct main" description: name: google_maps_flutter_android - sha256: b9ddc35f8b55fd70a196e43a61594abce5c41bc0843ea078a97679a9791749fe + sha256: e9a506d05a4c70b091ebe41b1f28f3b1efdfddfa4f81487018d9feebeff94709 url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.10.0" google_maps_flutter_ios: dependency: transitive description: name: google_maps_flutter_ios - sha256: d2d63ae17297a5b045ec115572c5a86fa4e53bb6eceaa0c6d200ac5ca69bfca4 + sha256: "7250837d9b2f1a40d7724d735aaa4fc574c5f0e120f3f43149aceace16095ccf" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.8.0" google_maps_flutter_platform_interface: dependency: "direct main" description: name: google_maps_flutter_platform_interface - sha256: "2bf21aa97edba4461282af5de693b354e589d09f695f7a6f80437d084a29687e" + sha256: bd60ca330e3c7763b95b477054adec338a522d982af73ecc520b232474063ac5 url: "https://pub.dev" source: hosted - version: "2.7.1" + version: "2.8.0" google_maps_flutter_web: dependency: transitive description: diff --git a/plugins/aves_services_huawei/pubspec.lock b/plugins/aves_services_huawei/pubspec.lock index c9ff9cee7..441d2e862 100644 --- a/plugins/aves_services_huawei/pubspec.lock +++ b/plugins/aves_services_huawei/pubspec.lock @@ -117,10 +117,10 @@ packages: dependency: transitive description: name: flutter_map - sha256: ead3532d99548140346684cf737a4c0a6f59f02f62ee4e406597f8364afbf1a2 + sha256: cc102fe5eeab1a740c321e1982706061e46259cd75115b3b3779195f21d57cc3 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" http: dependency: transitive description: diff --git a/plugins/aves_services_none/pubspec.lock b/plugins/aves_services_none/pubspec.lock index 719f2a1e2..cf4db6a55 100644 --- a/plugins/aves_services_none/pubspec.lock +++ b/plugins/aves_services_none/pubspec.lock @@ -103,10 +103,10 @@ packages: dependency: transitive description: name: flutter_map - sha256: ead3532d99548140346684cf737a4c0a6f59f02f62ee4e406597f8364afbf1a2 + sha256: cc102fe5eeab1a740c321e1982706061e46259cd75115b3b3779195f21d57cc3 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" http: dependency: transitive description: diff --git a/pubspec.lock b/pubspec.lock index 9229181ed..2bb7c4095 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -13,10 +13,10 @@ packages: dependency: transitive description: name: _flutterfire_internals - sha256: "13e611501ef36044655852215b4f30aed81123654a4f55193d0051a0e8705658" + sha256: a315d1c444402c3fa468de626d33a1c666041c87e9e195e8fb355b7084aefcc1 url: "https://pub.dev" source: hosted - version: "1.3.36" + version: "1.3.38" analyzer: dependency: transitive description: @@ -396,42 +396,42 @@ packages: dependency: transitive description: name: firebase_core - sha256: "0d436d29b16fd9844a098ece2a3ce75efc290e5fe0844d282c5e8987173b0d02" + sha256: "1e06b0538ab3108a61d895ee16951670b491c4a94fce8f2d30e5de7a5eca4b28" url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.1" firebase_core_platform_interface: dependency: transitive description: name: firebase_core_platform_interface - sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + sha256: "1003a5a03a61fc9a22ef49f37cbcb9e46c86313a7b2e7029b9390cf8c6fc32cb" url: "https://pub.dev" source: hosted - version: "5.0.0" + version: "5.1.0" firebase_core_web: dependency: transitive description: name: firebase_core_web - sha256: "22fcb352744908224fc7be3caae254836099786acfe5df6e9fe901e9c2575a41" + sha256: "6643fe3dbd021e6ccfb751f7882b39df355708afbdeb4130fc50f9305a9d1a3d" url: "https://pub.dev" source: hosted - version: "2.17.1" + version: "2.17.2" firebase_crashlytics: dependency: transitive description: name: firebase_crashlytics - sha256: bc95715ae12f885a77a7f8d13eeee2e8cb3ea26c2b466c590f8cf4f5645a23b7 + sha256: "54c06fa45585ed77e978b049f8e488db7677313d5dc808c54d24384a6e5bf0c8" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.0.2" firebase_crashlytics_platform_interface: dependency: transitive description: name: firebase_crashlytics_platform_interface - sha256: fdee44039a994250577179d792060ac9a0a211ee186e2ec59ef2007a138ba30f + sha256: "8ec63ebefe9233d3cdc744f75d5b88cf16b6241d8680e6284c2d272bcb0a10af" url: "https://pub.dev" source: hosted - version: "3.6.36" + version: "3.6.38" fixnum: dependency: transitive description: @@ -460,10 +460,10 @@ packages: dependency: "direct main" description: name: floating - sha256: ddcd7f28247746dbb62997c48c89d1824118676796df47fdc6f864f8d02849bc + sha256: c0e5f68b4f384cafcc974a0368c7b0232125ac9208a3433508269054026dcac4 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "4.0.0" fluster: dependency: "direct main" description: @@ -532,18 +532,18 @@ packages: dependency: "direct main" description: name: flutter_map - sha256: ead3532d99548140346684cf737a4c0a6f59f02f62ee4e406597f8364afbf1a2 + sha256: cc102fe5eeab1a740c321e1982706061e46259cd75115b3b3779195f21d57cc3 url: "https://pub.dev" source: hosted - version: "7.0.0" + version: "7.0.1" flutter_markdown: dependency: "direct main" description: name: flutter_markdown - sha256: "9921f9deda326f8a885e202b1e35237eadfc1345239a0f6f0f1ff287e047547f" + sha256: "85cc6f7daeae537844c92e2d56e2aff61b00095f8f77913b529ea4be12fc45ea" url: "https://pub.dev" source: hosted - version: "0.7.1" + version: "0.7.2+1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -643,26 +643,26 @@ packages: dependency: transitive description: name: google_maps_flutter_android - sha256: b9ddc35f8b55fd70a196e43a61594abce5c41bc0843ea078a97679a9791749fe + sha256: e9a506d05a4c70b091ebe41b1f28f3b1efdfddfa4f81487018d9feebeff94709 url: "https://pub.dev" source: hosted - version: "2.9.0" + version: "2.10.0" google_maps_flutter_ios: dependency: transitive description: name: google_maps_flutter_ios - sha256: d2d63ae17297a5b045ec115572c5a86fa4e53bb6eceaa0c6d200ac5ca69bfca4 + sha256: "7250837d9b2f1a40d7724d735aaa4fc574c5f0e120f3f43149aceace16095ccf" url: "https://pub.dev" source: hosted - version: "2.7.0" + version: "2.8.0" google_maps_flutter_platform_interface: dependency: transitive description: name: google_maps_flutter_platform_interface - sha256: "2bf21aa97edba4461282af5de693b354e589d09f695f7a6f80437d084a29687e" + sha256: bd60ca330e3c7763b95b477054adec338a522d982af73ecc520b232474063ac5 url: "https://pub.dev" source: hosted - version: "2.7.1" + version: "2.8.0" google_maps_flutter_web: dependency: transitive description: @@ -811,10 +811,10 @@ packages: dependency: transitive description: name: local_auth_android - sha256: "48dfb2d954da8ef6a77adfc93a29998f7729e9308eaa817e91dea4500317b2c8" + sha256: b77dc490fef9214e785c326bf11fa733feaa47675d0433f05f48b5caa486c8f7 url: "https://pub.dev" source: hosted - version: "1.0.39" + version: "1.0.40" local_auth_darwin: dependency: transitive description: @@ -1125,10 +1125,10 @@ packages: dependency: transitive description: name: permission_handler_android - sha256: "8bb852cd759488893805c3161d0b2b5db55db52f773dbb014420b304055ba2c5" + sha256: b29a799ca03be9f999aa6c39f7de5209482d638e6f857f6b93b0875c618b7e54 url: "https://pub.dev" source: hosted - version: "12.0.6" + version: "12.0.7" permission_handler_apple: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index c8742c81d..aa91c197d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,7 +7,7 @@ repository: https://github.com/deckerst/aves # - play changelog: /whatsnew/whatsnew-en-US # - izzy changelog: /fastlane/metadata/android/en-US/changelogs/XXX01.txt # - libre changelog: /fastlane/metadata/android/en-US/changelogs/XXX.txt -version: 1.11.3+122 +version: 1.11.4+123 publish_to: none environment: diff --git a/whatsnew/whatsnew-en-US b/whatsnew/whatsnew-en-US index dfb8b7c93..b1151387e 100644 --- a/whatsnew/whatsnew-en-US +++ b/whatsnew/whatsnew-en-US @@ -1,3 +1,4 @@ -In v1.11.3: -- show selected albums together in Collection +In v1.11.4: +- explore your collection with the... explorer +- convert your motion photos to stills in bulk Full changelog available on GitHub \ No newline at end of file