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:
edde746
2026-08-08 00:37:17 +02:00
parent e3703892b3
commit 24a041977b
71 changed files with 1210 additions and 207 deletions
+457
View File
@@ -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;
}
+190
View File
@@ -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();
}