diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 06d09a52..5b6f0978 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -263,7 +263,7 @@ class _AuthScreenState extends State { body: Center( child: Container( constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400), - padding: const EdgeInsets.all(24), + padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom), child: isDesktop ? Row( crossAxisAlignment: .center, diff --git a/lib/screens/companion_remote/mobile_remote_screen.dart b/lib/screens/companion_remote/mobile_remote_screen.dart index 5e2098c1..3f7b9d6e 100644 --- a/lib/screens/companion_remote/mobile_remote_screen.dart +++ b/lib/screens/companion_remote/mobile_remote_screen.dart @@ -203,7 +203,7 @@ class _RemoteControlContentState extends State<_RemoteControlContent> { ), Expanded( child: ListView( - padding: const EdgeInsets.all(16), + padding: .fromLTRB(16, 16, 16, 16 + MediaQuery.paddingOf(context).bottom), children: [ SegmentedButton( showSelectedIcon: false, diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index ff784071..513f28f0 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -13,6 +13,7 @@ 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. @@ -86,7 +87,10 @@ mixin FocusableDetailScreenMixin on State, GridFocu /// 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]`). + /// `[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 slivers}) { return PrimaryScrollController( controller: scrollController, @@ -100,7 +104,9 @@ mixin FocusableDetailScreenMixin on State, GridFocu Navigator.pop(context); } }, - child: Scaffold(body: CustomScrollView(primary: true, slivers: slivers)), + child: Scaffold( + body: CustomScrollView(primary: true, slivers: [...slivers, const SliverSystemBottomInset()]), + ), ), ), ); diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 1aaf4242..94f94659 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -12,6 +12,7 @@ import '../media/media_server_client.dart'; import '../media/media_sort.dart'; import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; +import '../widgets/system_bottom_inset.dart'; import '../utils/app_logger.dart'; import '../utils/continuation_pagination_coordinator.dart'; import '../utils/error_message_utils.dart'; @@ -611,6 +612,7 @@ class _HubDetailScreenState extends State onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), onBack: handleBackFromContent, ), + const SliverSystemBottomInset(), ], ), ), diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 8be3f5f6..18a04b3b 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -36,6 +36,7 @@ import '../focusable_detail_screen_mixin.dart'; import '../libraries/content_state_builder.dart'; import '../../mixins/grid_focus_node_mixin.dart'; import '../../widgets/overlay_sheet.dart'; +import '../../widgets/system_bottom_inset.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { @@ -757,6 +758,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen with AsyncFormSta SliverFillRemaining( hasScrollBody: false, child: Padding( - padding: const EdgeInsets.all(24), + padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom), child: Center(child: _buildQuickConnectPanel(theme)), ), ) diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index 404a3fd2..f0300d26 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -138,7 +138,7 @@ class _AddPlexAccountScreenState extends State with AsyncF SliverFillRemaining( hasScrollBody: false, child: Padding( - padding: const EdgeInsets.all(24), + padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 420), diff --git a/lib/screens/settings/logs_screen.dart b/lib/screens/settings/logs_screen.dart index 0a849a55..7aa71ea7 100644 --- a/lib/screens/settings/logs_screen.dart +++ b/lib/screens/settings/logs_screen.dart @@ -26,6 +26,7 @@ import '../../utils/platform_detector.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; +import '../../widgets/system_bottom_inset.dart'; const previousStartupFailureKey = Key('logs-previous-startup-failure'); @@ -419,7 +420,7 @@ class _LogsScreenState extends State with MountedSetStateMixin { ?_buildPreviousFailureBanner(theme), if (_logs.isEmpty) SliverFillRemaining(child: Center(child: Text(t.messages.noLogsAvailable))) - else + else ...[ SliverPadding( padding: const EdgeInsets.all(12), sliver: SliverToBoxAdapter( @@ -435,6 +436,10 @@ class _LogsScreenState extends State 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(), + ], ], ), ), diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index ca71065b..64f6a783 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -43,6 +43,7 @@ import '../../widgets/overlay_sheet.dart'; import '../../widgets/setting_tile.dart'; import '../../widgets/settings_builder.dart'; import '../../widgets/settings_section.dart'; +import '../../widgets/system_bottom_inset.dart'; import '../../profiles/active_profile_provider.dart'; import '../../profiles/profile.dart'; import '../../watch_together/services/watch_together_relay_endpoint.dart'; @@ -224,6 +225,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun const SizedBox(height: 24), ]), ), + const SliverSystemBottomInset(), ], ), ), diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart index d2842f73..94ced166 100644 --- a/lib/widgets/focused_scroll_scaffold.dart +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -3,6 +3,7 @@ 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. @@ -122,6 +123,9 @@ class _FocusedScrollScaffoldState extends State { automaticallyImplyLeading: widget.automaticallyImplyLeading, ), ...widget.slivers, + // Keeps the last row scrollable clear of the Android + // navigation bar / iOS home indicator; zero-height elsewhere. + const SliverSystemBottomInset(), ], ), ), diff --git a/lib/widgets/system_bottom_inset.dart b/lib/widgets/system_bottom_inset.dart new file mode 100644 index 00000000..767b28f8 --- /dev/null +++ b/lib/widgets/system_bottom_inset.dart @@ -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)); + } +} diff --git a/test/widgets/system_bottom_inset_test.dart b/test/widgets/system_bottom_inset_test.dart new file mode 100644 index 00000000..a7248050 --- /dev/null +++ b/test/widgets/system_bottom_inset_test.dart @@ -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 _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(find.byType(Scrollable)).position; + +Future _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 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)), + ], + ); + } +}