android 15 / api 35, predictive back
This commit is contained in:
parent
0cb139b41a
commit
3d424eb82b
26 changed files with 224 additions and 218 deletions
|
@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file.
|
|||
|
||||
## <a id="unreleased"></a>[Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- predictive back support (inter-app)
|
||||
|
||||
### Changed
|
||||
|
||||
- target Android 15 (API 35)
|
||||
|
||||
## <a id="v1.11.5"></a>[v1.11.5] - 2024-07-11
|
||||
|
||||
### Added
|
||||
|
|
|
@ -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"] ?: "<NONE>"]
|
||||
|
|
|
@ -14,10 +14,6 @@
|
|||
android:name="android.software.leanback"
|
||||
android:required="false" />
|
||||
|
||||
<!--
|
||||
TODO TLAD [Android 14 (API 34)] request/handle READ_MEDIA_VISUAL_USER_SELECTED permission
|
||||
cf https://developer.android.com/about/versions/14/changes/partial-photo-video-access
|
||||
-->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
@ -35,10 +31,13 @@
|
|||
|
||||
<!-- to access media with original metadata with scoped storage (API >=29) -->
|
||||
<uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
|
||||
<!-- to provide a foreground service type, as required by Android 14 (API 34) -->
|
||||
<!-- TODO TLAD [Android 15 (API 35)] use `FOREGROUND_SERVICE_MEDIA_PROCESSING` -->
|
||||
<!-- to provide a foreground service type, as required from Android 14 (API 34) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"
|
||||
android:maxSdkVersion="34"
|
||||
tools:ignore="SystemPermissionTypo" />
|
||||
<uses-permission
|
||||
android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROCESSING"
|
||||
tools:ignore="SystemPermissionTypo" />
|
||||
<!-- TODO TLAD still needed to fetch map tiles / reverse geocoding / else ? check in release mode -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
@ -103,17 +102,12 @@
|
|||
</intent>
|
||||
</queries>
|
||||
|
||||
<!--
|
||||
as of Flutter v3.16.0, predictive back gesture does not work
|
||||
as expected when extending `FlutterFragmentActivity`
|
||||
so we disable `enableOnBackInvokedCallback`
|
||||
-->
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:appCategory="image"
|
||||
android:banner="@drawable/banner"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:fullBackupContent="@xml/full_backup_content"
|
||||
android:fullBackupOnly="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
|
@ -261,11 +255,14 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- anonymous service for analysis worker is specified here to provide service type -->
|
||||
<!-- TODO TLAD [Android 15 (API 35)] use `mediaProcessing` -->
|
||||
<!--
|
||||
anonymous service for analysis worker is specified here to provide service type:
|
||||
- `dataSync` for Android 14 (API 34)
|
||||
- `mediaProcessing` from Android 15 (API 35)
|
||||
-->
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:foregroundServiceType="dataSync|mediaProcessing"
|
||||
tools:node="merge" />
|
||||
|
||||
<service
|
||||
|
|
|
@ -179,13 +179,12 @@ class AnalysisWorker(context: Context, parameters: WorkerParameters) : Coroutine
|
|||
.setContentIntent(openAppIntent)
|
||||
.addAction(stopAction)
|
||||
.build()
|
||||
return if (Build.VERSION.SDK_INT >= 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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<AvesEntry>,
|
||||
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -174,7 +174,8 @@ class _AvesAppState extends State<AvesApp> 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<NavigatorState> _navigatorKey = GlobalKey(debugLabel: 'app-navigator');
|
||||
static ScreenBrightness? _screenBrightness;
|
||||
|
|
|
@ -55,7 +55,6 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
final List<StreamSubscription> _subscriptions = [];
|
||||
late CollectionLens _collection;
|
||||
final StreamController<DraggableScrollbarEvent> _draggableScrollBarEventStreamController = StreamController.broadcast();
|
||||
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
@ -80,7 +79,6 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
..forEach((sub) => sub.cancel())
|
||||
..clear();
|
||||
_collection.dispose();
|
||||
_doubleBackPopHandler.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -98,16 +96,12 @@ class _CollectionPageState extends State<CollectionPage> {
|
|||
builder: (context) {
|
||||
return AvesPopScope(
|
||||
handlers: [
|
||||
(context) {
|
||||
final selection = context.read<Selection<AvesEntry>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
TvNavigationPopHandler.pop,
|
||||
_doubleBackPopHandler.pop,
|
||||
APopHandler(
|
||||
canPop: (context) => context.select<Selection<AvesEntry>, bool>((v) => !v.isSelecting),
|
||||
onPopBlocked: (context) => context.read<Selection<AvesEntry>>().browse(),
|
||||
),
|
||||
tvNavigationPopHandler,
|
||||
doubleBackPopHandler,
|
||||
],
|
||||
child: GestureAreaProtectorStack(
|
||||
child: DirectionalSafeArea(
|
||||
|
|
|
@ -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<Settings, bool>((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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<bool Function(BuildContext context)> handlers;
|
||||
final List<PopHandler> 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 {}
|
||||
|
|
|
@ -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<Settings, bool>((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) {
|
||||
|
|
|
@ -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<SearchPage> {
|
||||
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<SearchPage> {
|
|||
_unregisterWidget(widget);
|
||||
widget.animation.removeStatusListener(_onAnimationStatusChanged);
|
||||
_searchFieldFocusNode.dispose();
|
||||
_doubleBackPopHandler.dispose();
|
||||
widget.delegate.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
@ -151,8 +148,8 @@ class _SearchPageState extends State<SearchPage> {
|
|||
),
|
||||
body: AvesPopScope(
|
||||
handlers: [
|
||||
TvNavigationPopHandler.pop,
|
||||
_doubleBackPopHandler.pop,
|
||||
tvNavigationPopHandler,
|
||||
doubleBackPopHandler,
|
||||
],
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -43,7 +43,6 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
final List<StreamSubscription> _subscriptions = [];
|
||||
final ValueNotifier<VolumeRelativeDirectory> _directory = ValueNotifier(const VolumeRelativeDirectory(volumePath: '', relativeDir: ''));
|
||||
final ValueNotifier<List<Directory>> _contents = ValueNotifier([]);
|
||||
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
|
||||
|
||||
Set<StorageVolume> get _volumes => androidFileUtils.storageVolumes;
|
||||
|
||||
|
@ -78,99 +77,95 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
..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<List<Directory>>(
|
||||
valueListenable: _contents,
|
||||
builder: (context, contents, child) {
|
||||
final durations = context.watch<DurationsData>();
|
||||
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<VolumeRelativeDirectory>(
|
||||
valueListenable: _directory,
|
||||
builder: (context, directory, child) {
|
||||
return AvesFilterChip(
|
||||
return ValueListenableBuilder<VolumeRelativeDirectory>(
|
||||
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<List<Directory>>(
|
||||
valueListenable: _contents,
|
||||
builder: (context, contents, child) {
|
||||
final durations = context.watch<DurationsData>();
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -191,12 +191,10 @@ class _FilterGrid<T extends CollectionFilter> extends StatefulWidget {
|
|||
|
||||
class _FilterGridState<T extends CollectionFilter> extends State<_FilterGrid<T>> {
|
||||
TileExtentController? _tileExtentController;
|
||||
final DoubleBackPopHandler _doubleBackPopHandler = DoubleBackPopHandler();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_tileExtentController?.dispose();
|
||||
_doubleBackPopHandler.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
@ -212,16 +210,12 @@ class _FilterGridState<T extends CollectionFilter> extends State<_FilterGrid<T>>
|
|||
);
|
||||
return AvesPopScope(
|
||||
handlers: [
|
||||
(context) {
|
||||
final selection = context.read<Selection<FilterGridItem<T>>>();
|
||||
if (selection.isSelecting) {
|
||||
selection.browse();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
TvNavigationPopHandler.pop,
|
||||
_doubleBackPopHandler.pop,
|
||||
APopHandler(
|
||||
canPop: (context) => context.select<Selection<FilterGridItem<T>>, bool>((v) => !v.isSelecting),
|
||||
onPopBlocked: (context) => context.read<Selection<FilterGridItem<T>>>().browse(),
|
||||
),
|
||||
tvNavigationPopHandler,
|
||||
doubleBackPopHandler,
|
||||
],
|
||||
child: TileExtentControllerProvider(
|
||||
controller: _tileExtentController!,
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in a new issue