diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index a14db069..1a89b484 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -540,14 +540,22 @@ class _OverlaySheetHostState extends State with SingleTickerPr // Barrier + sheet only when open if (_isOpen) ...[ Positioned.fill( - child: AnimatedBuilder( - animation: _barrierAnimation, - builder: (context, child) { - return GestureDetector( - onTap: _barrierDismissible ? () => _close() : null, - child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)), - ); - }, + // 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) { + return GestureDetector( + onTap: _barrierDismissible ? () => _close() : null, + child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)), + ); + }, + ), ), ), _buildSheet(context), @@ -569,7 +577,14 @@ class _OverlaySheetHostState extends State 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; } diff --git a/test/widgets/overlay_sheet_test.dart b/test/widgets/overlay_sheet_test.dart index 5be3dcea..a52cdb5e 100644 --- a/test/widgets/overlay_sheet_test.dart +++ b/test/widgets/overlay_sheet_test.dart @@ -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'));