Files
plezy/lib/screens/settings/add_plex_account_screen.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

188 lines
7.8 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../connection/connection.dart';
import '../../connection/connection_registry.dart';
import '../../connection/plex_account_setup.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart';
import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/plex_home_service.dart';
import '../../profiles/profile.dart';
import '../../profiles/profile_connection_cleanup.dart';
import '../../profiles/profile_connection_registry.dart';
import '../../services/storage_service.dart';
import '../../utils/app_logger.dart';
import '../../media/media_backend.dart';
import '../../widgets/backend_badge.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../auth/plex_pin_auth_flow.dart';
import '../profile/borrow_connection_screen.dart';
import 'async_form_state_mixin.dart';
/// Add a Plex account to the [ConnectionRegistry].
///
/// Hands off the PIN/QR/polling UI to [PlexPinAuthFlow]; this screen owns
/// the post-token-received flow: build the [PlexAccountConnection], guard
/// against duplicates, register with the live [MultiServerManager], and
/// either pop with success or route into [BorrowConnectionScreen] for the
/// passed-in profile.
///
/// When [targetProfile] is provided, after a successful sign-in the user
/// is routed into [BorrowConnectionScreen] for that target so they can
/// pick which Home user from the new account to attach to the profile.
class AddPlexAccountScreen extends StatefulWidget {
/// When set, after sign-in route into the borrow flow for this profile.
/// The new account's Home users surface globally either way; the borrow
/// step is what creates the [ProfileConnection] row that grants this
/// profile access to one of them.
final Profile? targetProfile;
const AddPlexAccountScreen({super.key, this.targetProfile});
@override
State<AddPlexAccountScreen> createState() => _AddPlexAccountScreenState();
}
class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncFormStateMixin {
Future<void> _onTokenReceived(String token) async {
final completed = await runAsync<bool>(
() async {
final connRegistry = context.read<ConnectionRegistry>();
final pcRegistry = context.read<ProfileConnectionRegistry>();
final plexHome = context.read<PlexHomeService>();
final storage = context.read<StorageService>();
final target = widget.targetProfile;
// Shared token→connection pipeline: identity resolution, dedup of a
// legacy client-id-keyed row, registry upsert, and the home-user
// fetch (which must land before the borrow screen — it reads
// `activeProvider.profiles` once in initState). Binding is
// deliberately left to ActiveProfileBinder below (global reauth) or
// the borrow flow (profile-scoped add) so we never put the raw
// account token into the active runtime session.
final registration = await registerPlexAccountFromToken(
token: token,
connections: connRegistry,
profileConnections: pcRegistry,
storage: storage,
plexHome: plexHome,
);
final connection = registration.connection;
if (!mounted) return false;
if (target != null) {
final borrowed = await Navigator.of(context).push<bool>(
MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: target, popOnSuccess: true)),
);
if (borrowed != true) {
// The user backed out of picking a home user — a cancel, not an
// error. If this flow created the account solely to attach it
// to the profile, remove it again so a cancelled attach doesn't
// leave a global account behind.
if (!registration.existedBefore) {
await ProfileConnectionCleanup(
profileConnections: pcRegistry,
connections: connRegistry,
storage: storage,
).removePlexAccountConnection(connection);
}
if (mounted) Navigator.of(context).pop(false);
return true;
}
if (mounted) Navigator.of(context).pop(true);
return true;
}
await _rebindActiveIfUses(connection.id);
if (!mounted) return false;
Navigator.of(context).pop(true);
return true;
},
errorMapper: (e) {
appLogger.e('Failed to register Plex account', error: e);
return t.addServer.failedToRegisterAccount(error: e.toString());
},
);
if (mounted && completed != true) {
throw StateError(errorText ?? t.addServer.failedToRegisterAccount(error: t.common.unknown));
}
}
Future<void> _rebindActiveIfUses(String connectionId) async {
final activeProvider = context.read<ActiveProfileProvider>();
await activeProvider.initialize();
if (!mounted) return;
final active = activeProvider.active;
if (active == null) return;
var usesConnection = active.parentConnectionId == connectionId;
if (!usesConnection) {
final pcs = await context.read<ProfileConnectionRegistry>().listForProfile(active.id);
usesConnection = pcs.any((pc) => pc.connectionId == connectionId);
}
if (!mounted || !usesConnection) return;
await context.read<ActiveProfileBinder>().rebindActive();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FocusedScrollScaffold(
title: Text(t.addServer.addPlexTitle),
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
PlexPinAuthFlow(
onTokenReceived: _onTokenReceived,
initialButtonsBuilder: (context, browser, qr, busy) => Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
FocusableButton(
useBackgroundFocus: true,
onPressed: busy || this.busy ? null : browser,
child: FilledButton.icon(
onPressed: busy || this.busy ? null : browser,
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
label: Text(t.auth.signInWithPlex),
),
),
const SizedBox(height: 12),
FocusableButton(
useBackgroundFocus: true,
onPressed: busy || this.busy ? null : qr,
child: OutlinedButton.icon(
onPressed: busy || this.busy ? null : qr,
icon: const AppIcon(Symbols.qr_code_rounded, fill: 1),
label: Text(t.auth.showQRCode),
),
),
],
),
),
...buildInlineError(theme, gap: 16, center: true),
],
),
),
),
),
),
],
);
}
}