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.
This commit is contained in:
@@ -1,9 +1,14 @@
|
||||
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 {
|
||||
@@ -95,6 +100,379 @@ void main() {
|
||||
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);
|
||||
@@ -428,3 +806,82 @@ void main() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/ids.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/providers/trackers_provider.dart';
|
||||
import 'package:plezy/services/base_shared_preferences_service.dart';
|
||||
import 'package:plezy/services/trackers/anilist/anilist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
|
||||
import 'package:plezy/services/trackers/mdblist/mdblist_tracker.dart';
|
||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||
import 'package:plezy/services/trackers/tracker_account_store.dart';
|
||||
import 'package:plezy/services/trackers/tracker_constants.dart';
|
||||
import 'package:plezy/services/trackers/tracker_session.dart';
|
||||
import 'package:plezy/services/trackers/trakt/trakt_tracker.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||
import 'package:plezy/widgets/rating_bottom_sheet.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/io_fakes.dart';
|
||||
import '../test_helpers/media_items.dart';
|
||||
import '../test_helpers/prefs.dart';
|
||||
import '../test_helpers/theme.dart';
|
||||
|
||||
/// Sizing suite for [RatingBottomSheet]. The sheet no longer carries its own
|
||||
/// `ConstrainedBox(maxHeight: height * 0.64/0.74)`: it is a
|
||||
/// `Column(mainAxisSize: .min)` + `Flexible` + `ListView(shrinkWrap: true)`
|
||||
/// whose only ceiling is the [OverlaySheetHost] cap (`viewportHeight * 0.75`,
|
||||
/// itself capped at 720 on a desktop OS). Every test drives the real host so
|
||||
/// the cap under test is the production one.
|
||||
void main() {
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
_resetTrackerBindings();
|
||||
});
|
||||
tearDown(_resetTrackerBindings);
|
||||
|
||||
testWidgets('an unratable tracker keeps its row so the sheet cannot resize under the user', (tester) async {
|
||||
// The score load resolves asynchronously and finds every tracker unratable
|
||||
// for this item. Removing those rows would shorten the sheet hundreds of ms
|
||||
// after it opens, and because sheets are bottom-anchored that slides the
|
||||
// rows above them — live rating controls — out from under the user.
|
||||
await _seedAllTrackerSessions(_profileUuid);
|
||||
await _pumpRatingSheet(
|
||||
tester,
|
||||
viewport: const Size(1280, 800),
|
||||
serverClient: _StubServerClient(ServerCapabilities.plex),
|
||||
profileUuid: _profileUuid,
|
||||
item: testMediaItem(id: 'ep-1', kind: MediaKind.episode, serverId: 'server-1', serverName: 'Living Room'),
|
||||
);
|
||||
|
||||
// Post-frame resolve has already run and reported every tracker unavailable.
|
||||
expect(find.text(t.rateSheet.notAvailable), findsNWidgets(5));
|
||||
expect(_listContentExtent(tester), _tallListExtent, reason: 'no row may be dropped');
|
||||
|
||||
final heightAfterResolve = tester.getSize(_sheetFinder).height;
|
||||
final serverRowTop = tester.getRect(find.text(t.rateSheet.server)).top;
|
||||
|
||||
// Pump well past any further async settling: nothing may move.
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tester.getSize(_sheetFinder).height, heightAfterResolve);
|
||||
expect(tester.getRect(find.text(t.rateSheet.server)).top, serverRowTop);
|
||||
});
|
||||
}
|
||||
|
||||
/// The sheet body itself — the direct child of the host's capping
|
||||
/// `ConstrainedBox`, so its height *is* the clamped sheet height.
|
||||
final Finder _sheetFinder = find.byType(RatingBottomSheet);
|
||||
|
||||
/// Six 54px rows (48px min-height row + 6px gap) plus the list's 4/12 padding.
|
||||
const double _tallListExtent = 6 * 54 + 16;
|
||||
|
||||
double _listContentExtent(WidgetTester tester) {
|
||||
final position = tester
|
||||
.state<ScrollableState>(find.descendant(of: _sheetFinder, matching: find.byType(Scrollable)))
|
||||
.position;
|
||||
return position.maxScrollExtent + position.viewportDimension;
|
||||
}
|
||||
|
||||
const _profileUuid = 'profile-1';
|
||||
|
||||
Future<void> _pumpRatingSheet(
|
||||
WidgetTester tester, {
|
||||
required Size viewport,
|
||||
MediaServerClient? serverClient,
|
||||
String? profileUuid,
|
||||
MediaItem? item,
|
||||
}) async {
|
||||
tester.view.physicalSize = viewport;
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
// Pin the two platform gates the host's cap consults, so the 0.75/720 math
|
||||
// is the same on every machine running the suite.
|
||||
PlatformDetector.debugSetIsDesktopOSOverride(true);
|
||||
addTearDown(() => PlatformDetector.debugSetIsDesktopOSOverride(null));
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
|
||||
|
||||
final trackers = TrackersProvider(httpClientFactory: () => FakeHttpClient(200, const <int>[]));
|
||||
addTearDown(trackers.dispose);
|
||||
if (profileUuid != null) {
|
||||
await trackers.onActiveProfileChanged(profileUuid);
|
||||
}
|
||||
|
||||
final resolvedItem = item ?? testMediaItem(id: 'item-1', serverId: 'server-1', serverName: 'Living Room');
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider<TrackersProvider>.value(
|
||||
value: trackers,
|
||||
child: MaterialApp(
|
||||
theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [testMonoTokens]),
|
||||
home: OverlaySheetHost(
|
||||
child: Scaffold(
|
||||
body: Center(
|
||||
child: Builder(
|
||||
builder: (context) => ElevatedButton(
|
||||
onPressed: () => OverlaySheetController.of(context).show<void>(
|
||||
builder: (_) => RatingBottomSheet(item: resolvedItem, serverClient: serverClient),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _seedAllTrackerSessions(String uuid) async {
|
||||
final now = DateTime.now().millisecondsSinceEpoch ~/ 1000;
|
||||
for (final service in TrackerService.values) {
|
||||
await trackerAccountStore(service).save(
|
||||
uuid,
|
||||
TrackerSession(
|
||||
accessToken: '${service.name}-at',
|
||||
refreshToken: '${service.name}-rt',
|
||||
expiresAt: now + 3600,
|
||||
createdAt: now,
|
||||
username: 'tester',
|
||||
),
|
||||
);
|
||||
}
|
||||
BaseSharedPreferencesService.resetForTesting();
|
||||
}
|
||||
|
||||
/// The tracker singletons outlive a test; unbind the seeded sessions so the
|
||||
/// next case starts disconnected.
|
||||
void _resetTrackerBindings() {
|
||||
MalTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
AnilistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
SimklTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
TraktTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
MdblistTracker.instance.rebindSession(null, onSessionInvalidated: () {});
|
||||
}
|
||||
|
||||
/// Narrow stand-in for the media server: while laying out, the sheet reads only
|
||||
/// [capabilities] (which decides whether the server row renders at all),
|
||||
/// [backend], [serverName], and [serverId].
|
||||
class _StubServerClient implements MediaServerClient {
|
||||
_StubServerClient(this.capabilities);
|
||||
|
||||
@override
|
||||
final ServerCapabilities capabilities;
|
||||
|
||||
@override
|
||||
ServerId get serverId => ServerId('server-1');
|
||||
|
||||
@override
|
||||
String? get serverName => 'Living Room';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||
import 'package:plezy/widgets/video_controls/sheets/subtitle_search_sheet.dart';
|
||||
|
||||
import '../test_helpers/theme.dart';
|
||||
|
||||
void main() {
|
||||
group('resolveSubtitleSearchLanguageCode', () {
|
||||
test('prefers saved language over system language', () {
|
||||
@@ -21,4 +26,72 @@ void main() {
|
||||
expect(resolveSubtitleSearchLanguageCode(savedLanguageCode: 'zz', systemLocale: const Locale('xx')), 'en');
|
||||
});
|
||||
});
|
||||
|
||||
group('sheet geometry', () {
|
||||
setUp(() {
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
// The 720 assertion below is the desktop-window ceiling, so pin both
|
||||
// gates it reads rather than inheriting the host OS.
|
||||
PlatformDetector.debugSetIsDesktopOSOverride(true);
|
||||
TvDetectionService.debugSetAppleTVOverride(false);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
PlatformDetector.debugSetIsDesktopOSOverride(null);
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('search body keeps a stable height so the focused field cannot slide', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 1400);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.reset);
|
||||
|
||||
await _pumpSearchSheet(tester);
|
||||
|
||||
// No MultiServerProvider, so the search resolves to no client and the
|
||||
// results area is empty. A shrink-wrapping body would collapse here.
|
||||
final emptyHeight = _sheetHeight(tester);
|
||||
final fieldTop = tester.getTopLeft(find.byType(TextField)).dy;
|
||||
expect(emptyHeight, 720, reason: 'search surface fills the windowed height ceiling');
|
||||
|
||||
// Switching to the language picker and filtering it down to a couple of
|
||||
// matches must not move the geometry either.
|
||||
await tester.tap(find.text('English'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(_sheetHeight(tester), emptyHeight);
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'zulu');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Zulu'), findsOneWidget);
|
||||
expect(_sheetHeight(tester), emptyHeight, reason: 'per-keystroke match count must not resize the sheet');
|
||||
expect(tester.getTopLeft(find.byType(TextField)).dy, fieldTop);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Height of the sheet box the host lays out, i.e. what the user sees.
|
||||
double _sheetHeight(WidgetTester tester) {
|
||||
return tester.getSize(find.descendant(of: find.byType(OverlaySheetHost), matching: find.byType(AnimatedSize))).height;
|
||||
}
|
||||
|
||||
Future<void> _pumpSearchSheet(WidgetTester tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: ThemeData(extensions: const [testMonoTokens]),
|
||||
home: OverlaySheetHost(
|
||||
child: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: ElevatedButton(
|
||||
onPressed: () => OverlaySheetController.of(context).show<void>(
|
||||
builder: (_) => const SubtitleSearchSheet(ratingKey: '1', serverId: 'server'),
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user