Files
plezy/test/widgets/overlay_sheet_test.dart
edde746 24a041977b fix(sheets): size sheets to their content instead of 75% of the window
Sheets rendered at the host's maximum height regardless of content, so a
one-item player queue or a two-track picker filled ~75% of a desktop window
with empty space.

BottomSheetPageScaffold now always lays out Column(mainAxisSize: .min) plus
Flexible(child:), and each sheet body shrink-wraps its own scrollable. The
scaffold's shrinkWrap flag is gone: its old true branch put the child on an
unbounded axis, where an over-tall list overflowed instead of clamping and
scrolling. Measured on a 1600x1000 window, the chapter sheet goes from 750px
to 118px for one chapter and the two-column track sheet from 750px to 154px
for one audio and one subtitle track, both still clamping at the cap.

Add SheetSplitColumns for the three side-by-side sheet layouts. A bare
VerticalDivider has no intrinsic height, so it inflated those rows to the cap
on its own; the rule now paints from a Positioned.fill that cannot size the
Stack. IntrinsicHeight is not an option because a Viewport has no intrinsics.

Because sheets are bottom-anchored, a content-driven height moves the sheet's
top edge and everything above the change point. Three surfaces opt out for
that reason and say so at the call site: SubtitleSearchSheet and its language
picker keep filling, since both refilter under an autofocused field;
FiltersBottomSheet holds the outgoing page's height through its loading
transient; and RatingBottomSheet no longer hides MAL/AniList rows
asynchronously, which used to slide live rating controls down two rows several
hundred ms after open. Wrap the shared StateMessageWidget at the filters sheet
boundary rather than editing a widget with 33 filling call sites.

The host gains an AnimatedSize keyed per sheet session so nested pushes ease
while a replacing show adopts its own height, a 720px absolute height ceiling
on desktop windows only, and a min(max(25%, 96px), 60%) drag-dismiss threshold
so short sheets neither close on a nudge nor become undismissable.

Add videoControls.noAudioDevicesAvailable so the audio output page shows a
placeholder instead of a bare header while devices load.
2026-08-08 00:37:17 +02:00

888 lines
35 KiB
Dart

import 'dart:async';
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/bottom_sheet_header.dart';
import 'package:plezy/widgets/bottom_sheet_page_scaffold.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/video_controls/sheets/sheet_split_columns.dart';
void main() {
testWidgets('scrollable sheet does not attach to parent primary controller', (tester) async {
final parentController = ScrollController();
addTearDown(parentController.dispose);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: PrimaryScrollController(
controller: parentController,
child: OverlaySheetHost(
child: Scaffold(
body: CustomScrollView(
primary: true,
slivers: [
SliverFillRemaining(
child: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (_) => ListView.builder(
itemCount: 30,
itemBuilder: (_, index) => ListTile(title: Text('Item $index')),
),
);
},
child: const Text('Open'),
),
),
),
),
],
),
),
),
),
),
);
expect(parentController.positions.length, 1);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(parentController.positions.length, 1);
expect(find.text('Item 0'), findsOneWidget);
});
testWidgets('desktop default constraints scale with window height', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (_) => ListView.builder(
itemCount: 100,
itemBuilder: (_, index) => ListTile(title: Text('Item $index')),
),
);
},
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
// Unbounded list content fills the default constraints: 75% of window
// height (previously a fixed 400 on desktop) capped at 700 wide.
final sheetSize = tester.getSize(find.byType(ListView));
expect(sheetSize.height, 800 * 0.75);
expect(sheetSize.width, 700);
});
testWidgets('a tall desktop window clamps the sheet to the absolute ceiling, a tall phone does not', (tester) async {
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
// Pin both gates the ceiling reads, not just the OS one.
PlatformDetector.debugSetIsDesktopOSOverride(true);
TvDetectionService.debugSetAppleTVOverride(false);
addTearDown(() {
PlatformDetector.debugSetIsDesktopOSOverride(null);
TvDetectionService.debugSetAppleTVOverride(null);
});
// 1440p desktop: 75% would be 1080px, which reads as a wall of list.
tester.view.physicalSize = const Size(2560, 1440);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)),
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 720);
controller.close();
await tester.pumpAndSettle();
// Narrow viewports keep the plain 75% rule: the ceiling is windows-only.
tester.view.physicalSize = const Size(400, 1200);
unawaited(
controller.show<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)),
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 1200 * 0.75);
});
testWidgets('a portrait tablet keeps the full 75% height — the ceiling is for resizable windows', (tester) async {
// Wide enough for the 700px width cap, but not a window the user can drag
// taller, so the absolute ceiling must not apply.
PlatformDetector.debugSetIsDesktopOSOverride(false);
TvDetectionService.debugSetAppleTVOverride(false);
addTearDown(() {
PlatformDetector.debugSetIsDesktopOSOverride(null);
TvDetectionService.debugSetAppleTVOverride(null);
});
tester.view.physicalSize = const Size(1024, 1366);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)),
),
);
await tester.pumpAndSettle();
final size = tester.getSize(find.byType(BottomSheetPageScaffold));
expect(size.height, 1366 * 0.75);
expect(size.width, 700, reason: 'the width cap still applies on a wide viewport');
});
testWidgets('TV keeps the full 75% height — the ceiling is for resizable windows', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
PlatformDetector.debugSetIsDesktopOSOverride(true);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
PlatformDetector.debugSetIsDesktopOSOverride(null);
});
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)),
),
);
await tester.pumpAndSettle();
// A 10-foot UI wants every row it can get: 810, not the 720 window ceiling.
expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 1080 * 0.75);
});
testWidgets('the hostless modal fallback inherits the same sizing rules', (tester) async {
PlatformDetector.debugSetIsDesktopOSOverride(true);
TvDetectionService.debugSetAppleTVOverride(false);
addTearDown(() {
PlatformDetector.debugSetIsDesktopOSOverride(null);
TvDetectionService.debugSetAppleTVOverride(null);
});
tester.view.physicalSize = const Size(2560, 1440);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: Scaffold(
body: Builder(
// No OverlaySheetHost anywhere above: showAdaptive must fall back
// to showModalBottomSheet and still honour the default constraints.
builder: (context) => ElevatedButton(
onPressed: () => unawaited(
OverlaySheetController.showAdaptive<void>(
context,
isScrollControlled: true,
builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)),
),
),
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
final size = tester.getSize(find.byType(BottomSheetPageScaffold));
expect(size.height, 720);
expect(size.width, 700);
});
for (final anchor in [Alignment.bottomCenter, Alignment.topCenter]) {
final isTop = anchor.y < 0;
final label = isTop ? 'top' : 'bottom';
testWidgets('a $label-anchored sheet keeps its anchored edge still while it grows', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
alignment: anchor,
builder: (_) => const BottomSheetPageScaffold(title: 'Small', child: _FixedRowList(rowCount: 2)),
),
);
await tester.pumpAndSettle();
final animated = find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize));
final smallHeight = tester.getSize(animated).height;
// The header is the content row adjacent to the anchored edge for a
// top-anchored sheet, and the farthest from it for a bottom-anchored one.
final header = find.byType(BottomSheetHeader);
final headerTopBefore = tester.getTopLeft(header).dy;
unawaited(
controller.push<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Big', child: _FixedRowList(rowCount: 6)),
),
);
// Mid-tween: this is the only point where AnimatedSize's `alignment`
// is observable. The child is already laid out at its final size, so a
// wrong alignment shows up as the content sliding against its anchor.
// The first pump starts the tween; the second advances it.
await tester.pump();
await tester.pump(const Duration(milliseconds: 90));
final growing = tester.getSize(animated).height;
expect(growing, greaterThan(smallHeight));
expect(growing, lessThan(smallHeight + 160));
if (isTop) {
expect(tester.getTopLeft(header).dy, headerTopBefore, reason: 'header is pinned to the top anchor');
} else {
// Bottom-anchored: the child is laid out at its final height and
// bottom-pinned, so the header sits at its FINAL y from the first tween
// frame and holds there. Under a wrong (top) alignment it would instead
// track the animating height. Only the exact value distinguishes them.
expect(tester.getTopLeft(header).dy, 800 - (smallHeight + 160));
expect(tester.getBottomLeft(animated).dy, 800, reason: 'bottom edge stays pinned to the viewport');
}
await tester.pumpAndSettle();
expect(tester.getSize(animated).height, smallHeight + 160);
});
}
testWidgets('popping back to a shorter page eases the sheet down', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Small', child: _FixedRowList(rowCount: 2)),
),
);
await tester.pumpAndSettle();
final animated = find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize));
final smallHeight = tester.getSize(animated).height;
unawaited(
controller.push<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Big', child: _FixedRowList(rowCount: 6)),
),
);
await tester.pumpAndSettle();
expect(tester.getSize(animated).height, smallHeight + 160);
controller.pop();
await tester.pump();
expect(tester.getSize(animated).height, smallHeight + 160, reason: 'shrink must ease, not jump');
await tester.pump(const Duration(milliseconds: 90));
final shrinking = tester.getSize(animated).height;
expect(shrinking, lessThan(smallHeight + 160));
expect(shrinking, greaterThan(smallHeight));
await tester.pumpAndSettle();
expect(tester.getSize(animated).height, smallHeight);
});
testWidgets('a short content-sized sheet still needs a deliberate drag to dismiss', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
showDragHandle: true,
builder: (_) => const BottomSheetPageScaffold(title: 'Tiny', child: _FixedRowList(rowCount: 2)),
),
);
await tester.pumpAndSettle();
// ~145px of content plus a 20px drag handle, so a bare 25%-of-height
// threshold would be ~41px — barely more than touch slop. The absolute
// floor is what keeps a stray flick from closing it.
expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, lessThan(200));
// 90px of gesture is ~70px of drag once the recogniser's slop is consumed:
// past 25% of the sheet, but well short of the floor.
await _slowDragDown(tester, 90);
expect(find.byType(BottomSheetPageScaffold), findsOneWidget, reason: '70px of drag must not dismiss');
await _slowDragDown(tester, 140);
expect(find.byType(BottomSheetPageScaffold), findsNothing, reason: '140px clears the floor');
});
testWidgets('a sheet shorter than the dismiss floor stays dismissible by drag', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
showDragHandle: true,
builder: (_) => const BottomSheetPageScaffold(title: 'One', child: _FixedRowList(rowCount: 1)),
),
);
await tester.pumpAndSettle();
// A one-row menu is shorter than the 96px floor. Left unclamped, the floor
// would demand a drag longer than the sheet itself, so the only way to
// dismiss by distance would be to pull it clean off the screen.
final sheetHeight = tester
.getSize(find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize)))
.height;
expect(sheetHeight, lessThan(96 / 0.6), reason: 'the clamp must actually be the binding rule here');
// Just past 60% of the sheet, still short of the raw 96px floor.
await _slowDragDown(tester, sheetHeight * 0.6 + 8 + _dragSlop);
expect(find.byType(BottomSheetPageScaffold), findsNothing, reason: 'clamped floor keeps a short sheet dismissible');
});
testWidgets('sheet page shrinks to short content instead of filling the height cap', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await _pumpHostedSheet(tester, const BottomSheetPageScaffold(title: 'Tiny', child: _FixedRowList(rowCount: 2)));
final sheetHeight = tester.getSize(find.byType(BottomSheetPageScaffold)).height;
final headerHeight = tester.getSize(find.byType(BottomSheetHeader)).height;
// Exactly header + two 40px rows: no filler between the last row and the
// bottom of the sheet.
expect(sheetHeight, headerHeight + 80);
expect(sheetHeight, lessThan(800 * 0.75));
});
testWidgets('sheet page clamps to the height cap once content overflows', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await _pumpHostedSheet(tester, const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)));
expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 800 * 0.75);
// Still scrollable rather than overflowing.
expect(tester.takeException(), isNull);
await tester.drag(find.byType(ListView), const Offset(0, -200));
await tester.pumpAndSettle();
expect(find.text('row 0'), findsNothing);
expect(
tester.getSize(find.byType(BottomSheetPageScaffold)).height,
800 * 0.75,
reason: 'scrolling must not resize the sheet',
);
});
testWidgets('uneven row heights do not make the clamped sheet wobble while scrolling', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
// A shrink-wrapping sliver reports an *estimated* max extent extrapolated
// from the rows laid out so far. That estimate can only move the sheet while
// it is near the clamp, so this list is deliberately sized to just overflow
// the 600px cap (11 rows of 40/64/88 = 616px) rather than to swamp it.
await _pumpHostedSheet(
tester,
const BottomSheetPageScaffold(title: 'Uneven', child: _FixedRowList(rowCount: 11, varyHeights: true)),
);
for (var step = 0; step < 4; step++) {
expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 800 * 0.75, reason: 'step $step');
await tester.drag(find.byType(ListView), const Offset(0, -40));
await tester.pumpAndSettle();
}
expect(tester.getSize(find.byType(BottomSheetPageScaffold)).height, 800 * 0.75);
});
testWidgets('split columns size to the taller column and stretch the rule to match', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await _pumpHostedSheet(
tester,
const SheetSplitColumns(start: _FixedRowList(rowCount: 2), end: _FixedRowList(rowCount: 3)),
);
// Taller column wins; the hairline rule must not drag the row to the cap.
expect(tester.getSize(find.byType(SheetSplitColumns)).height, 120);
final rule = tester.getSize(
find.descendant(of: find.byType(SheetSplitColumns), matching: find.byType(VerticalDivider)),
);
expect(rule, const Size(1, 120));
});
testWidgets('replacing an open sheet adopts the new height instead of easing down from the old one', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final controller = await _pumpIdleHost(tester);
unawaited(
controller.show<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Many', child: _FixedRowList(rowCount: 200)),
),
);
await tester.pumpAndSettle();
expect(tester.getSize(find.byType(AnimatedSize)).height, 800 * 0.75);
// Replacing without closing keeps the sheet mounted, so only the per-sheet
// animation key stops the new page from easing down from 600px.
unawaited(
controller.show<void>(
builder: (_) => const BottomSheetPageScaffold(title: 'Tiny', child: _FixedRowList(rowCount: 2)),
),
);
await tester.pump();
final headerHeight = tester.getSize(find.byType(BottomSheetHeader)).height;
expect(tester.getSize(find.byType(AnimatedSize)).height, headerHeight + 80);
});
testWidgets('pointer-opened sheet claims focus and handles Back before the screen', (tester) async {
final screenFocusNode = FocusNode(debugLabel: 'Screen');
addTearDown(screenFocusNode.dispose);
var screenBacks = 0;
await tester.pumpWidget(
MaterialApp(
home: Focus(
focusNode: screenFocusNode,
autofocus: true,
onKeyEvent: (_, event) {
if (event.logicalKey == LogicalKeyboardKey.gameButtonB) {
if (event is KeyUpEvent) screenBacks++;
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => OverlaySheetController.of(context).show<void>(
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('SHEET'))),
),
child: const Text('Open'),
),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'OverlaySheetScope');
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(screenBacks, 0);
});
testWidgets('pushAdaptive opens a root page on an idle host and returns its result', (tester) async {
final result = ValueNotifier<String>('pending');
addTearDown(result.dispose);
await tester.pumpWidget(
MaterialApp(
home: OverlaySheetHost(
child: Scaffold(
body: Builder(
builder: (context) => Column(
children: [
ElevatedButton(
onPressed: () async {
final value = await OverlaySheetController.pushAdaptive<String>(
context,
builder: (sheetContext) => SizedBox(
height: 120,
child: Column(
children: [
const Text('Adaptive root page'),
ElevatedButton(
onPressed: () => OverlaySheetController.of(sheetContext).close('root result'),
child: const Text('Close adaptive root'),
),
],
),
),
);
result.value = value ?? 'null';
},
child: const Text('Push adaptive root'),
),
ValueListenableBuilder<String>(
valueListenable: result,
builder: (_, value, _) => Text('Root result: $value'),
),
],
),
),
),
),
),
);
await tester.tap(find.text('Push adaptive root'));
await tester.pumpAndSettle();
expect(find.text('Adaptive root page'), findsOneWidget);
expect(find.text('Root result: pending'), findsOneWidget);
await tester.tap(find.text('Close adaptive root'));
await tester.pumpAndSettle();
expect(find.text('Adaptive root page'), findsNothing);
expect(find.text('Root result: root result'), findsOneWidget);
});
testWidgets('pushAdaptive pushes a nested page on an open host and pop restores the root', (tester) async {
final nestedResult = ValueNotifier<String>('pending');
addTearDown(nestedResult.dispose);
await tester.pumpWidget(
MaterialApp(
home: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () {
OverlaySheetController.of(context).show<void>(
builder: (rootContext) => SizedBox(
height: 160,
child: Column(
children: [
const Text('Existing root page'),
ElevatedButton(
onPressed: () async {
final value = await OverlaySheetController.pushAdaptive<String>(
rootContext,
builder: (nestedContext) => SizedBox(
height: 120,
child: Column(
children: [
const Text('Adaptive nested page'),
ElevatedButton(
onPressed: () =>
OverlaySheetController.of(nestedContext).pop('nested result'),
child: const Text('Pop adaptive nested'),
),
],
),
),
);
nestedResult.value = value ?? 'null';
},
child: const Text('Push adaptive nested'),
),
ValueListenableBuilder<String>(
valueListenable: nestedResult,
builder: (_, value, _) => Text('Nested result: $value'),
),
ElevatedButton(
onPressed: () => OverlaySheetController.of(rootContext).close(),
child: const Text('Close existing root'),
),
],
),
),
);
},
child: const Text('Open existing root'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open existing root'));
await tester.pumpAndSettle();
expect(find.text('Existing root page'), findsOneWidget);
await tester.tap(find.text('Push adaptive nested'));
await tester.pumpAndSettle();
expect(find.text('Adaptive nested page'), findsOneWidget);
expect(find.text('Existing root page'), findsNothing);
await tester.tap(find.text('Pop adaptive nested'));
await tester.pumpAndSettle();
expect(find.text('Adaptive nested page'), findsNothing);
expect(find.text('Existing root page'), findsOneWidget);
expect(find.text('Nested result: nested result'), findsOneWidget);
await tester.tap(find.text('Close existing root'));
await tester.pumpAndSettle();
expect(find.text('Existing root page'), findsNothing);
});
group('opt-in canPop / onSystemBack', () {
// Pushes an OverlaySheetHost route on top of a home route so we can observe
// whether a simulated system back pops the route. The host's child has an
// "Open" button that shows a sheet containing "SHEET".
Future<void> pushHost(WidgetTester tester, {required bool? canPop, VoidCallback? onSystemBack}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: Scaffold(
body: Builder(
builder: (context) => Center(
child: ElevatedButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => OverlaySheetHost(
canPop: canPop,
onSystemBack: onSystemBack,
child: Scaffold(
body: Builder(
builder: (sheetContext) => Center(
child: ElevatedButton(
onPressed: () => OverlaySheetController.of(sheetContext).show<void>(
builder: (_) => const SizedBox(height: 120, child: Center(child: Text('SHEET'))),
),
child: const Text('Open'),
),
),
),
),
),
),
),
child: const Text('Push'),
),
),
),
),
),
);
await tester.tap(find.text('Push'));
await tester.pumpAndSettle();
expect(find.text('Open'), findsOneWidget, reason: 'host route is shown');
}
testWidgets('canPop null installs no PopScope (system back pops the route)', (tester) async {
await pushHost(tester, canPop: null);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Open'), findsNothing, reason: 'route popped back to home');
expect(find.text('Push'), findsOneWidget);
});
testWidgets('canPop true pops the route natively on system back', (tester) async {
await pushHost(tester, canPop: true);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Open'), findsNothing, reason: 'route popped');
});
testWidgets('canPop false blocks the pop and runs onSystemBack', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('Open'), findsOneWidget, reason: 'route not popped');
expect(backs, 1);
});
testWidgets('system back closes an open sheet instead of popping or running onSystemBack', (tester) async {
var backs = 0;
await pushHost(tester, canPop: false, onSystemBack: () => backs++);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing, reason: 'sheet closed');
expect(find.text('Open'), findsOneWidget, reason: 'screen not popped');
expect(backs, 0, reason: 'onSystemBack not called while a sheet was open');
});
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'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsOneWidget);
expect(backs, 0);
await tester.sendKeyEvent(LogicalKeyboardKey.gameButtonB);
await tester.pumpAndSettle();
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'));
await tester.pumpAndSettle();
BackKeyCoordinator.markHandled();
await tester.pump();
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('SHEET'), findsNothing);
expect(find.text('Open'), findsOneWidget);
expect(backs, 0);
});
});
}
/// Shrink-wrapping list for the sizing tests: 40px rows by default, or a
/// 40/64/88 cycle when [varyHeights] is set so the sliver's estimated extent
/// keeps changing as rows are laid out.
class _FixedRowList extends StatelessWidget {
final int rowCount;
final bool varyHeights;
const _FixedRowList({required this.rowCount, this.varyHeights = false});
@override
Widget build(BuildContext context) {
return ListView.builder(
shrinkWrap: true,
itemCount: rowCount,
itemBuilder: (_, index) => SizedBox(height: varyHeights ? 40 + (index % 3) * 24 : 40, child: Text('row $index')),
);
}
}
Future<void> _pumpHostedSheet(WidgetTester tester, Widget content) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: OverlaySheetHost(
child: Scaffold(
body: Center(
child: Builder(
builder: (context) => ElevatedButton(
onPressed: () => OverlaySheetController.of(context).show<void>(builder: (_) => content),
child: const Text('Open'),
),
),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
/// Distance the vertical drag recogniser swallows before it starts reporting
/// updates, in 10px steps: two steps to clear `kTouchSlop`.
const _dragSlop = 20.0;
/// Drags the sheet down by [distance] in steps, so only the distance threshold
/// can decide. A single-move `tester.drag` would never emit `onUpdate` past the
/// recogniser's slop, which is why this steps by hand; the fling-velocity
/// escape hatch cannot fire either way, because `TestGesture.moveBy` stamps
/// every pointer event with `Duration.zero`.
Future<void> _slowDragDown(WidgetTester tester, double distance) async {
final gesture = await tester.startGesture(tester.getCenter(find.byType(BottomSheetHeader)));
for (var moved = 0.0; moved < distance; moved += 10) {
await gesture.moveBy(const Offset(0, 10));
await tester.pump(const Duration(milliseconds: 40));
}
await gesture.up();
await tester.pumpAndSettle();
}
Future<OverlaySheetController> _pumpIdleHost(WidgetTester tester) async {
late OverlaySheetController controller;
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(platform: TargetPlatform.android),
home: OverlaySheetHost(
child: Builder(
builder: (context) {
controller = OverlaySheetController.of(context);
return const Scaffold(body: SizedBox.expand());
},
),
),
),
);
return controller;
}