Halkyon's shared packages

A pub workspace of the Dart packages Halkyon's apps share: halkyon_ui,
Halkyon's look (the theme, fonts, layout and shared widgets, with texts
in English and German), halkyon_core's low-level primitives and
halkyon_interop's cross-app contracts. Dart 3.13 with LeanCode's lints,
configured once at the root; apps take a package by git, pinned to a
signed commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-18 21:46:55 +02:00
commit 80fa7b9628
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
14 changed files with 6762 additions and 0 deletions

View file

@ -0,0 +1,216 @@
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', (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 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 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();
}
});
}

View file

@ -0,0 +1,43 @@
import 'dart:async';
import 'package:expressive_loading_indicator/expressive_loading_indicator.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
testWidgets("pulled down, it refreshes with the app's spinner, Material 3 "
"Expressive's, not Flutter's older one", (tester) async {
final refreshing = Completer<void>();
var refreshes = 0;
await tester.pumpWidget(
MaterialApp(
theme: appTheme(Brightness.light),
home: Scaffold(
body: AppRefreshIndicator(
onRefresh: () {
refreshes++;
return refreshing.future;
},
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [Text('Japanese')],
),
),
),
),
);
expect(find.byType(ExpressiveLoadingIndicator), findsNothing);
await tester.fling(find.text('Japanese'), const Offset(0, 400), 1000);
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(refreshes, 1);
expect(find.byType(ExpressiveLoadingIndicator), findsOneWidget);
expect(find.byType(CircularProgressIndicator), findsNothing);
refreshing.complete();
await tester.pumpAndSettle();
expect(find.byType(ExpressiveLoadingIndicator), findsNothing);
});
}

View file

@ -0,0 +1,17 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
void main() {
test('steps go to the next multiple of the step, never below the first', () {
expect(stepUp(12, 5, max: 100), 15);
expect(stepUp(15, 5, max: 100), 20);
expect(stepUp(98, 5, max: 100), 100);
expect(stepDown(12, 5), 10);
expect(stepDown(15, 5), 10);
expect(stepDown(7, 5), 5);
expect(canStepDown(5, 5), isFalse);
expect(canStepDown(6, 5), isTrue);
expect(stepUp(1, 25, max: 1000), 25);
expect(stepDown(230, 25), 225);
});
}

View file

@ -0,0 +1,121 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
void main() {
late StreamController<List<String>> items;
setUp(() => items = StreamController());
tearDown(() => items.close());
/// Opens the screen of the item [id] over a first screen.
Future<void> open(
WidgetTester tester,
String id, {
({String label, VoidCallback onPressed})? wayBack,
}) async {
await tester.pumpWidget(
testApp(
Builder(
builder: (context) => TextButton(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => FoundById<String>(
id: id,
items: () => items.stream,
idOf: (item) => item,
builder: (item, items) =>
Scaffold(body: Text('Item $item of ${items.length}')),
wayBack: wayBack,
),
),
),
child: const Text('Open'),
),
),
),
);
await tester.tap(find.text('Open'));
// Through the opening transition; the spinner never settles.
await tester.pump();
await tester.pump(const Duration(seconds: 1));
}
testWidgets('shows a spinner until the list arrives, then the item', (
tester,
) async {
await open(tester, 'a');
expect(find.byType(AppLoadingIndicator), findsOneWidget);
items.add(['a', 'b']);
await tester.pump();
expect(find.text('Item a of 2'), findsOneWidget);
expect(find.byType(AppLoadingIndicator), findsNothing);
});
testWidgets('says that an item missing from the list no longer exists', (
tester,
) async {
await open(tester, 'c');
items.add(['a', 'b']);
await tester.pump();
expect(find.textContaining('no longer exists'), findsOneWidget);
});
testWidgets('leads the way back from there, as the app says', (
tester,
) async {
var ledBack = 0;
await open(
tester,
'c',
wayBack: (label: 'To the library', onPressed: () => ledBack++),
);
items.add(['a']);
await tester.pump();
await tester.tap(find.text('To the library'));
expect(ledBack, 1);
});
testWidgets('says so once the item goes while its screen is open', (
tester,
) async {
await open(tester, 'a');
items.add(['a']);
await tester.pump();
// Deleted on another device, say.
items.add([]);
await tester.pump();
expect(find.text('Item a of 1'), findsNothing);
expect(find.textContaining('no longer exists'), findsOneWidget);
});
testWidgets('a screen that closes as its item goes keeps showing the item '
'on its way out', (tester) async {
await open(tester, 'a');
items.add(['a']);
await tester.pump();
// As when the screen deleted its item and closes.
Navigator.of(tester.element(find.text('Item a of 1'))).pop();
items.add([]);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
expect(find.text('Item a of 1'), findsOneWidget);
expect(find.textContaining('no longer exists'), findsNothing);
await tester.pumpAndSettle();
expect(find.text('Open'), findsOneWidget);
});
}

View file

@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> leaveAndComeBack(WidgetTester tester) async {
[
AppLifecycleState.inactive,
AppLifecycleState.hidden,
AppLifecycleState.paused,
AppLifecycleState.hidden,
AppLifecycleState.inactive,
AppLifecycleState.resumed,
].forEach(tester.binding.handleAppLifecycleStateChanged);
await tester.pump();
}
bool keyboardAskedFor(WidgetTester tester) =>
tester.testTextInput.log.any((call) => call.method == 'TextInput.show');
testWidgets('a field focused when the app was left has its keyboard back '
'on return', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: KeyboardBackOnResume(
child: Scaffold(body: TextField(autofocus: true)),
),
),
);
await tester.pump();
tester.testTextInput.log.clear();
await leaveAndComeBack(tester);
expect(keyboardAskedFor(tester), isTrue);
});
testWidgets('without a field focused, no keyboard', (tester) async {
await tester.pumpWidget(
const MaterialApp(
home: KeyboardBackOnResume(child: Scaffold(body: TextField())),
),
);
tester.testTextInput.log.clear();
await leaveAndComeBack(tester);
expect(keyboardAskedFor(tester), isFalse);
});
}

View file

@ -0,0 +1,53 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
/// A transparent PNG of one pixel, standing in for an app's icon.
final _icon = MemoryImage(
Uint8List.fromList(const [
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, //
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, 0x00, 0x00, 0x00,
0x0D, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00, 0x00, 0x00, 0x00, 0x49,
0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
]),
);
void main() {
testWidgets("shows the app's icon, and \"Halkyon\" over the app's word, "
"read out as the app's name", (tester) async {
await tester.pumpWidget(
testApp(LaunchScreen(icon: _icon, product: 'Todo', name: 'Halkyon Todo')),
);
expect(find.byType(Image), findsOneWidget);
final brand = tester.getRect(find.text('Halkyon'));
final product = tester.getRect(find.text('Todo'));
expect(product.top, greaterThanOrEqualTo(brand.bottom - 1));
expect(product.center.dx, moreOrLessEquals(brand.center.dx));
expect(find.bySemanticsLabel('Halkyon Todo'), findsOneWidget);
});
testWidgets('the wordmark is cobalt over copper, and silent to screen '
'readers', (tester) async {
await tester.pumpWidget(
testApp(const Scaffold(body: BrandWordmark(product: 'Train'))),
);
final scheme = appTheme(Brightness.light).colorScheme;
expect(
tester.widget<Text>(find.text('Halkyon')).style?.color,
scheme.primary,
);
expect(
tester.widget<Text>(find.text('Train')).style?.color,
scheme.tertiary,
);
expect(find.bySemanticsLabel('Train'), findsNothing);
});
}

View file

@ -0,0 +1,58 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
/// A list of [lines] with a button that hides while reading onwards.
Future<void> show(WidgetTester tester, {int lines = 100}) async {
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
home: ScrollHidingFab(
builder: (context, fab) => Scaffold(
floatingActionButton: fab,
body: ListView(
children: [
for (var i = 0; i < lines; i++)
SizedBox(height: 48, child: Text('Line $i')),
],
),
),
fab: FloatingActionButton(
tooltip: 'New studyset',
onPressed: () {},
child: const Icon(Icons.add),
),
),
),
);
}
Finder fab() => find.byTooltip('New studyset').hitTestable();
testWidgets('hides while scrolling down and returns when scrolling up', (
tester,
) async {
await show(tester);
expect(fab(), findsOneWidget);
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(fab(), findsNothing);
await tester.drag(find.byType(ListView), const Offset(0, 100));
await tester.pumpAndSettle();
expect(fab(), findsOneWidget);
});
testWidgets('stays when the list fits without scrolling', (tester) async {
await show(tester, lines: 3);
await tester.drag(find.byType(ListView), const Offset(0, -300));
await tester.pumpAndSettle();
expect(fab(), findsOneWidget);
});
}

View file

@ -0,0 +1,64 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
void main() {
/// Pumps [child] under a screen tint, in a medium window, so a gradient
/// in it claims its colour while in view.
Future<ScreenTint> show(WidgetTester tester, {required bool visible}) async {
tester.view.physicalSize = const Size(800, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tint = ScreenTint();
addTearDown(tint.dispose);
await tester.pumpWidget(
testApp(
ScreenTintScope(
notifier: tint,
child: TabVisibility(
visible: visible,
child: const TopGradient(
color: Color(0xFFBA1E55),
background: Color(0xFFFFFFFF),
child: SizedBox.expand(),
),
),
),
),
);
await tester.pump();
return tint;
}
testWidgets('a screen whose tab shows gives the frame its colour', (
tester,
) async {
final tint = await show(tester, visible: true);
expect(tint.color, const Color(0xFFBA1E55));
});
testWidgets("a hidden tab's screen leaves the frame's colour alone", (
tester,
) async {
final tint = await show(tester, visible: false);
expect(tint.color, Brand.primary);
});
testWidgets('outside any tabs, a screen counts as shown', (tester) async {
late bool visible;
await tester.pumpWidget(
Builder(
builder: (context) {
visible = TabVisibility.of(context);
return const SizedBox();
},
),
);
expect(visible, isTrue);
});
}

View file

@ -0,0 +1,44 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<TextEditingController> show(WidgetTester tester) async {
final controller = TextEditingController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: TextField(
controller: controller,
maxLength: TextLimits.name,
buildCounter: nearLimitCounter,
),
),
),
);
return controller;
}
testWidgets('a field counts only near its limit, and stops there', (
tester,
) async {
final controller = await show(tester);
await tester.enterText(find.byType(TextField), 'a' * 10);
await tester.pump();
expect(find.textContaining(' / ${TextLimits.name}'), findsNothing);
await tester.enterText(find.byType(TextField), 'a' * 35);
await tester.pump();
expect(find.text('35 / ${TextLimits.name}'), findsOneWidget);
await tester.enterText(find.byType(TextField), 'a' * 100);
await tester.pump();
expect(controller.text, hasLength(TextLimits.name));
expect(
find.text('${TextLimits.name} / ${TextLimits.name}'),
findsOneWidget,
);
});
}

View file

@ -0,0 +1,48 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
/// A screen with something typed into it, as a study session or a card
/// being written holds what was done.
Future<void> typeIn(
WidgetTester tester,
Widget Function(Widget) around,
) async {
tester.view.physicalSize = const Size(1200, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
MaterialApp(
theme: appTheme(Brightness.light),
home: around(const Scaffold(body: TextField())),
),
);
await tester.enterText(find.byType(TextField), 'kept');
}
/// Crosses every line where the frame or the snack bars change: medium,
/// compact, medium again.
Future<void> resize(WidgetTester tester) async {
for (final width in const [700.0, 400.0, 1200.0]) {
tester.view.physicalSize = Size(width, 800);
await tester.pumpAndSettle();
}
}
testWidgets('a framed screen keeps what it holds when the window crosses '
'from medium to compact and back', (tester) async {
await typeIn(tester, (screen) => FramedScreen(child: screen));
await resize(tester);
expect(find.text('kept'), findsOneWidget);
});
testWidgets('so does every screen under the snack bars fitted to the '
'window', (tester) async {
await typeIn(tester, (screen) => FittedSnackBars(child: screen));
await resize(tester);
expect(find.text('kept'), findsOneWidget);
});
}

View file

@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
Future<void> show(WidgetTester tester, Widget child) => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: Align(
alignment: Alignment.topLeft,
child: SizedBox(width: 400, child: child),
),
),
),
);
double top(WidgetTester tester, Finder finder) =>
tester.getTopLeft(finder).dy;
testWidgets('shows the kicker uppercased above the title', (tester) async {
await show(
tester,
const TitleWithKicker(title: 'Vocabulary', kicker: 'Japanese'),
);
expect(find.text('JAPANESE'), findsOneWidget);
expect(
top(tester, find.text('JAPANESE')),
lessThan(top(tester, find.text('Vocabulary'))),
);
});
testWidgets('shows the title alone without a kicker', (tester) async {
await show(tester, const TitleWithKicker(title: 'Vocabulary', kicker: ''));
expect(top(tester, find.text('Vocabulary')), 0);
});
testWidgets('can keep the kicker line free, so titles side by side line '
'up', (tester) async {
await show(
tester,
const Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: TitleWithKicker(title: 'Vocabulary', kicker: 'JLPT'),
),
Expanded(
child: TitleWithKicker(title: 'Grammar', reserveKickerSpace: true),
),
],
),
);
expect(
top(tester, find.text('Grammar')),
top(tester, find.text('Vocabulary')),
);
});
testWidgets('puts the trailing widget beside the title, not the kicker', (
tester,
) async {
const action = Key('action');
await show(
tester,
const TitleWithKicker(
title: 'Vocabulary',
kicker: 'Japanese',
trailing: SizedBox(key: action, width: 48, height: 48),
),
);
expect(
top(tester, find.byKey(action)),
top(tester, find.text('Vocabulary')),
);
expect(
tester.getTopRight(find.byKey(action)).dx,
400,
reason: 'the title takes the rest of the width',
);
});
}

View file

@ -0,0 +1,139 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
group('The tone at the top', () {
for (final brightness in Brightness.values) {
final scheme = appTheme(brightness).colorScheme;
for (final background in [scheme.surface, scheme.surfaceContainerLow]) {
test('keeps the text readable on every palette colour, '
'${brightness.name}, $background', () {
for (final color in PaletteColor.values) {
final top = TopGradient.topTone(
scheme,
color.background,
background,
);
expect(
TopGradient.contrast(top, scheme.onSurface),
greaterThanOrEqualTo(TopGradient.minContrast),
reason: color.name,
);
}
});
}
}
test("the frame around the tabs keeps the rail's labels readable on "
'every palette colour, top and foot', () {
for (final brightness in Brightness.values) {
final scheme = appTheme(brightness).colorScheme;
for (final color in PaletteColor.values) {
for (final tone in TopGradient.frame(
scheme,
color.background,
).colors) {
expect(
TopGradient.contrast(tone, scheme.onSurfaceVariant),
greaterThanOrEqualTo(TopGradient.minContrast),
reason: '${brightness.name} ${color.name}',
);
}
}
}
});
test('is a deep shade of cobalt in dark mode, a tint in light mode', () {
final dark = appTheme(Brightness.dark).colorScheme;
final light = appTheme(Brightness.light).colorScheme;
expect(
TopGradient.topTone(dark, Brand.primary, dark.surface),
Color.lerp(
const Color(0xFF000000),
Brand.primary,
TopGradient.darkShade,
),
);
expect(
TopGradient.topTone(light, Brand.primary, light.surface),
Color.lerp(light.surface, Brand.primary, TopGradient.lightMix),
);
});
test('half as strong inside the panel', () {
final dark = appTheme(Brightness.dark).colorScheme;
final full = Color.lerp(
const Color(0xFF000000),
Brand.primary,
TopGradient.darkShade,
)!;
expect(
TopGradient.topTone(
dark,
Brand.primary,
dark.surface,
share: TopGradient.panelShare,
),
Color.lerp(dark.surface, full, TopGradient.panelShare),
);
});
test('mixes in less where the full share would be unreadable', () {
final dark = appTheme(Brightness.dark).colorScheme;
// Light text can't sit on a shade of white at 42 %.
const white = Color(0xFFFFFFFF);
final top = TopGradient.topTone(dark, white, dark.surface);
expect(
top,
isNot(
Color.lerp(const Color(0xFF000000), white, TopGradient.darkShade),
),
);
expect(
TopGradient.contrast(top, dark.onSurface),
greaterThanOrEqualTo(TopGradient.minContrast),
);
});
});
testWidgets('fades from the tone into the background below the status bar', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: appTheme(Brightness.dark),
home: MediaQuery(
data: const MediaQueryData(padding: EdgeInsets.only(top: 24)),
child: Builder(
builder: (context) => TopGradient(
color: Brand.primary,
background: Theme.of(context).colorScheme.surface,
extent: 100,
child: const SizedBox.expand(),
),
),
),
),
);
final box = tester.widget<DecoratedBox>(
find.descendant(
of: find.byType(TopGradient),
matching: find.byType(DecoratedBox),
),
);
final gradient =
(box.decoration as BoxDecoration).gradient! as LinearGradient;
final scheme = appTheme(Brightness.dark).colorScheme;
expect(gradient.colors, [
TopGradient.topTone(scheme, Brand.primary, scheme.surface),
scheme.surface,
]);
expect(tester.getSize(find.byWidget(box)).height, 124);
});
}

View file

@ -0,0 +1,54 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
void main() {
late int undos;
setUp(() => undos = 0);
/// Shows "Studyset archived" with a way to undo it.
Future<void> archive(WidgetTester tester) async {
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: HalkyonUiLocalizations.localizationsDelegates,
supportedLocales: HalkyonUiLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder: (context) => TextButton(
onPressed: () => showUndoSnackBar(
context,
message: 'Studyset archived',
onUndo: () => undos++,
),
child: const Text('Archive'),
),
),
),
),
);
await tester.tap(find.text('Archive'));
await tester.pumpAndSettle();
}
testWidgets('offers to undo', (tester) async {
await archive(tester);
expect(find.text('Studyset archived'), findsOneWidget);
await tester.tap(find.text('Undo'));
await tester.pumpAndSettle();
expect(undos, 1);
expect(find.text('Studyset archived'), findsNothing);
});
testWidgets('goes by itself after three seconds', (tester) async {
await archive(tester);
await tester.pump(const Duration(seconds: 3));
await tester.pumpAndSettle();
expect(find.text('Studyset archived'), findsNothing);
expect(undos, 0);
});
}

View file

@ -0,0 +1,99 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
import '../support/test_app.dart';
void main() {
/// Shows [child] on a screen, in German.
Future<void> show(WidgetTester tester, Widget child) => tester.pumpWidget(
testApp(locale: const Locale('de'), Scaffold(body: child)),
);
testWidgets("a required field says so in the app's language", (
tester,
) async {
await show(
tester,
Builder(
builder: (context) => TextField(
decoration: requiredDecoration(
context,
const InputDecoration(labelText: 'Name'),
missing: true,
),
),
),
);
expect(find.text('Pflichtfeld'), findsOneWidget);
});
testWidgets('a count stepper names its buttons', (tester) async {
await show(
tester,
CountStepper(value: '5', onFewer: () {}, onMore: () {}, onTyped: (_) {}),
);
expect(find.byTooltip('Verringern'), findsOneWidget);
expect(find.byTooltip('Erhöhen'), findsOneWidget);
expect(find.byTooltip('Zahl eingeben'), findsOneWidget);
});
testWidgets('a reorderable row is dragged by its grip', (tester) async {
await show(
tester,
CustomScrollView(
slivers: [
SliverReorderableList(
itemCount: 1,
onReorderItem: (_, _) {},
itemBuilder: (context, index) => ListTile(
key: const ValueKey('row'),
title: const Text('Row'),
trailing: ReorderGrip(index: index),
),
),
],
),
);
expect(find.byTooltip('Zum Umsortieren ziehen'), findsOneWidget);
});
testWidgets('More info names itself, and copies a value with a tap', (
tester,
) async {
await show(
tester,
Builder(
builder: (context) => TextButton(
onPressed: () => showMoreInfoSheet(context, [
(
title: null,
rows: [(label: 'ID', value: '0199a', copyable: true)],
),
]),
child: const Text('Open'),
),
),
);
await tester.tap(find.text('Open'));
await tester.pumpAndSettle();
expect(find.text('Weitere Informationen'), findsOneWidget);
await tester.tap(find.byTooltip('Kopieren'));
await tester.pump();
expect(find.text('In die Zwischenablage kopiert'), findsOneWidget);
});
testWidgets('the palette picker names its colour', (tester) async {
await show(
tester,
PaletteColorPicker(selected: PaletteColor.copper, onChanged: (_) {}),
);
expect(find.text('Kupfer'), findsOneWidget);
expect(find.bySemanticsLabel('Farbe: Kupfer'), findsOneWidget);
});
}