fix(sheets): let system back dismiss a hosted sheet on touch platforms

Back left the Manage Libraries sheet open on Android with no way to
dismiss it. The host answered the platform pop with
`BackKeyCoordinator.consumeIfHandled()`, which dedups the focused key
path against the platform pop. Only TV routes one Back through both;
touch platforms never deliver Back to the sheet's key handler, verified
on device — a physical Back produced only `popRoute` and no key event.
So there was nothing to dedup against, and the global one-shot marker,
once set by any other handler, silently swallowed the only signal that
closes the sheet.

Scopes the dedup to TV. The TV regression that guarded this never set
the TV override, so it asserted the swallow on every platform and hid
the defect; it now enables the override and a touch counterpart pins the
dismissal.

Also blocks semantics behind the barrier. The barrier takes every
pointer event but left the screen underneath in the semantics tree, so
assistive tech and UI automation still saw rows that could not be
activated — Maestro read an occluded settings row as visible and tapped
its stale coordinates into the sheet. Flutter's own ModalBarrier blocks
semantics for the same reason.

Verified by replaying the failing Maestro sequence
(.maestro/subflows/settings_deep_checks.yaml lines 42-60) on a device:
back dismisses the sheet, Services opens, and back returns to settings.
This commit is contained in:
edde746
2026-07-27 01:11:23 +02:00
parent c48cbf7059
commit 8e1904ddee
2 changed files with 53 additions and 9 deletions
+16 -1
View File
@@ -540,6 +540,13 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
// Barrier + sheet only when open
if (_isOpen) ...[
Positioned.fill(
// The barrier swallows every pointer event behind it, so the
// screen underneath must leave the semantics tree too. Without
// this, assistive tech (and UI automation) still reads rows that
// cannot be activated — including through the ~250ms close, where
// a tap on a "visible" row lands on the barrier instead. Flutter's
// own ModalBarrier blocks semantics for the same reason.
child: BlockSemantics(
child: AnimatedBuilder(
animation: _barrierAnimation,
builder: (context, child) {
@@ -550,6 +557,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
},
),
),
),
_buildSheet(context),
],
],
@@ -569,7 +577,14 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (_isOpen && !_isClosing) {
if (BackKeyCoordinator.consumeIfHandled()) return;
// Only TV routes one Back through both the focused key path and
// the platform pop, so only TV needs to dedup them. Touch
// platforms never deliver Back to [_handleKeyEvent], so consulting
// the global marker here could only ever consume some unrelated
// widget's mark and swallow the one signal that closes the sheet.
// The marker is global and one-shot, so anything left set by
// another handler would strand the sheet open with no way out.
if (PlatformDetector.isTV() && BackKeyCoordinator.consumeIfHandled()) return;
_handleBack();
return;
}
+29
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/key_event_utils.dart';
import 'package:plezy/utils/platform_detector.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
void main() {
@@ -365,6 +366,10 @@ void main() {
});
testWidgets('system back does not duplicate a handled TV key Back on an open sheet', (tester) async {
// The dedup is TV-only, so the override has to be on for this to be the
// TV contract rather than an accidental cross-platform assertion.
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
@@ -382,7 +387,31 @@ void main() {
expect(find.text('SHEET'), findsNothing);
});
testWidgets('touch system back closes the sheet despite an unrelated handled mark', (tester) async {
// Regression: the handled marker is global, and touch platforms never
// deliver Back to the sheet's key handler, so there is nothing here to
// dedup against. Consuming a mark left by any other handler stranded the
// sheet open with no way to dismiss it.
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing, reason: 'stale mark must not swallow the close');
expect(find.text('Open'), findsOneWidget, reason: 'screen not popped');
expect(backs, 0);
});
testWidgets('system back in a later frame is not mistaken for a duplicate TV key', (tester) async {
// TV-only: the dedup this exercises is skipped on touch platforms, so
// without the override the pop would never consult the marker and the
// post-frame expiry would go untested.
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));