Files
plezy/lib/screens/focusable_detail_screen_mixin.dart
T
edde746 a9f0532f5f fix(ui): keep pushed screens clear of the Android navigation bar
Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is
36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16
disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only
SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on
API 35+ the window is edge-to-edge from the first frame and
MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation.

MainScreen's phone layout hides that. It supplies a bottomNavigationBar and
never sets extendBody, so Flutter's Scaffold strips padding.bottom from the
body MediaQuery and every tab is already safe. Routes pushed on the profile
navigator are full-screen siblings of MainScreen with no bottom bar, so they
receive the untouched inset and nothing consumes it - the last settings card
and the final log lines render under the back, home, and recents buttons.

Three shared hosts own most of those routes, so the inset is consumed there:
FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and
FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing
SliverSystemBottomInset, and the four screens that build their own Scaffold
around a CustomScrollView append it directly.

The new widget codifies the convention this repository had already written
down but open-coded - insets baked into the scroll content rather than a
SafeArea around the scroll view - so content still paints under the bar while
the scroll extent grows enough to bring the last row above it. It reads
padding from its own context and collapses to zero height wherever the inset
is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside
MainScreen's tab bodies. No platform branching, and it stacks additively with
the music detail screens' existing mini-player spacers, which is correct
because the mini-player itself floats above the navigation bar on a pushed
route.

Scroll views that are not sliver lists take the inset in their own padding:
the companion remote's ListView, the auth screen's scroll container, and the
two SliverFillRemaining sign-in forms, whose children size themselves from
the extent remaining before them and so cannot be helped by a trailing
sliver. The logs empty state is left alone for the same reason inverted - it
already fills the viewport, and a trailing inset would only add scroll slack.

Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation:
Settings, Logs, and Video Playback all end clear of the bar.

close #1766
2026-08-03 00:05:21 +02:00

258 lines
9.2 KiB
Dart

import 'package:flutter/material.dart';
import '../focus/focusable_action_bar.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart';
import '../media/media_item.dart';
import '../mixins/grid_focus_node_mixin.dart';
import '../services/settings_service.dart';
import '../utils/platform_detector.dart';
import '../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../widgets/settings_builder.dart';
import '../widgets/focusable_media_card.dart';
import '../widgets/media_card_sliver_layout.dart';
import '../widgets/overlay_sheet.dart';
import '../widgets/skeleton_media_card.dart';
import '../widgets/system_bottom_inset.dart';
/// Mixin that provides common focus navigation functionality for detail screens.
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
///
/// Classes using this mixin must also use [GridFocusNodeMixin].
mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocusNodeMixin<T> {
// Scroll controller for scrolling to top when app bar is focused
final ScrollController scrollController = ScrollController();
// Action bar key for accessing focus nodes
final GlobalKey<FocusableActionBarState> actionBarKey = GlobalKey<FocusableActionBarState>();
// Grid item focus
final FocusNode firstItemFocusNode = FocusNode(debugLabel: 'detail_first_item');
// App bar focus state
bool isAppBarFocused = false;
// Flag to prevent PopScope from exiting when BACK was handled by a key handler
bool backHandledByKeyEvent = false;
/// Called when items are available and we want to check if focus should be set
bool get hasItems;
/// Called to get the list of app bar action configurations
List<FocusableAction> getAppBarActions();
/// Dispose focus-related resources. Call this from your dispose() method.
void disposeFocusResources() {
scrollController.dispose();
firstItemFocusNode.dispose();
disposeGridFocusNodes();
}
/// Navigate from content to app bar
void navigateToAppBar() {
setState(() {
isAppBarFocused = true;
});
actionBarKey.currentState?.requestFocusOnFirst();
// Scroll to top to show the app bar
scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
}
/// Handle BACK key from content - navigate to app bar and set flag to prevent PopScope exit
void handleBackFromContent() {
if (getAppBarActions().isEmpty) {
if (mounted) Navigator.pop(context);
return;
}
backHandledByKeyEvent = true;
navigateToAppBar();
}
/// Navigate focus from app bar down to the grid
void navigateToGrid() {
if (!hasItems) return;
final targetIndex = shouldRestoreGridFocus ? lastFocusedGridIndex! : 0;
setState(() {
isAppBarFocused = false;
});
_focusNodeForIndex(targetIndex).requestFocus();
}
FocusNode _focusNodeForIndex(int index) => focusNodeForIndex(index, firstItemFocusNode, prefix: 'detail_grid_item');
/// Wrap [slivers] in the standard detail-screen scaffold — an overlay-sheet
/// host that defers route back to [handleBackNavigation], plus a Scaffold
/// with a CustomScrollView bound as the primary scroll view. Callers build
/// the slivers themselves (typically
/// `[appBar, ...header, ...buildStateSlivers(), grid]`); a trailing
/// [SliverSystemBottomInset] is appended so the last row clears the system
/// navigation bar. Screens that add their own trailing spacer (the music
/// detail screens reserve the floating mini-player) stack on top of it.
Widget buildDetailScaffold({required List<Widget> slivers}) {
return PrimaryScrollController(
controller: scrollController,
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: OverlaySheetHost(
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (handleBackNavigation() && mounted) {
Navigator.pop(context);
}
},
child: Scaffold(
body: CustomScrollView(primary: true, slivers: [...slivers, const SliverSystemBottomInset()]),
),
),
),
);
}
/// Handle back navigation for PopScope. Returns true if should pop.
bool handleBackNavigation() {
// If BACK was already handled by a key event, don't pop
if (backHandledByKeyEvent) {
backHandledByKeyEvent = false;
return false;
}
if (isAppBarFocused || getAppBarActions().isEmpty) {
return true;
} else {
// Focus app bar first
navigateToAppBar();
return false;
}
}
/// Build focusable app bar action widgets
List<Widget> buildFocusableAppBarActions() {
return [
FocusableActionBar(
key: actionBarKey,
onNavigateDown: navigateToGrid,
onBack: () => Navigator.pop(context),
actions: getAppBarActions(),
),
];
}
/// Auto-focus first item after load if in keyboard mode.
/// Call this from loadItems() after items are loaded.
void autoFocusFirstItemAfterLoad() {
if (mounted && hasItems) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (InputModeTracker.isKeyboardMode(context, listen: false)) {
setState(() {
isAppBarFocused = false;
});
firstItemFocusNode.requestFocus();
}
});
}
}
/// Build a standard focusable grid sliver for media items.
/// Used by collection, smart playlist, and music artist detail screens.
/// [shape] overrides the grid cell silhouette (e.g. [CardShape.square]
/// for album grids); null keeps the stock poster geometry.
///
/// Fully-loaded case of [buildSparseFocusableGrid]: every slot resolves to an
/// item, so the skeleton branch is unreachable.
Widget buildFocusableGrid({
required List<MediaItem> items,
required void Function(MediaItem source) onRefresh,
String? collectionId,
VoidCallback? onListRefresh,
CardShape? shape,
}) {
return buildSparseFocusableGrid(
totalItems: items.length,
itemAt: (index) => items[index],
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
shape: shape,
);
}
/// Sparse-loading counterpart of [buildFocusableGrid]. Renders [totalItems]
/// slots; for each, [itemAt] returns the loaded item or null if not yet
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
/// the caller can kick off a page fetch containing that index.
Widget buildSparseFocusableGrid({
required int totalItems,
required MediaItem? Function(int index) itemAt,
required void Function(MediaItem source) onRefresh,
void Function(int index)? onSkeletonVisible,
String? collectionId,
VoidCallback? onListRefresh,
CardShape? shape,
}) {
return SettingsBuilder(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
builder: (context) {
final svc = SettingsService.instance;
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
Widget buildTile(MediaCardSliverPosition position) {
final index = position.index;
final item = itemAt(index);
if (item == null) {
onSkeletonVisible?.call(index);
return const SkeletonMediaCard();
}
final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard(
key: Key(item.id),
item: item,
focusNode: focusNode,
semanticValue: _semanticPosition(position),
disableScale: position.disableScale,
onRefresh: onRefresh,
collectionId: collectionId,
onListRefresh: onListRefresh,
fullBleedImage: useFullCardLayout && position.isGrid,
cardShapeOverride: shape,
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
);
}
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: totalItems,
density: libraryDensity,
padding: const EdgeInsets.all(8),
fullBleedImage: useFullCardLayout,
shape: shape,
itemBuilder: (context, position) => buildTile(position),
);
},
);
}
String _semanticPosition(MediaCardSliverPosition position) {
if (!position.isGrid) {
return t.accessibility.rowPosition(row: position.index + 1, rowCount: position.itemCount);
}
final rowCount = (position.itemCount + position.columnCount - 1) ~/ position.columnCount;
return t.accessibility.rowColumnPosition(
row: position.index ~/ position.columnCount + 1,
rowCount: rowCount,
column: position.index % position.columnCount + 1,
columnCount: position.columnCount,
);
}
}