fix(profiles): don't re-prompt for profile when a system overlay closes
On Fire TV, invoking Alexa and dismissing its overlay popped the profile picker over a still-playing session: MainScreen treated every resumed lifecycle event as an app open and re-applied "ask for profile on open". The Alexa overlay only produces inactive -> resumed, which the handler could not tell apart from a real return to the foreground. Track the deepest lifecycle state since the last resume in a small gate (ProfileSelectionResumeGate): only a genuine backgrounding (hidden, paused, or detached) arms the prompt for the next resume. This also keeps iOS working, where returning from the background arrives as hidden -> inactive -> resumed. close #1990
This commit is contained in:
@@ -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<MainScreen>
|
||||
/// 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<Widget> _screens;
|
||||
|
||||
/// One [GlobalKey] per tab, so a tab's live [State] can be reached from
|
||||
@@ -923,7 +966,8 @@ class _MainScreenState extends State<MainScreen>
|
||||
|
||||
@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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user