android 15 / api 35, predictive back

This commit is contained in:
Thibault Deckers 2024-07-13 01:32:30 +02:00
parent 0cb139b41a
commit 3d424eb82b
26 changed files with 224 additions and 218 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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