diff --git a/CHANGELOG.md b/CHANGELOG.md index df80d4d71..b1af20889 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- predictive back support (inter-app) + +### Changed + +- target Android 15 (API 35) + ## [v1.11.5] - 2024-07-11 ### Added diff --git a/android/app/build.gradle b/android/app/build.gradle index 9fd533ff4..d81242fbf 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -44,7 +44,7 @@ if (keystorePropertiesFile.exists()) { android { namespace 'deckers.thibault.aves' - compileSdk 34 + compileSdk 35 // cf https://developer.android.com/studio/projects/install-ndk#default-ndk-per-agp ndkVersion '26.1.10909125' @@ -66,7 +66,7 @@ android { defaultConfig { applicationId packageName minSdk flutter.minSdkVersion - targetSdk 34 + targetSdk 35 versionCode flutterVersionCode.toInteger() versionName flutterVersionName manifestPlaceholders = [googleApiKey: keystoreProperties["googleApiKey"] ?: ""] diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 99b8fa55a..114582d4a 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -14,10 +14,6 @@ android:name="android.software.leanback" android:required="false" /> - @@ -35,10 +31,13 @@ - - + + @@ -103,17 +102,12 @@ - - - + = 34) { - // from Android 14 (API 34), foreground service type is mandatory - // despite the sample code omitting it at: + return if (Build.VERSION.SDK_INT == 34) { + // from Android 14 (API 34), foreground service type is mandatory for long-running workers: // https://developer.android.com/guide/background/persistent/how-to/long-running - // TODO TLAD [Android 15 (API 35)] use `FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING` - val type = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC - ForegroundInfo(NOTIFICATION_ID, notification, type) + ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else if (Build.VERSION.SDK_INT >= 35) { + ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROCESSING) } else { ForegroundInfo(NOTIFICATION_ID, notification) } 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 8954bfca1..079ad5d30 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/HomeWidgetProvider.kt @@ -42,7 +42,7 @@ class HomeWidgetProvider : AppWidgetProvider() { val widgetInfo = appWidgetManager.getAppWidgetOptions(widgetId) val pendingResult = goAsync() - defaultScope.launch() { + defaultScope.launch { val backgroundProps = getProps(context, widgetId, widgetInfo, drawEntryImage = false) updateWidgetImage(context, appWidgetManager, widgetId, backgroundProps) 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 0166fdff4..00bb5fcad 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/MainActivity.kt @@ -54,7 +54,7 @@ import deckers.thibault.aves.channel.streams.SettingsChangeStreamHandler 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.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall @@ -66,7 +66,7 @@ import kotlinx.coroutines.launch import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap -open class MainActivity : FlutterFragmentActivity() { +open class MainActivity : FlutterActivity() { private val defaultScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private lateinit var mediaStoreChangeStreamHandler: MediaStoreChangeStreamHandler diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt index 21b7dd13e..b6eb80626 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/AnalysisHandler.kt @@ -1,7 +1,6 @@ package deckers.thibault.aves.channel.calls import android.content.Context -import androidx.activity.ComponentActivity import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequestBuilder @@ -10,6 +9,7 @@ import androidx.work.WorkManager import androidx.work.workDataOf import deckers.thibault.aves.AnalysisWorker import deckers.thibault.aves.utils.FlutterUtils +import io.flutter.embedding.android.FlutterActivity import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.CoroutineScope @@ -19,7 +19,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -class AnalysisHandler(private val activity: ComponentActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler { +class AnalysisHandler(private val activity: FlutterActivity, private val onAnalysisCompleted: () -> Unit) : MethodChannel.MethodCallHandler { private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { @@ -37,10 +37,11 @@ class AnalysisHandler(private val activity: ComponentActivity, private val onAna return } - activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) - .edit() - .putLong(AnalysisWorker.CALLBACK_HANDLE_KEY, callbackHandle) - .apply() + val preferences = activity.getSharedPreferences(AnalysisWorker.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) + with(preferences.edit()) { + putLong(AnalysisWorker.CALLBACK_HANDLE_KEY, callbackHandle) + apply() + } result.success(true) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt index c614ab6ea..4ccb395af 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/GlobalSearchHandler.kt @@ -28,10 +28,11 @@ class GlobalSearchHandler(private val context: Context) : MethodCallHandler { return } - context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) - .edit() - .putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle) - .apply() + val preferences = context.getSharedPreferences(SearchSuggestionsProvider.SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE) + with(preferences.edit()) { + putLong(SearchSuggestionsProvider.CALLBACK_HANDLE_KEY, callbackHandle) + apply() + } result.success(true) } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt index 05a6ba398..336cb4546 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/calls/SecurityHandler.kt @@ -44,7 +44,8 @@ class SecurityHandler(private val context: Context) : MethodCallHandler { return } - with(getStore().edit()) { + val preferences = getStore() + with(preferences.edit()) { when (value) { is Boolean -> putBoolean(key, value) is Float -> putFloat(key, value) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt index c6f10000e..42de00276 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/channel/streams/ImageOpStreamHandler.kt @@ -1,10 +1,10 @@ package deckers.thibault.aves.channel.streams +import android.app.Activity import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log -import androidx.fragment.app.FragmentActivity import deckers.thibault.aves.channel.calls.MediaEditHandler.Companion.cancelledOps import deckers.thibault.aves.model.AvesEntry import deckers.thibault.aves.model.FieldMap @@ -21,9 +21,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch -import java.util.* -class ImageOpStreamHandler(private val activity: FragmentActivity, private val arguments: Any?) : EventChannel.StreamHandler { +class ImageOpStreamHandler(private val activity: Activity, private val arguments: Any?) : EventChannel.StreamHandler { private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private lateinit var eventSink: EventSink private lateinit var handler: Handler diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt index ddb1e7096..94a435c8f 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/decoder/VideoThumbnailGlideModule.kt @@ -152,12 +152,12 @@ internal class VideoThumbnailFetcher(private val model: VideoThumbnail, val widt @RequiresApi(Build.VERSION_CODES.P) private fun getBitmapParams() = MediaMetadataRetriever.BitmapParams().apply { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + preferredConfig = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // improved precision with the same memory cost as `ARGB_8888` (4 bytes per pixel) // for wide-gamut and HDR content which does not require alpha blending - setPreferredConfig(Bitmap.Config.RGBA_1010102) + Bitmap.Config.RGBA_1010102 } else { - setPreferredConfig(Bitmap.Config.ARGB_8888) + Bitmap.Config.ARGB_8888 } } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt index 36afac482..13f66f5da 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt @@ -29,7 +29,6 @@ import deckers.thibault.aves.metadata.GeoTiffKeys import deckers.thibault.aves.metadata.Metadata import deckers.thibault.aves.metadata.metadataextractor.mpf.MpfReader import deckers.thibault.aves.utils.LogUtils -import deckers.thibault.aves.utils.MemoryUtils import java.io.BufferedInputStream import java.io.IOException import java.io.InputStream 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 d992f1cef..9d4f6dea3 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 @@ -12,7 +12,6 @@ import android.os.Binder import android.os.Build import android.util.Log import androidx.exifinterface.media.ExifInterface -import androidx.fragment.app.FragmentActivity import com.bumptech.glide.Glide import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy @@ -196,7 +195,7 @@ abstract class ImageProvider { } suspend fun convertMultiple( - activity: FragmentActivity, + activity: Activity, imageExportMimeType: String, targetDir: String, entries: List, @@ -255,7 +254,7 @@ abstract class ImageProvider { } private suspend fun convertSingle( - activity: FragmentActivity, + activity: Activity, sourceEntry: AvesEntry, targetDir: String, targetDirDocFile: DocumentFileCompat?, @@ -334,7 +333,7 @@ abstract class ImageProvider { .diskCacheStrategy(DiskCacheStrategy.NONE) .skipMemoryCache(true) - target = Glide.with(activity) + target = Glide.with(activity.applicationContext) .asBitmap() .apply(glideOptions) .load(model) @@ -396,7 +395,7 @@ abstract class ImageProvider { return newFields } finally { // clearing Glide target should happen after effectively writing the bitmap - Glide.with(activity).clear(target) + Glide.with(activity.applicationContext).clear(target) resolution.replacementFile?.delete() } diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MathUtils.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MathUtils.kt index 7002c07f0..8935cd712 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MathUtils.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MathUtils.kt @@ -5,5 +5,5 @@ import kotlin.math.pow object MathUtils { fun highestPowerOf2(x: Int): Int = highestPowerOf2(x.toDouble()) - fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt() + private fun highestPowerOf2(x: Double): Int = if (x < 1) 0 else 2.toDouble().pow(log2(x).toInt()).toInt() } \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt index 1430472b0..8920a5d40 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/utils/MimeTypes.kt @@ -17,8 +17,8 @@ object MimeTypes { private const val ICO = "image/x-icon" const val JPEG = "image/jpeg" const val PNG = "image/png" - const val PSD_VND = "image/vnd.adobe.photoshop" - const val PSD_X = "image/x-photoshop" + private const val PSD_VND = "image/vnd.adobe.photoshop" + private const val PSD_X = "image/x-photoshop" const val TIFF = "image/tiff" private const val WBMP = "image/vnd.wap.wbmp" const val WEBP = "image/webp" diff --git a/lib/widgets/about/about_tv_page.dart b/lib/widgets/about/about_tv_page.dart index e8f0d8c68..4e01d8e43 100644 --- a/lib/widgets/about/about_tv_page.dart +++ b/lib/widgets/about/about_tv_page.dart @@ -21,7 +21,7 @@ class AboutTvPage extends StatelessWidget { Widget build(BuildContext context) { return AvesScaffold( body: AvesPopScope( - handlers: const [TvNavigationPopHandler.pop], + handlers: [tvNavigationPopHandler], child: Row( children: [ TvRail( diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index 6ee414f49..f279c2984 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -174,7 +174,8 @@ class _AvesAppState extends State with WidgetsBindingObserver { // Flutter has various page transition implementations for Android: // - `FadeUpwardsPageTransitionsBuilder` on Oreo / API 27 and below // - `OpenUpwardsPageTransitionsBuilder` on Pie / API 28 - // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.0.0) + // - `ZoomPageTransitionsBuilder` on Android 10 / API 29 and above (default in Flutter v3.22.0) + // - `PredictiveBackPageTransitionsBuilder` for Android 15 / API 35 intra-app predictive back static const defaultPageTransitionsBuilder = FadeUpwardsPageTransitionsBuilder(); static final GlobalKey _navigatorKey = GlobalKey(debugLabel: 'app-navigator'); static ScreenBrightness? _screenBrightness; diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index 5a6812e99..777a4f262 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -55,7 +55,6 @@ class _CollectionPageState extends State { final List _subscriptions = []; late CollectionLens _collection; final StreamController _draggableScrollBarEventStreamController = StreamController.broadcast(); - final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); @override void initState() { @@ -80,7 +79,6 @@ class _CollectionPageState extends State { ..forEach((sub) => sub.cancel()) ..clear(); _collection.dispose(); - _doubleBackPopHandler.dispose(); super.dispose(); } @@ -98,16 +96,12 @@ class _CollectionPageState extends State { builder: (context) { return AvesPopScope( handlers: [ - (context) { - final selection = context.read>(); - if (selection.isSelecting) { - selection.browse(); - return false; - } - return true; - }, - TvNavigationPopHandler.pop, - _doubleBackPopHandler.pop, + APopHandler( + canPop: (context) => context.select, bool>((v) => !v.isSelecting), + onPopBlocked: (context) => context.read>().browse(), + ), + tvNavigationPopHandler, + doubleBackPopHandler, ], child: GestureAreaProtectorStack( child: DirectionalSafeArea( diff --git a/lib/widgets/common/behaviour/pop/double_back.dart b/lib/widgets/common/behaviour/pop/double_back.dart index 224e264bb..b5a2b3729 100644 --- a/lib/widgets/common/behaviour/pop/double_back.dart +++ b/lib/widgets/common/behaviour/pop/double_back.dart @@ -1,48 +1,49 @@ import 'dart:async'; import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/common/services.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:overlay_support/overlay_support.dart'; +import 'package:provider/provider.dart'; -class DoubleBackPopHandler { +final DoubleBackPopHandler doubleBackPopHandler = DoubleBackPopHandler._private(); + +class DoubleBackPopHandler extends PopHandler { bool _backOnce = false; Timer? _backTimer; - DoubleBackPopHandler() { - if (kFlutterMemoryAllocationsEnabled) { - FlutterMemoryAllocations.instance.dispatchObjectCreated( - library: 'aves', - className: '$DoubleBackPopHandler', - object: this, - ); - } + DoubleBackPopHandler._private(); + + @override + bool canPop(BuildContext context) { + if (context.select((s) => !s.mustBackTwiceToExit)) return true; + if (Navigator.canPop(context)) return true; + return false; } - void dispose() { - if (kFlutterMemoryAllocationsEnabled) { - FlutterMemoryAllocations.instance.dispatchObjectDisposed(object: this); - } - _stopBackTimer(); - } - - bool pop(BuildContext context) { - if (!Navigator.canPop(context) && settings.mustBackTwiceToExit && !_backOnce) { + @override + void onPopBlocked(BuildContext context) { + if (_backOnce) { + if (Navigator.canPop(context)) { + Navigator.maybeOf(context)?.pop(); + } else { + // exit + reportService.log('Exit by pop'); + PopExitNotification().dispatch(context); + SystemNavigator.pop(); + } + } else { _backOnce = true; - _stopBackTimer(); + _backTimer?.cancel(); _backTimer = Timer(ADurations.doubleBackTimerDelay, () => _backOnce = false); toast( context.l10n.doubleBackExitMessage, duration: ADurations.doubleBackTimerDelay, ); - return false; } - return true; - } - - void _stopBackTimer() { - _backTimer?.cancel(); } } diff --git a/lib/widgets/common/behaviour/pop/scope.dart b/lib/widgets/common/behaviour/pop/scope.dart index c2f7bfb20..f75ca980e 100644 --- a/lib/widgets/common/behaviour/pop/scope.dart +++ b/lib/widgets/common/behaviour/pop/scope.dart @@ -1,11 +1,9 @@ -import 'package:aves/services/common/services.dart'; -import 'package:flutter/services.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/widgets.dart'; -// as of Flutter v3.3.10, the resolution order of multiple `WillPopScope` is random -// so this widget combines multiple handlers with a guaranteed order +// this widget combines multiple pop handlers with a guaranteed order class AvesPopScope extends StatelessWidget { - final List handlers; + final List handlers; final Widget child; const AvesPopScope({ @@ -16,21 +14,12 @@ class AvesPopScope extends StatelessWidget { @override Widget build(BuildContext context) { + final blocker = handlers.firstWhereOrNull((v) => !v.canPop(context)); return PopScope( - canPop: false, + canPop: blocker == null, onPopInvoked: (didPop) { - if (didPop) return; - - final shouldPop = handlers.fold(true, (prev, v) => prev ? v(context) : false); - if (shouldPop) { - if (Navigator.canPop(context)) { - Navigator.maybeOf(context)?.pop(); - } else { - // exit - reportService.log('Exit by pop'); - PopExitNotification().dispatch(context); - SystemNavigator.pop(); - } + if (!didPop) { + blocker?.onPopBlocked(context); } }, child: child, @@ -38,5 +27,28 @@ class AvesPopScope extends StatelessWidget { } } +abstract class PopHandler { + bool canPop(BuildContext context); + + void onPopBlocked(BuildContext context); +} + +class APopHandler implements PopHandler { + final bool Function(BuildContext context) _canPop; + final void Function(BuildContext context) _onPopBlocked; + + APopHandler({ + required bool Function(BuildContext context) canPop, + required void Function(BuildContext context) onPopBlocked, + }) : _canPop = canPop, + _onPopBlocked = onPopBlocked; + + @override + bool canPop(BuildContext context) => _canPop(context); + + @override + void onPopBlocked(BuildContext context) => _onPopBlocked(context); +} + @immutable class PopExitNotification extends Notification {} diff --git a/lib/widgets/common/behaviour/pop/tv_navigation.dart b/lib/widgets/common/behaviour/pop/tv_navigation.dart index b55953eb8..b895aa917 100644 --- a/lib/widgets/common/behaviour/pop/tv_navigation.dart +++ b/lib/widgets/common/behaviour/pop/tv_navigation.dart @@ -3,6 +3,7 @@ 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/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/behaviour/pop/scope.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'; @@ -11,18 +12,25 @@ import 'package:aves_model/aves_model.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -// address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality -class TvNavigationPopHandler { - static bool pop(BuildContext context) { - if (!settings.useTvLayout || _isHome(context)) { - return true; - } +final TvNavigationPopHandler tvNavigationPopHandler = TvNavigationPopHandler._private(); +// address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality +class TvNavigationPopHandler implements PopHandler { + TvNavigationPopHandler._private(); + + @override + bool canPop(BuildContext context) { + if (context.select((s) => !s.useTvLayout)) return true; + if (_isHome(context)) return true; + return false; + } + + @override + void onPopBlocked(BuildContext context) { Navigator.maybeOf(context)?.pushAndRemoveUntil( _getHomeRoute(), (route) => false, ); - return false; } static bool _isHome(BuildContext context) { diff --git a/lib/widgets/common/search/page.dart b/lib/widgets/common/search/page.dart index 65bbddce9..6bbf50f8f 100644 --- a/lib/widgets/common/search/page.dart +++ b/lib/widgets/common/search/page.dart @@ -1,4 +1,3 @@ - import 'package:aves/theme/durations.dart'; import 'package:aves/theme/themes.dart'; import 'package:aves/utils/debouncer.dart'; @@ -31,7 +30,6 @@ class SearchPage extends StatefulWidget { class _SearchPageState extends State { final Debouncer _debouncer = Debouncer(delay: ADurations.searchDebounceDelay); final FocusNode _searchFieldFocusNode = FocusNode(); - final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); @override void initState() { @@ -55,7 +53,6 @@ class _SearchPageState extends State { _unregisterWidget(widget); widget.animation.removeStatusListener(_onAnimationStatusChanged); _searchFieldFocusNode.dispose(); - _doubleBackPopHandler.dispose(); widget.delegate.dispose(); super.dispose(); } @@ -151,8 +148,8 @@ class _SearchPageState extends State { ), body: AvesPopScope( handlers: [ - TvNavigationPopHandler.pop, - _doubleBackPopHandler.pop, + tvNavigationPopHandler, + doubleBackPopHandler, ], child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 9b8296419..79e7259a1 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -67,7 +67,7 @@ class AppDebugPage extends StatelessWidget { ], ), body: AvesPopScope( - handlers: const [TvNavigationPopHandler.pop], + handlers: [tvNavigationPopHandler], child: SafeArea( child: ListView( padding: const EdgeInsets.all(8), diff --git a/lib/widgets/explorer/explorer_page.dart b/lib/widgets/explorer/explorer_page.dart index 6119e7098..a414a8d95 100644 --- a/lib/widgets/explorer/explorer_page.dart +++ b/lib/widgets/explorer/explorer_page.dart @@ -43,7 +43,6 @@ 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; @@ -78,99 +77,95 @@ class _ExplorerPageState extends State { ..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( + return ValueListenableBuilder( + valueListenable: _directory, + builder: (context, directory, child) { + final atRoot = directory.relativeDir.isEmpty; + return AvesPopScope( + handlers: [ + APopHandler( + canPop: (context) => atRoot, + onPopBlocked: (context) => _goTo(pContext.dirname(_currentDirectoryPath)), + ), + tvNavigationPopHandler, + doubleBackPopHandler, + ], + 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: AvesFilterChip( filter: PathFilter(_currentDirectoryPath), maxWidth: double.infinity, onTap: (filter) => _goToCollectionPage(context, filter), onLongPress: null, - ); - }, + ), + ), ), - ), + ], ), - ], + ), ), - ), - ), + ); + }, ); } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 1d69ff71f..9cc9fed3b 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -191,12 +191,10 @@ class _FilterGrid extends StatefulWidget { class _FilterGridState extends State<_FilterGrid> { TileExtentController? _tileExtentController; - final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler(); @override void dispose() { _tileExtentController?.dispose(); - _doubleBackPopHandler.dispose(); super.dispose(); } @@ -212,16 +210,12 @@ class _FilterGridState extends State<_FilterGrid> ); return AvesPopScope( handlers: [ - (context) { - final selection = context.read>>(); - if (selection.isSelecting) { - selection.browse(); - return false; - } - return true; - }, - TvNavigationPopHandler.pop, - _doubleBackPopHandler.pop, + APopHandler( + canPop: (context) => context.select>, bool>((v) => !v.isSelecting), + onPopBlocked: (context) => context.read>>().browse(), + ), + tvNavigationPopHandler, + doubleBackPopHandler, ], child: TileExtentControllerProvider( controller: _tileExtentController!, diff --git a/lib/widgets/settings/settings_tv_page.dart b/lib/widgets/settings/settings_tv_page.dart index 2f74d3d52..753d5042c 100644 --- a/lib/widgets/settings/settings_tv_page.dart +++ b/lib/widgets/settings/settings_tv_page.dart @@ -16,7 +16,7 @@ class SettingsTvPage extends StatelessWidget { Widget build(BuildContext context) { return AvesScaffold( body: AvesPopScope( - handlers: const [TvNavigationPopHandler.pop], + handlers: [tvNavigationPopHandler], child: Row( children: [ TvRail(