halkyon/packages/halkyon_ui/test/widget/adaptive_sheet_test.dart
Tizian 「ティツィアーン」 b908019969
feat(todo): dialogs on the desktop, the tabs reordered, a task's path and menu (ADR 0033, D11 to D14)
- halkyon_ui: showAdaptiveSheet shows a dialog on Linux, macOS and Windows
  at every width (bottom sheets stay on phones and tablets below expanded
  windows), so Learn and Train follow; SheetScaffold takes a heading in
  the title's place and may leave out its close button.
- Todo: the tabs are Inbox, Today, Upcoming, Browse, Today still opening;
  `serverpod start` launches the app on Linux, as it has no web build.
- A task's sheet shows where the task is (its project, an icon once tasks
  are above it, and the tasks above it, each opening what it names) and a
  ⋮ menu in place of its close button: when it was added (its UUID v7),
  Duplicate with its sub-tasks, hiding its done sub-tasks (synced in its
  payload), Close in the pane, Delete after asking. The description's icon
  stays by its first line.
- A row names its project and section at the end of its date's line; every
  menu entry has its icon.
- Learn: a studyset's path starts at the Library tab's icon, named by its
  tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-01 16:53:38 +01:00

271 lines
8.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
/// Opens a sheet titled "New studyset" with [body] and a Create button, in
/// a window [width] wide, with a status bar [statusBar] high on top.
Future<void> open(
WidgetTester tester, {
required double width,
Widget body = const Text('Body'),
double statusBar = 0,
bool busy = false,
}) async {
tester.view.physicalSize = Size(width, 900);
tester.view.devicePixelRatio = 1;
tester.view.padding = FakeViewPadding(top: statusBar);
tester.view.viewPadding = FakeViewPadding(top: statusBar);
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
supportedLocales: HalkyonUiLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showAdaptiveSheet<void>(
context,
swipeable: !busy,
builder: (context) => SheetScaffold(
title: 'New studyset',
busy: busy,
body: body,
footer: FilledButton(
onPressed: () {},
child: const Text('Create'),
),
),
),
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
}
testWidgets('is a bottom sheet below expanded windows on a phone or a '
'tablet', (tester) async {
await open(tester, width: 839);
expect(find.byType(BottomSheet), findsOneWidget);
expect(find.byType(Dialog), findsNothing);
});
testWidgets('is a dialog from expanded windows on', (tester) async {
await open(tester, width: 840);
expect(find.byType(Dialog), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
});
for (final platform in [
TargetPlatform.linux,
TargetPlatform.macOS,
TargetPlatform.windows,
]) {
testWidgets('is a dialog on the desktop however narrow the window '
'(${platform.name})', (tester) async {
debugDefaultTargetPlatformOverride = platform;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await open(tester, width: 400);
expect(find.byType(Dialog), findsOneWidget);
expect(find.byType(BottomSheet), findsNothing);
debugDefaultTargetPlatformOverride = null;
});
}
for (final width in [400.0, 1200.0]) {
testWidgets('keeps its close button and footer in view above a long body '
'(${width.round()} wide)', (tester) async {
await open(
tester,
width: width,
body: Column(
children: [for (var i = 0; i < 80; i++) Text('Line $i')],
),
);
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
expect(find.text('Create').hitTestable(), findsOneWidget);
});
}
testWidgets('stays below the status bar with the keyboard open', (
tester,
) async {
await open(
tester,
width: 400,
statusBar: 48,
body: Column(
children: [for (var i = 0; i < 80; i++) Text('Line $i')],
),
);
tester.view.viewInsets = const FakeViewPadding(bottom: 400);
await tester.pumpAndSettle();
expect(tester.getTopLeft(find.byType(BottomSheet)).dy, 48);
expect(find.byTooltip('Close').hitTestable(), findsOneWidget);
expect(find.text('Create').hitTestable(), findsOneWidget);
});
testWidgets('closes with its close button', (tester) async {
await open(tester, width: 400);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
expect(find.text('New studyset'), findsNothing);
});
testWidgets('while busy, its close button is off and back keeps it open', (
tester,
) async {
await open(tester, width: 400, busy: true);
expect(
tester
.widget<IconButton>(
find.ancestor(
of: find.byTooltip('Close'),
matching: find.byType(IconButton),
),
)
.onPressed,
isNull,
);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(find.text('New studyset'), findsOneWidget);
});
group('a body longer than the sheet', () {
final tall = Column(
children: [for (var i = 0; i < 40; i++) const Text('Line')],
);
Finder below() => find.byKey(SheetScaffold.moreBelowKey);
Finder above() => find.byKey(SheetScaffold.moreAboveKey);
testWidgets('shows a line above the buttons while more lies below, and '
'one under the title while more lies above', (tester) async {
// As a dialog: a modal bottom sheet takes a test's drag itself.
await open(tester, width: 1000, body: tall);
expect(below(), findsOneWidget);
expect(above(), findsNothing);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -300),
);
await tester.pumpAndSettle();
expect(below(), findsOneWidget);
expect(above(), findsOneWidget);
expect(
tester
.state<ScrollableState>(
find.descendant(
of: find.byType(SingleChildScrollView),
matching: find.byType(Scrollable),
),
)
.position
.pixels,
greaterThan(0),
reason: 'the line coming keeps where the body was scrolled to',
);
await tester.drag(
find.byType(SingleChildScrollView),
const Offset(0, -5000),
);
await tester.pumpAndSettle();
expect(below(), findsNothing);
expect(above(), findsOneWidget);
});
testWidgets('has a scrollbar, always shown on the desktop', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.linux;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
await open(tester, width: 1000, body: tall);
final scrollbar = tester.widget<Scrollbar>(find.byType(Scrollbar));
expect(scrollbar.thumbVisibility, isTrue);
debugDefaultTargetPlatformOverride = null;
});
});
testWidgets("a heading of its own takes the title's place, and the close "
'button may give way to its action', (tester) async {
tester.view.physicalSize = const Size(400, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
supportedLocales: HalkyonUiLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showAdaptiveSheet<void>(
context,
builder: (context) => const SheetScaffold(
title: 'Task',
heading: Text('Inbox › Shopping'),
closeButton: false,
action: Text('More'),
body: Text('Body'),
),
),
child: const Text('Open'),
),
),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Inbox › Shopping'), findsOneWidget);
expect(find.text('Task'), findsNothing);
expect(find.byTooltip('Close'), findsNothing);
expect(find.text('More'), findsOneWidget);
});
testWidgets('a body that fits shows no lines', (tester) async {
await open(tester, width: 400);
expect(find.byKey(SheetScaffold.moreBelowKey), findsNothing);
expect(find.byKey(SheetScaffold.moreAboveKey), findsNothing);
});
testWidgets("the body keeps clear of the scroll view's edges, where it is "
'cut and the scrollbar runs', (tester) async {
for (final width in [400.0, 1000.0]) {
await open(
tester,
width: width,
body: const TextField(
decoration: InputDecoration(border: OutlineInputBorder()),
),
);
final scrolling = tester.getRect(find.byType(SingleChildScrollView));
final field = tester.getRect(find.byType(TextField));
expect(field.left - scrolling.left, greaterThanOrEqualTo(16));
expect(scrolling.right - field.right, greaterThanOrEqualTo(16));
// The title and the button stay in line with the body.
expect(tester.getTopLeft(find.text('New studyset')).dx, field.left);
expect(tester.getTopRight(find.byType(FilledButton)).dx, field.right);
await tester.tap(find.byTooltip('Close'));
await tester.pumpAndSettle();
}
});
}