Files
FlClash/test/widgets/fit_sheet_test.dart
T
chen08209 5eafd7ad38 feat(theme): redesign theme colors with an iOS-style picker
Changelog: Redesigned theme colors and color styles
2026-10-17 17:21:43 +08:00

743 lines
24 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:fl_clash/providers/app.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/paged_sheet.dart';
import 'package:fl_clash/ui/scaffold.dart';
import 'package:fl_clash/ui/sheet.dart';
import 'package:fl_clash/ui/sheet_header.dart';
import 'package:fl_clash/ui/snap_sheet.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
const _view = Size(400, 900);
final _tallTop = _view.height * (1 - snapSheetDetents.last);
const _rowHeight = 48.0;
Widget _list(int rows) {
return CommonScaffold(
title: 'title',
body: ListView(
shrinkWrap: true,
children: [
for (var index = 0; index < rows; index++)
ListTile(
title: Text('row $index'),
minTileHeight: _rowHeight,
onTap: () {},
),
],
),
);
}
void main() {
Future<Future<Object?>> openSheet(
WidgetTester tester,
WidgetBuilder builder, {
SheetProps props = const SheetProps(),
}) async {
tester.view.physicalSize = _view;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
late Future<Object?> result;
await tester.pumpWidget(
TestApp(
overrides: [viewSizeProvider.overrideWithBuild((_, _) => _view)],
child: Builder(
builder: (context) => TextButton(
onPressed: () {
result = showSheet<Object?>(
context: context,
props: props,
builder: builder,
);
},
child: const Text('open'),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
return result;
}
double sheetTop(WidgetTester tester) =>
tester.getTopLeft(find.byType(SheetDragHandle)).dy;
Finder content() => find.byType(Scrollable).last;
double contentOffset(WidgetTester tester) =>
tester.state<ScrollableState>(content()).position.pixels;
bool isOpen(WidgetTester tester) => tester.any(find.text('title'));
testWidgets('a short sheet rests at the short detent and closes when '
'its content is dragged down', (tester) async {
final result = await openSheet(tester, (_) => _list(3));
var closed = false;
unawaited(result.then((_) => closed = true));
final top = sheetTop(tester);
expect(top, closeTo(_view.height * (1 - snapSheetDetents.first), 1));
expect(tester.getBottomLeft(find.text('row 2')).dy, lessThan(_view.height));
await tester.drag(find.text('row 1'), const Offset(0, 40));
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(top, 1));
expect(isOpen(tester), isTrue);
await tester.fling(find.text('row 1'), const Offset(0, -200), 2000);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(top, 1));
await tester.drag(find.text('row 1'), Offset(0, _view.height - top));
await tester.pumpAndSettle();
expect(closed, isTrue);
expect(isOpen(tester), isFalse);
});
testWidgets('a long sheet rests at the short detent, opens to the tall one '
'before its content scrolls and closes from the short detent', (
tester,
) async {
await openSheet(tester, (_) => _list(60));
final top = sheetTop(tester);
expect(top, closeTo(_view.height * (1 - snapSheetDetents.first), 1));
await tester.fling(content(), const Offset(0, -200), 2000);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
expect(contentOffset(tester), 0);
await tester.drag(content(), const Offset(0, -200));
await tester.pumpAndSettle();
expect(contentOffset(tester), greaterThan(0));
expect(sheetTop(tester), closeTo(_tallTop, 1));
await tester.drag(content(), const Offset(0, 200));
await tester.pumpAndSettle();
expect(contentOffset(tester), 0);
await tester.fling(content(), const Offset(0, 300), 2000);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(top, 1));
expect(isOpen(tester), isTrue);
await tester.fling(content(), const Offset(0, 300), 2000);
await tester.pumpAndSettle();
expect(isOpen(tester), isFalse);
});
testWidgets('a sheet a little taller than the short detent opens to its '
'content height and no further', (tester) async {
await openSheet(tester, (_) => _list(14));
final top = sheetTop(tester);
expect(top, closeTo(_view.height * (1 - snapSheetDetents.first), 1));
await tester.drag(content(), const Offset(0, -600));
await tester.pumpAndSettle();
final open = sheetTop(tester);
expect(open, inExclusiveRange(1, top - 1));
expect(
tester.getBottomLeft(find.text('row 13')).dy,
lessThanOrEqualTo(_view.height),
);
await tester.fling(content(), const Offset(0, -300), 2000);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(open, 1));
});
testWidgets('a flick up from the short detent rises to the height of '
'content a little taller than it without dropping on the way', (
tester,
) async {
await openSheet(tester, (_) => _list(14));
await tester.fling(content(), const Offset(0, -100), 3000);
var top = sheetTop(tester);
for (var frame = 0; frame < 40; frame++) {
await tester.pump(const Duration(milliseconds: 16));
expect(sheetTop(tester), lessThan(top + 8), reason: 'frame $frame');
top = sheetTop(tester);
}
await tester.pumpAndSettle();
expect(sheetTop(tester), greaterThan(_tallTop + 1));
expect(
tester.getBottomLeft(find.text('row 13')).dy,
lessThanOrEqualTo(_view.height),
);
});
testWidgets('a drag let go nearer the height of content a little taller '
'than the short detent than the detent opens the sheet to it', (
tester,
) async {
await openSheet(tester, (_) => _list(14));
final short = sheetTop(tester);
final gesture = await tester.startGesture(tester.getCenter(content()));
for (var move = 0; move < 6; move++) {
await gesture.moveBy(const Offset(0, -20));
await tester.pump(const Duration(milliseconds: 16));
}
await tester.pump(const Duration(milliseconds: 100));
await gesture.up();
await tester.pumpAndSettle();
expect(sheetTop(tester), lessThan(short - 1));
expect(
tester.getBottomLeft(find.text('row 13')).dy,
lessThanOrEqualTo(_view.height),
);
});
testWidgets('a wheel tick opens a sheet a little taller than the short '
'detent to its content height without dropping on the way', (
tester,
) async {
await openSheet(tester, (_) => _list(14));
final pointer = TestPointer(1, PointerDeviceKind.mouse);
await tester.sendEventToBinding(pointer.hover(tester.getCenter(content())));
await tester.sendEventToBinding(pointer.scroll(const Offset(0, 50)));
var top = sheetTop(tester);
for (var frame = 0; frame < 40; frame++) {
await tester.pump(const Duration(milliseconds: 16));
expect(sheetTop(tester), lessThan(top + 8), reason: 'frame $frame');
top = sheetTop(tester);
}
await tester.pumpAndSettle();
expect(sheetTop(tester), greaterThan(_tallTop + 1));
expect(
tester.getBottomLeft(find.text('row 13')).dy,
lessThanOrEqualTo(_view.height),
);
});
double rowBottom(WidgetTester tester, int row) =>
tester.getBottomLeft(find.widgetWithText(ListTile, 'row $row')).dy;
Future<void> raiseKeyboard(WidgetTester tester, double height) async {
for (final inset in [height / 4, height / 2, height * 3 / 4, height]) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
// The engine takes the keyboard out of the padding.
tester.view.padding = FakeViewPadding.zero;
await tester.pump();
}
await tester.pumpAndSettle();
}
Future<void> lowerKeyboard(WidgetTester tester) async {
tester.view.resetViewInsets();
tester.view.resetPadding();
await tester.pumpAndSettle();
}
testWidgets('a short sheet keeps its content above the keyboard and comes '
'back to the short detent', (tester) async {
await openSheet(tester, (_) => _list(3));
tester.view.viewPadding = const FakeViewPadding(bottom: 30);
tester.view.padding = const FakeViewPadding(bottom: 30);
await tester.pumpAndSettle();
final top = sheetTop(tester);
expect(top, closeTo(_view.height * (1 - snapSheetDetents.first), 1));
const keyboard = 400.0;
await raiseKeyboard(tester, keyboard);
expect(rowBottom(tester, 2), lessThanOrEqualTo(_view.height - keyboard));
tester.view.resetViewInsets();
tester.view.padding = const FakeViewPadding(bottom: 30);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(top, 1));
});
testWidgets('a long sheet opens past the tall detent to a peek below the '
'top while the keyboard is still rising', (tester) async {
await openSheet(tester, (_) => _list(60));
for (final inset in [80.0, 160.0]) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
await tester.pump(const Duration(milliseconds: 16));
}
var top = sheetTop(tester);
for (final inset in [240.0, 320.0, 400.0]) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
await tester.pump(const Duration(milliseconds: 16));
expect(sheetTop(tester), lessThan(top));
top = sheetTop(tester);
}
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(stackedSheetPeek, 1));
});
testWidgets('a long sheet at the tall detent rises with the keyboard into '
'the peek and comes back down with it on every frame', (tester) async {
await openSheet(tester, (_) => _list(60));
addTearDown(tester.view.resetViewInsets);
await tester.fling(content(), const Offset(0, -200), 2000);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
var top = _tallTop;
var last = 0.0;
for (final inset in [40.0, 120.0, 300.0, 120.0, 40.0]) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
await tester.pump(const Duration(milliseconds: 16));
final moved = sheetTop(tester);
expect(
moved,
inInclusiveRange(
math.max(stackedSheetPeek, _tallTop - inset) - 1,
_tallTop - 1,
),
);
expect(inset > last ? top - moved : moved - top, greaterThan(1));
top = moved;
last = inset;
}
expect(top, greaterThan(stackedSheetPeek + 1));
tester.view.viewInsets = const FakeViewPadding(bottom: 300);
await tester.pump(const Duration(milliseconds: 16));
expect(sheetTop(tester), closeTo(stackedSheetPeek, 1));
tester.view.resetViewInsets();
await tester.pump(const Duration(milliseconds: 16));
expect(sheetTop(tester), closeTo(_tallTop, 1));
});
Future<List<double>> trackKeyboard(
WidgetTester tester,
Iterable<double> insets,
) async {
final tops = <double>[];
for (final inset in insets) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
await tester.pump(const Duration(milliseconds: 16));
tops.add(sheetTop(tester));
}
return tops;
}
/// A keyboard moving at a steady speed never jolts the sheet: its speed
/// changes by a small part of the keyboard's from one frame to the next.
void expectEased(List<double> tops, double step) {
for (var index = 2; index < tops.length; index++) {
final speed = tops[index] - tops[index - 1];
final before = tops[index - 1] - tops[index - 2];
expect((speed - before).abs(), lessThan(step / 4));
}
}
testWidgets('a short sheet starts and stops rising with the keyboard and '
'keeps its content above it', (tester) async {
await openSheet(tester, (_) => _list(3));
addTearDown(tester.view.resetViewInsets);
final rest = sheetTop(tester);
const step = 10.0;
final insets = [for (var inset = 0.0; inset <= 400; inset += step) inset];
final rising = await trackKeyboard(tester, insets);
expect(rising.last, lessThan(rest - 1));
expect(rowBottom(tester, 2), lessThanOrEqualTo(_view.height - 400));
expectEased(rising, step);
final falling = await trackKeyboard(tester, insets.reversed);
expectEased(falling, step);
expect(falling.last, closeTo(rest, 0.01));
});
testWidgets('a long sheet at the tall detent slows into the peek with the '
'keyboard', (tester) async {
await openSheet(tester, (_) => _list(60));
addTearDown(tester.view.resetViewInsets);
await tester.fling(content(), const Offset(0, -200), 2000);
await tester.pumpAndSettle();
const step = 8.0;
final insets = [for (var inset = 0.0; inset <= 240; inset += step) inset];
final tops = await trackKeyboard(tester, insets);
expectEased(tops, step);
expect(tops.last, closeTo(stackedSheetPeek, 1));
});
testWidgets('the keyboard moves a sheet without laying out or painting its '
'content again', (tester) async {
for (final rows in [3, 60]) {
final counts = _Counts();
await openSheet(
tester,
(_) => CommonScaffold(
title: 'title',
body: _Counted(
counts: counts,
child: ListView(
shrinkWrap: true,
children: [
for (var index = 0; index < rows; index++)
SizedBox(height: _rowHeight, child: Text('row $index')),
],
),
),
),
);
addTearDown(tester.view.resetViewInsets);
counts
..layouts = 0
..paints = 0;
final tops = await trackKeyboard(tester, [100, 200, 300, 200, 100, 0]);
expect(tops.toSet(), hasLength(greaterThan(2)), reason: '$rows rows');
expect(counts.layouts, 0, reason: '$rows rows');
expect(counts.paints, 0, reason: '$rows rows');
Navigator.of(tester.element(find.text('row 0'))).pop();
await tester.pumpAndSettle();
}
});
testWidgets('a settle whose detent the keyboard moves every frame keeps '
'moving every frame', (tester) async {
await openSheet(tester, (_) => _list(60));
addTearDown(tester.view.resetViewInsets);
await tester.fling(content(), const Offset(0, -100), 2000);
var top = sheetTop(tester);
for (var frame = 1; frame <= 12; frame++) {
tester.view.viewInsets = FakeViewPadding(bottom: frame * 20.0);
await tester.pump(const Duration(milliseconds: 16));
expect(sheetTop(tester), lessThan(top - 1));
top = sheetTop(tester);
}
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(stackedSheetPeek, 1));
});
testWidgets('a sheet the tall detent would cut off above the keyboard '
'rises only as far as its content needs', (tester) async {
await openSheet(tester, (_) => _list(9));
const keyboard = 400.0;
await raiseKeyboard(tester, keyboard);
expect(sheetTop(tester), lessThan(_tallTop));
expect(sheetTop(tester), greaterThan(stackedSheetPeek));
expect(rowBottom(tester, 8), lessThanOrEqualTo(_view.height - keyboard));
expect(
tester.state<ScrollableState>(content()).position.maxScrollExtent,
0,
);
await lowerKeyboard(tester);
expect(
sheetTop(tester),
closeTo(_view.height * (1 - snapSheetDetents.first), 1),
);
});
testWidgets('a nested sheet keeps its content above the keyboard on every '
'frame the keyboard moves', (tester) async {
await openSheet(
tester,
(_) => NestedPagedSheet(builder: (_) => _list(3)),
props: nestedPagedSheetProps,
);
for (final inset in [100.0, 200.0, 300.0, 400.0, 250.0, 0.0]) {
tester.view.viewInsets = FakeViewPadding(bottom: inset);
await tester.pump();
expect(rowBottom(tester, 2), lessThanOrEqualTo(_view.height - inset + 1));
}
});
testWidgets('a short sheet the keyboard has left grows to the short detent '
'as if it had never risen', (tester) async {
final rows = ValueNotifier(3);
addTearDown(rows.dispose);
await openSheet(
tester,
(_) => ValueListenableBuilder(
valueListenable: rows,
builder: (_, count, _) => _list(count),
),
);
await raiseKeyboard(tester, 400);
await lowerKeyboard(tester);
rows.value = 20;
await tester.pumpAndSettle();
expect(
sheetTop(tester),
closeTo(_view.height * (1 - snapSheetDetents.first), 1),
);
});
testWidgets('a nested sheet opened to the tall detent stays there across '
'a shorter page', (tester) async {
Widget page({required VoidCallback onNext}) => CommonScaffold(
title: 'title',
body: ListView(
children: [
for (var index = 0; index < 60; index++)
SizedBox(
height: _rowHeight,
child: index == 3
? TextButton(onPressed: onNext, child: const Text('next'))
: Text('row $index'),
),
],
),
);
await openSheet(
tester,
(_) => NestedPagedSheet(
builder: (context) => page(
onNext: () => Navigator.of(
context,
).push(PagedSheetRoute(builder: (_) => _list(3))),
),
),
props: nestedPagedSheetProps,
);
await tester.fling(find.text('row 5'), const Offset(0, -200), 2000);
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
await tester.tap(find.text('next'));
await tester.pumpAndSettle();
expect(
sheetTop(tester),
closeTo(_view.height * (1 - snapSheetDetents.first), 1),
);
await tester.pageBack();
await tester.pumpAndSettle();
expect(sheetTop(tester), closeTo(_tallTop, 1));
});
testWidgets('a nested sheet resting at its page height follows a shorter '
'page down on every frame of the push', (tester) async {
final shortTop = _view.height * (1 - snapSheetDetents.first);
await openSheet(
tester,
(_) => NestedPagedSheet(
builder: (context) => CommonScaffold(
title: 'title',
body: ListView(
shrinkWrap: true,
children: [
for (var index = 0; index < 11; index++)
ListTile(title: Text('row $index'), minTileHeight: _rowHeight),
ListTile(
title: const Text('next'),
minTileHeight: _rowHeight,
onTap: () => Navigator.of(
context,
).push(PagedSheetRoute(builder: (_) => _list(3))),
),
],
),
),
),
props: nestedPagedSheetProps,
);
double top() => tester.getTopLeft(find.byType(SheetDragHandle).first).dy;
await tester.drag(find.text('row 2'), const Offset(0, -300));
await tester.pumpAndSettle();
var last = top();
expect(last, inExclusiveRange(_tallTop + 1, shortTop - 1));
await tester.tap(find.text('next'));
for (var frame = 0; frame < 30; frame++) {
await tester.pump(const Duration(milliseconds: 16));
expect(top(), greaterThanOrEqualTo(last - 0.5));
last = top();
}
await tester.pumpAndSettle();
expect(top(), closeTo(shortTop, 1));
});
testWidgets('a mouse moving over the sheet between frames finds it laid '
'out after a drag moved it', (tester) async {
await openSheet(tester, (_) => _list(60));
final handle = tester.getCenter(find.byType(SheetDragHandle));
final drag = await tester.startGesture(
handle,
kind: PointerDeviceKind.mouse,
);
await drag.moveBy(const Offset(0, -40));
await drag.moveBy(const Offset(0, -40));
await drag.up();
await drag.moveBy(const Offset(0, 40));
expect(tester.takeException(), isNull);
await tester.pumpAndSettle();
await drag.removePointer();
});
testWidgets('a tap outside closes the sheet', (tester) async {
await openSheet(tester, (_) => _list(3));
await tester.tapAt(const Offset(200, 40));
await tester.pumpAndSettle();
expect(isOpen(tester), isFalse);
});
testWidgets('a nested sheet decides what a drag away and a tap outside do', (
tester,
) async {
final dismissals = <bool>[];
await openSheet(
tester,
(_) =>
NestedPagedSheet(builder: (_) => _list(3), onDismiss: dismissals.add),
props: nestedPagedSheetProps,
);
final top = sheetTop(tester);
await tester.fling(find.text('row 1'), const Offset(0, 300), 2000);
await tester.pumpAndSettle();
await tester.tapAt(const Offset(200, 40));
await tester.pumpAndSettle();
expect(dismissals, [false, false]);
expect(isOpen(tester), isTrue);
expect(sheetTop(tester), closeTo(top, 1));
});
for (final closes in [true, false]) {
testWidgets('a nested sheet that ${closes ? 'closes' : 'stays'} after a '
'drag away springs back while it decides', (tester) async {
final decision = Completer<void>();
await openSheet(
tester,
(_) => NestedPagedSheet(
builder: (_) => _list(3),
onDismiss: (_) async {
await decision.future;
if (closes) {
globalState.navigatorKey.currentState!.pop();
}
},
),
props: nestedPagedSheetProps,
);
final top = sheetTop(tester);
await tester.fling(find.text('row 1'), const Offset(0, 300), 2000);
await tester.pump();
expect(sheetTop(tester), greaterThan(top));
await tester.pump(const Duration(seconds: 1));
await tester.pump(const Duration(seconds: 1));
expect(isOpen(tester), isTrue);
expect(sheetTop(tester), closeTo(top, 1));
decision.complete();
var lowest = top;
for (var frame = 0; frame < 30 && isOpen(tester); frame++) {
await tester.pump(const Duration(milliseconds: 16));
if (!isOpen(tester)) {
break;
}
final current = sheetTop(tester);
expect(current, greaterThanOrEqualTo(lowest - 1));
lowest = current;
}
await tester.pumpAndSettle();
expect(isOpen(tester), !closes);
if (!closes) {
expect(sheetTop(tester), closeTo(top, 1));
}
});
}
testWidgets(
'pages kept alive in a nested sheet scroll on their own controllers and '
'still hand a drag down to the sheet',
(tester) async {
final dismissals = <bool>[];
Widget page(String name, {VoidCallback? onNext}) => CommonScaffold(
title: 'title',
body: ListView(
children: [
if (onNext != null)
TextButton(onPressed: onNext, child: const Text('next')),
for (var index = 0; index < 60; index++)
SizedBox(height: _rowHeight, child: Text('$name $index')),
],
),
);
await openSheet(
tester,
(_) => NestedPagedSheet(
builder: (context) => page(
'a',
onNext: () => Navigator.of(
context,
).push(PagedSheetRoute(builder: (_) => page('b'))),
),
onDismiss: dismissals.add,
),
props: nestedPagedSheetProps,
);
await tester.tap(find.text('next'));
await tester.pumpAndSettle();
await tester.drag(find.text('b 5'), const Offset(0, -200));
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
await tester.drag(find.text('b 8'), const Offset(0, 200));
await tester.pumpAndSettle();
await tester.fling(find.text('b 3'), const Offset(0, 300), 2000);
await tester.pumpAndSettle();
expect(dismissals, [true]);
},
variant: TargetPlatformVariant.only(TargetPlatform.macOS),
);
}
class _Counts {
var layouts = 0;
var paints = 0;
}
class _Counted extends SingleChildRenderObjectWidget {
const _Counted({required this.counts, required super.child});
final _Counts counts;
@override
RenderObject createRenderObject(BuildContext context) =>
_RenderCounted(counts);
}
class _RenderCounted extends RenderProxyBox {
_RenderCounted(this.counts);
final _Counts counts;
@override
void performLayout() {
counts.layouts++;
super.performLayout();
}
@override
void paint(PaintingContext context, Offset offset) {
counts.paints++;
super.paint(context, offset);
}
}