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
@@ -6,6 +6,7 @@ import 'package:plezy/media/media_filter.dart';
import 'package:plezy/screens/libraries/filters_bottom_sheet.dart';
import 'package:plezy/screens/libraries/state_messages.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';
final _filters = [
@@ -18,7 +19,7 @@ MediaFilterValue _value(String key, String title) => MediaFilterValue(key: key,
void main() {
testWidgets('filter switch rejects an obsolete success and its presentation effects', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
await _goBack(tester);
@@ -34,12 +35,11 @@ void main() {
expect(find.text('Current Studio'), findsOneWidget);
expect(find.text('Obsolete Genre'), findsNothing);
expect(tester.takeException(), isNull);
harness.dispose();
});
testWidgets('same-filter reopen rejects the first request completion', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
await _goBack(tester);
@@ -52,12 +52,11 @@ void main() {
expect(find.text('New Genre'), findsOneWidget);
expect(find.text('Old Genre'), findsNothing);
harness.dispose();
});
testWidgets('stale failure cannot replace a newer successful value list', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
await _goBack(tester);
@@ -70,7 +69,6 @@ void main() {
expect(find.byType(ErrorStateWidget), findsNothing);
expect(find.text('Current Studio'), findsOneWidget);
harness.dispose();
});
testWidgets('library replacement retires the old owner request', (tester) async {
@@ -87,13 +85,12 @@ void main() {
expect(find.text('Old Library Genre'), findsNothing);
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Filters'), findsOneWidget);
harness.dispose();
});
testWidgets('back then clear retires a loading request before closing', (tester) async {
final requests = _FilterRequests();
final applied = <Map<String, String>>[];
final harness = await _pumpSheet(
await _pumpSheet(
tester,
loader: requests.load,
selectedFilters: const {'studio': 'selected'},
@@ -112,13 +109,12 @@ void main() {
requests.request('genre').complete([_value('late', 'Late Genre')]);
await tester.pump();
expect(tester.takeException(), isNull);
harness.dispose();
});
testWidgets('missing selected value is preserved until explicit user action', (tester) async {
final requests = _FilterRequests();
final applied = <Map<String, String>>[];
final harness = await _pumpSheet(
await _pumpSheet(
tester,
loader: requests.load,
selectedFilters: const {'genre': 'missing'},
@@ -132,12 +128,11 @@ void main() {
expect(find.text('Clear All'), findsOneWidget);
expect(applied, isEmpty);
harness.dispose();
});
testWidgets('load failure has retry state while empty success remains selectable', (tester) async {
final requests = _FilterRequests();
final harness = await _pumpSheet(tester, loader: requests.load);
await _pumpSheet(tester, loader: requests.load);
await _openFilter(tester, 'Genre');
requests.request('genre').completeError(StateError('temporary failure'));
@@ -154,12 +149,49 @@ void main() {
expect(find.byType(ErrorStateWidget), findsNothing);
expect(find.text('All'), findsOneWidget);
harness.dispose();
});
testWidgets('settled filter-values states hug while the transient one holds the height', (tester) async {
tester.view.physicalSize = const Size(1280, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
final requests = _FilterRequests();
await _pumpSheet(tester, loader: requests.load);
const cap = 800 * 0.75;
double sheetHeight() => tester.getSize(find.byType(BottomSheetPageScaffold)).height;
// The root filters list must hug too — nothing else in the suite pins it.
// (`sheet == header + list` is a layout identity for a min-Column with no
// divider, so it holds even under a full fill; only the cap bound below
// actually discriminates.)
final filtersListHeight = sheetHeight();
expect(filtersListHeight, lessThan(cap), reason: 'two filters must not fill the cap');
// Drilling in is a setState page swap inside one sheet, so the transient
// spinner must hold the outgoing height: a change here moves the header and
// its Back button, and moves them straight back when the values land.
await _openFilter(tester, 'Genre');
expect(sheetHeight(), filtersListHeight, reason: 'the transient spinner must not move the sheet');
// Settled states hug — that is the empty space this change exists to remove.
requests.request('genre').completeError(StateError('temporary failure'));
await tester.pumpAndSettle();
expect(find.byType(ErrorStateWidget), findsOneWidget);
expect(sheetHeight(), lessThan(cap), reason: 'error state must not fill the cap');
await tester.tap(find.text('Retry'));
await tester.pump();
requests.request('genre', 1).complete([_value('action', 'Action')]);
await tester.pumpAndSettle();
expect(find.text('Action'), findsOneWidget);
expect(sheetHeight(), lessThan(cap), reason: 'short value list must not fill the cap');
});
testWidgets('cached values bypass the lazy loader', (tester) async {
var loadCount = 0;
final harness = await _pumpSheet(
await _pumpSheet(
tester,
loader: (_) async {
loadCount++;
@@ -174,7 +206,6 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Cached Genre'), findsOneWidget);
expect(loadCount, 0);
harness.dispose();
});
}
@@ -226,19 +257,31 @@ Future<_SheetHarness> _pumpSheet(
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
return _SheetHarness(config);
final harness = _SheetHarness(config);
addTearDown(harness.dispose);
return harness;
}
Future<void> _openFilter(WidgetTester tester, String title) async {
await tester.tap(find.text(title));
await tester.pump();
expect(find.byType(CircularProgressIndicator), findsOneWidget);
await _settleSheetResize(tester);
}
Future<void> _goBack(WidgetTester tester) async {
final headerRect = tester.getRect(find.byType(BottomSheetHeader));
await tester.tapAt(headerRect.centerLeft + const Offset(20, 0));
await tester.pump();
await _settleSheetResize(tester);
}
/// Advances past the host's 180ms resize tween. A page swap changes the sheet's
/// height, and mid-tween the content is laid out at its final size but clipped
/// by the still-animating box — so header geometry is not tappable until this
/// completes. `pumpAndSettle` cannot be used: the spinner never settles.
Future<void> _settleSheetResize(WidgetTester tester) async {
await tester.pump(const Duration(milliseconds: 200));
}
class _FilterRequests {