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
This commit is contained in:
edde746
2026-08-03 00:05:21 +02:00
parent fb0613e3db
commit a9f0532f5f
12 changed files with 221 additions and 7 deletions
+1 -1
View File
@@ -263,7 +263,7 @@ class _AuthScreenState extends State<AuthScreen> {
body: Center( body: Center(
child: Container( child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400), constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: const EdgeInsets.all(24), padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: isDesktop child: isDesktop
? Row( ? Row(
crossAxisAlignment: .center, crossAxisAlignment: .center,
@@ -203,7 +203,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> {
), ),
Expanded( Expanded(
child: ListView( child: ListView(
padding: const EdgeInsets.all(16), padding: .fromLTRB(16, 16, 16, 16 + MediaQuery.paddingOf(context).bottom),
children: [ children: [
SegmentedButton<int>( SegmentedButton<int>(
showSelectedIcon: false, showSelectedIcon: false,
@@ -13,6 +13,7 @@ import '../widgets/focusable_media_card.dart';
import '../widgets/media_card_sliver_layout.dart'; import '../widgets/media_card_sliver_layout.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../widgets/skeleton_media_card.dart'; import '../widgets/skeleton_media_card.dart';
import '../widgets/system_bottom_inset.dart';
/// Mixin that provides common focus navigation functionality for detail screens. /// Mixin that provides common focus navigation functionality for detail screens.
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
@@ -86,7 +87,10 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
/// host that defers route back to [handleBackNavigation], plus a Scaffold /// host that defers route back to [handleBackNavigation], plus a Scaffold
/// with a CustomScrollView bound as the primary scroll view. Callers build /// with a CustomScrollView bound as the primary scroll view. Callers build
/// the slivers themselves (typically /// the slivers themselves (typically
/// `[appBar, ...header, ...buildStateSlivers(), grid]`). /// `[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}) { Widget buildDetailScaffold({required List<Widget> slivers}) {
return PrimaryScrollController( return PrimaryScrollController(
controller: scrollController, controller: scrollController,
@@ -100,7 +104,9 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
Navigator.pop(context); Navigator.pop(context);
} }
}, },
child: Scaffold(body: CustomScrollView(primary: true, slivers: slivers)), child: Scaffold(
body: CustomScrollView(primary: true, slivers: [...slivers, const SliverSystemBottomInset()]),
),
), ),
), ),
); );
+2
View File
@@ -12,6 +12,7 @@ import '../media/media_server_client.dart';
import '../media/media_sort.dart'; import '../media/media_sort.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../widgets/settings_builder.dart'; import '../widgets/settings_builder.dart';
import '../widgets/system_bottom_inset.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/continuation_pagination_coordinator.dart'; import '../utils/continuation_pagination_coordinator.dart';
import '../utils/error_message_utils.dart'; import '../utils/error_message_utils.dart';
@@ -611,6 +612,7 @@ class _HubDetailScreenState extends State<HubDetailScreen>
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent, onBack: handleBackFromContent,
), ),
const SliverSystemBottomInset(),
], ],
), ),
), ),
@@ -36,6 +36,7 @@ import '../focusable_detail_screen_mixin.dart';
import '../libraries/content_state_builder.dart'; import '../libraries/content_state_builder.dart';
import '../../mixins/grid_focus_node_mixin.dart'; import '../../mixins/grid_focus_node_mixin.dart';
import '../../widgets/overlay_sheet.dart'; import '../../widgets/overlay_sheet.dart';
import '../../widgets/system_bottom_inset.dart';
/// Screen to display the contents of a playlist /// Screen to display the contents of a playlist
class PlaylistDetailScreen extends StatefulWidget { class PlaylistDetailScreen extends StatefulWidget {
@@ -757,6 +758,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
onBack: handleBackFromContent, onBack: handleBackFromContent,
), ),
], ],
const SliverSystemBottomInset(),
], ],
); );
@@ -461,7 +461,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: Center(child: _buildQuickConnectPanel(theme)), child: Center(child: _buildQuickConnectPanel(theme)),
), ),
) )
@@ -138,7 +138,7 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
SliverFillRemaining( SliverFillRemaining(
hasScrollBody: false, hasScrollBody: false,
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: Center( child: Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 420),
+6 -1
View File
@@ -26,6 +26,7 @@ import '../../utils/platform_detector.dart';
import '../../utils/snackbar_helper.dart'; import '../../utils/snackbar_helper.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/desktop_app_bar.dart';
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
import '../../widgets/system_bottom_inset.dart';
const previousStartupFailureKey = Key('logs-previous-startup-failure'); const previousStartupFailureKey = Key('logs-previous-startup-failure');
@@ -419,7 +420,7 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
?_buildPreviousFailureBanner(theme), ?_buildPreviousFailureBanner(theme),
if (_logs.isEmpty) if (_logs.isEmpty)
SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable)))
else else ...[
SliverPadding( SliverPadding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
sliver: SliverToBoxAdapter( sliver: SliverToBoxAdapter(
@@ -435,6 +436,10 @@ class _LogsScreenState extends State<LogsScreen> with MountedSetStateMixin {
), ),
), ),
), ),
// Only the log body needs it: the empty state already fills
// the viewport, so a trailing inset would just add slack.
const SliverSystemBottomInset(),
],
], ],
), ),
), ),
@@ -43,6 +43,7 @@ import '../../widgets/overlay_sheet.dart';
import '../../widgets/setting_tile.dart'; import '../../widgets/setting_tile.dart';
import '../../widgets/settings_builder.dart'; import '../../widgets/settings_builder.dart';
import '../../widgets/settings_section.dart'; import '../../widgets/settings_section.dart';
import '../../widgets/system_bottom_inset.dart';
import '../../profiles/active_profile_provider.dart'; import '../../profiles/active_profile_provider.dart';
import '../../profiles/profile.dart'; import '../../profiles/profile.dart';
import '../../watch_together/services/watch_together_relay_endpoint.dart'; import '../../watch_together/services/watch_together_relay_endpoint.dart';
@@ -224,6 +225,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
const SizedBox(height: 24), const SizedBox(height: 24),
]), ]),
), ),
const SliverSystemBottomInset(),
], ],
), ),
), ),
+4
View File
@@ -3,6 +3,7 @@ import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import 'desktop_app_bar.dart'; import 'desktop_app_bar.dart';
import 'ios_status_bar_tap_scroll_to_top.dart'; import 'ios_status_bar_tap_scroll_to_top.dart';
import 'system_bottom_inset.dart';
/// A scaffold widget that wraps Focus + Scaffold + CustomScrollView /// A scaffold widget that wraps Focus + Scaffold + CustomScrollView
/// with consistent keyboard navigation handling and app bar styling. /// with consistent keyboard navigation handling and app bar styling.
@@ -122,6 +123,9 @@ class _FocusedScrollScaffoldState extends State<FocusedScrollScaffold> {
automaticallyImplyLeading: widget.automaticallyImplyLeading, automaticallyImplyLeading: widget.automaticallyImplyLeading,
), ),
...widget.slivers, ...widget.slivers,
// Keeps the last row scrollable clear of the Android
// navigation bar / iOS home indicator; zero-height elsewhere.
const SliverSystemBottomInset(),
], ],
), ),
), ),
+34
View File
@@ -0,0 +1,34 @@
import 'package:flutter/widgets.dart';
/// Reserves the system bottom inset — Android's navigation bar, iOS's home
/// indicator — at the end of a scroll view.
///
/// Plezy always runs edge-to-edge on Android: `targetSdk` is 36, and Android
/// 15 enforces edge-to-edge for apps targeting 35+ while Android 16 removes
/// the opt-out entirely. So `MediaQuery.padding.bottom` is a real overlap on
/// phones (~48dp with 3-button navigation, less with gesture navigation) and
/// every full-screen pushed route has to consume it.
///
/// The convention is to bake the inset into the scroll *content* rather than
/// wrapping the scroll view in a [SafeArea]: content keeps painting under the
/// bar, and only the scroll extent grows so the last row can be scrolled clear
/// of it. See `media_detail_screen.dart` and `discover_screen.dart` for the
/// hand-rolled precedents this widget replaces.
///
/// Collapses to zero height wherever the bottom padding is already zero, so it
/// needs no platform branching: desktop and Android TV report no inset, tvOS
/// has it zeroed by `_AppleTvScale`, and inside `MainScreen`'s tab bodies
/// Flutter's [Scaffold] has already stripped it because a `bottomNavigationBar`
/// is present.
///
/// Reading the padding from this widget's own [BuildContext] — instead of the
/// enclosing screen's — keeps it correct below any ancestor that removes
/// padding, and limits inset-driven rebuilds to the spacer itself.
class SliverSystemBottomInset extends StatelessWidget {
const SliverSystemBottomInset({super.key});
@override
Widget build(BuildContext context) {
return SliverPadding(padding: .only(bottom: MediaQuery.paddingOf(context).bottom));
}
}
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focusable_action_bar.dart';
import 'package:plezy/mixins/grid_focus_node_mixin.dart';
import 'package:plezy/screens/focusable_detail_screen_mixin.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/focused_scroll_scaffold.dart';
import 'package:plezy/widgets/system_bottom_inset.dart';
/// Stand-in for the Android 3-button navigation bar (#1766).
const _navBarInset = 48.0;
const _viewportHeight = 800.0;
const _rowHeight = 100.0;
const _rowCount = 20;
const _lastRow = 'Row ${_rowCount - 1}';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() {
TvDetectionService.debugSetAppleTVOverride(false);
});
tearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
});
group('SliverSystemBottomInset', () {
testWidgets('reserves exactly the system bottom inset as extra scroll extent', (tester) async {
await _pump(tester, const _PlainScrollView());
expect(_position(tester).maxScrollExtent, 1000 - _viewportHeight + _navBarInset);
});
testWidgets('collapses to nothing when the platform reports no bottom inset', (tester) async {
await _pump(tester, const _PlainScrollView(), bottomInset: 0);
expect(_position(tester).maxScrollExtent, 1000 - _viewportHeight);
});
});
group('FocusedScrollScaffold', () {
testWidgets('scrolls its last row clear of the system navigation bar', (tester) async {
await _pump(tester, FocusedScrollScaffold(title: const Text('Settings'), slivers: [_rows()]));
await _scrollToEnd(tester);
expect(tester.getRect(find.text(_lastRow)).bottom, moreOrLessEquals(_viewportHeight - _navBarInset));
});
testWidgets('leaves the last row flush with the viewport when there is no inset', (tester) async {
await _pump(tester, FocusedScrollScaffold(title: const Text('Settings'), slivers: [_rows()]), bottomInset: 0);
await _scrollToEnd(tester);
expect(tester.getRect(find.text(_lastRow)).bottom, moreOrLessEquals(_viewportHeight));
});
});
group('FocusableDetailScreenMixin.buildDetailScaffold', () {
testWidgets('scrolls its last row clear of the system navigation bar', (tester) async {
await _pump(tester, const _DetailSurface());
await _scrollToEnd(tester);
expect(tester.getRect(find.text(_lastRow)).bottom, moreOrLessEquals(_viewportHeight - _navBarInset));
});
// The music detail screens append their own spacer to clear the floating
// mini-player. That spacer and the system inset must add up, not replace
// each other — the mini-player itself already floats above the nav bar.
testWidgets('stacks the system inset under a screen-supplied trailing spacer', (tester) async {
const spacer = 64.0;
await _pump(tester, const _DetailSurface(trailingSpacer: spacer));
await _scrollToEnd(tester);
expect(tester.getRect(find.text(_lastRow)).bottom, moreOrLessEquals(_viewportHeight - _navBarInset - spacer));
});
});
}
Future<void> _pump(WidgetTester tester, Widget child, {double bottomInset = _navBarInset}) async {
tester.view.physicalSize = const Size(400, _viewportHeight);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
MaterialApp(
home: Builder(
// copyWith keeps the real viewport metrics and overrides only the
// padding, so layout still sees a 400x800 phone.
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(padding: EdgeInsets.only(bottom: bottomInset)),
child: child,
),
),
),
);
await tester.pumpAndSettle();
}
ScrollPosition _position(WidgetTester tester) => tester.state<ScrollableState>(find.byType(Scrollable)).position;
Future<void> _scrollToEnd(WidgetTester tester) async {
final position = _position(tester);
position.jumpTo(position.maxScrollExtent);
await tester.pumpAndSettle();
}
Widget _rows() => SliverList.builder(
itemCount: _rowCount,
itemBuilder: (context, index) => SizedBox(height: _rowHeight, child: Text('Row $index')),
);
class _PlainScrollView extends StatelessWidget {
const _PlainScrollView();
@override
Widget build(BuildContext context) {
return const CustomScrollView(
slivers: [
SliverToBoxAdapter(child: SizedBox(height: 1000)),
SliverSystemBottomInset(),
],
);
}
}
class _DetailSurface extends StatefulWidget {
const _DetailSurface({this.trailingSpacer = 0});
final double trailingSpacer;
@override
State<_DetailSurface> createState() => _DetailSurfaceState();
}
class _DetailSurfaceState extends State<_DetailSurface>
with GridFocusNodeMixin<_DetailSurface>, FocusableDetailScreenMixin<_DetailSurface> {
@override
bool get hasItems => true;
@override
List<FocusableAction> getAppBarActions() => const [];
@override
void dispose() {
disposeFocusResources();
super.dispose();
}
@override
Widget build(BuildContext context) {
return buildDetailScaffold(
slivers: [
_rows(),
if (widget.trailingSpacer > 0) SliverToBoxAdapter(child: SizedBox(height: widget.trailingSpacer)),
],
);
}
}