diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 6508230b7..a5807cfe5 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -88,6 +88,44 @@ bool shouldHandleDesktopRootEscape({ return isDesktop && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab; } +/// Latches whether the app has genuinely left the foreground since the last +/// `resumed`, and consumes that fact on the next resume so the "ask for a +/// profile on open" rule can re-apply exactly once per backgrounding. +/// +/// System overlays (Fire TV Alexa, notification shade, Control Center) only +/// produce `inactive -> resumed` — the app never left the foreground — so +/// they must not prompt (#1990). iOS returns from the background as +/// `hidden -> inactive -> resumed`, so inspecting only the immediately +/// previous state would miss a real return; latching the deepest state seen +/// handles both. The startup flow owns the cold open, so a first `resumed` +/// with no prior backgrounding does not prompt. +@visibleForTesting +class ProfileSelectionResumeGate { + bool _wasBackgrounded = false; + + /// Whether a genuine backgrounding has been observed since the last resume. + bool get wasBackgrounded => _wasBackgrounded; + + /// Feeds one lifecycle transition through the gate. Returns true exactly + /// once per backgrounding: on the first `resumed` after the sequence + /// reached `hidden`/`paused`/`detached`. Consuming resets the latch. + bool consumePromptOn(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + final shouldPrompt = _wasBackgrounded; + _wasBackgrounded = false; + return shouldPrompt; + case AppLifecycleState.hidden: + case AppLifecycleState.paused: + case AppLifecycleState.detached: + _wasBackgrounded = true; + return false; + case AppLifecycleState.inactive: + return false; + } + } +} + @visibleForTesting ({double left, double width}) mainScreenSideNavigationContentLayout({ required double viewportWidth, @@ -236,6 +274,11 @@ class _MainScreenState extends State /// Prevents double-pushing the profile selection screen bool _isShowingProfileSelection = false; + /// Latches a genuine backgrounding so "ask for a profile on open" fires + /// exactly once on the next resume, while transient focus losses + /// (`inactive`, e.g. the Fire TV Alexa overlay) never prompt. + final _profileSelectionResumeGate = ProfileSelectionResumeGate(); + late List _screens; /// One [GlobalKey] per tab, so a tab's live [State] can be reached from @@ -923,7 +966,8 @@ class _MainScreenState extends State @override void didChangeAppLifecycleState(AppLifecycleState state) { - if (state == AppLifecycleState.resumed && !_isOffline && !_isShowingProfileSelection) { + final shouldPrompt = _profileSelectionResumeGate.consumePromptOn(state); + if (shouldPrompt && !_isOffline && !_isShowingProfileSelection) { // Only show profile selection on resume for mobile platforms. // On desktop, "resumed" fires on every window focus gain (alt-tab, click), // which is too frequent — the initial prompt on startup is sufficient. diff --git a/test/screens/main_screen_layout_test.dart b/test/screens/main_screen_layout_test.dart index 6c0559e2c..9e9c00f4a 100644 --- a/test/screens/main_screen_layout_test.dart +++ b/test/screens/main_screen_layout_test.dart @@ -134,6 +134,44 @@ void main() { ); }); + group('ProfileSelectionResumeGate', () { + test('does not prompt for overlay-style focus loss and regain (#1990)', () { + final gate = ProfileSelectionResumeGate(); + expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse); + expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse); + }); + + test('prompts exactly once after a genuine backgrounding', () { + final gate = ProfileSelectionResumeGate(); + expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse); + expect(gate.consumePromptOn(AppLifecycleState.paused), isFalse); + expect(gate.wasBackgrounded, isTrue); + expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue); + expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse); + expect(gate.wasBackgrounded, isFalse); + }); + + test('prompts after an iOS-style hidden -> inactive -> resumed return', () { + final gate = ProfileSelectionResumeGate(); + expect(gate.consumePromptOn(AppLifecycleState.hidden), isFalse); + expect(gate.consumePromptOn(AppLifecycleState.inactive), isFalse); + expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue); + }); + + test('latches every backgrounding state', () { + for (final state in [AppLifecycleState.hidden, AppLifecycleState.paused, AppLifecycleState.detached]) { + final gate = ProfileSelectionResumeGate(); + expect(gate.consumePromptOn(state), isFalse); + expect(gate.consumePromptOn(AppLifecycleState.resumed), isTrue, reason: '$state'); + } + }); + + test('does not prompt without a prior backgrounding (cold open)', () { + final gate = ProfileSelectionResumeGate(); + expect(gate.consumePromptOn(AppLifecycleState.resumed), isFalse); + }); + }); + testWidgets('side navigation bleed animates from the previous value', (tester) async { Widget build(double targetBleed) { return Directionality(