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
137 lines
4.5 KiB
Dart
137 lines
4.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import '../focus/input_mode_tracker.dart';
|
|
import '../focus/key_event_utils.dart';
|
|
import 'desktop_app_bar.dart';
|
|
import 'ios_status_bar_tap_scroll_to_top.dart';
|
|
import 'system_bottom_inset.dart';
|
|
|
|
/// A scaffold widget that wraps Focus + Scaffold + CustomScrollView
|
|
/// with consistent keyboard navigation handling and app bar styling.
|
|
///
|
|
/// This widget reduces boilerplate for screens that need:
|
|
/// - Keyboard navigation (back key handling)
|
|
/// - Custom scrollable content with slivers
|
|
/// - Consistent app bar with title and optional actions
|
|
///
|
|
/// Automatically focuses the first content item (skipping the app bar)
|
|
/// when in keyboard navigation mode.
|
|
class FocusedScrollScaffold extends StatefulWidget {
|
|
/// The title to display in the app bar.
|
|
/// Can be a Text widget or a more complex widget like Column.
|
|
final Widget title;
|
|
|
|
/// The list of slivers to display in the scroll view.
|
|
/// Should not include the app bar (it's added automatically).
|
|
final List<Widget> slivers;
|
|
|
|
/// Optional actions to display in the app bar (e.g., IconButton widgets).
|
|
final List<Widget>? actions;
|
|
|
|
/// Whether app-bar controls participate in keyboard/controller traversal.
|
|
///
|
|
/// They remain excluded while initial focus is assigned so the first
|
|
/// content control still receives focus when the screen opens.
|
|
final bool focusableAppBarActions;
|
|
|
|
/// Whether the app bar should remain visible when scrolling.
|
|
/// Defaults to true.
|
|
final bool pinned;
|
|
|
|
/// Whether to automatically add a back button.
|
|
/// Defaults to true.
|
|
final bool automaticallyImplyLeading;
|
|
|
|
/// Optional override for the back key handler.
|
|
/// When set, this callback is invoked instead of the default
|
|
/// [handleBackKeyNavigation] (which pops the current route).
|
|
final VoidCallback? onBackPressed;
|
|
|
|
const FocusedScrollScaffold({
|
|
super.key,
|
|
required this.title,
|
|
required this.slivers,
|
|
this.actions,
|
|
this.focusableAppBarActions = false,
|
|
this.pinned = true,
|
|
this.automaticallyImplyLeading = true,
|
|
this.onBackPressed,
|
|
});
|
|
|
|
@override
|
|
State<FocusedScrollScaffold> createState() => _FocusedScrollScaffoldState();
|
|
}
|
|
|
|
class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
|
|
final _scopeNode = FocusScopeNode();
|
|
bool _focusRequested = false;
|
|
bool _appBarFocusEnabled = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_scopeNode.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _requestInitialFocus() {
|
|
if (_focusRequested || !mounted || !InputModeTracker.isKeyboardMode(context, listen: false)) return;
|
|
_focusRequested = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (_scopeNode.focusedChild != null) return;
|
|
_scopeNode.requestFocus();
|
|
_scopeNode.nextFocus();
|
|
if (widget.focusableAppBarActions && !_appBarFocusEnabled) {
|
|
setState(() => _appBarFocusEnabled = true);
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_focusRequested && InputModeTracker.isKeyboardMode(context)) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _requestInitialFocus());
|
|
}
|
|
|
|
return Focus(
|
|
canRequestFocus: false,
|
|
onKeyEvent: (_, event) {
|
|
if (widget.onBackPressed != null) {
|
|
return handleBackKeyAction(event, widget.onBackPressed!);
|
|
}
|
|
return handleBackKeyNavigation(context, event);
|
|
},
|
|
child: FocusScope(
|
|
node: _scopeNode,
|
|
child: IosStatusBarTapScrollToTop(
|
|
child: Scaffold(
|
|
body: CustomScrollView(
|
|
slivers: [
|
|
if (!widget.focusableAppBarActions || !_appBarFocusEnabled)
|
|
ExcludeFocus(
|
|
child: CustomAppBar(
|
|
title: widget.title,
|
|
pinned: widget.pinned,
|
|
actions: widget.actions,
|
|
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
|
),
|
|
)
|
|
else
|
|
CustomAppBar(
|
|
title: widget.title,
|
|
pinned: widget.pinned,
|
|
actions: widget.actions,
|
|
automaticallyImplyLeading: widget.automaticallyImplyLeading,
|
|
),
|
|
...widget.slivers,
|
|
// Keeps the last row scrollable clear of the Android
|
|
// navigation bar / iOS home indicator; zero-height elsewhere.
|
|
const SliverSystemBottomInset(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|