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
20 changed files with 6762 additions and 0 deletions

View file

@ -0,0 +1,70 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
/// The messages of the `.arb` file [name] in `lib/l10n/`, without metadata.
Map<String, String> _messages(String name) => {
for (final MapEntry(:key, :value) in (jsonDecode(
File('lib/l10n/$name').readAsStringSync(),
) as Map<String, Object?>).entries)
if (!key.startsWith('@')) key: value! as String,
};
/// The names of the placeholders in [message], with plural and select
/// arguments; the words of their branches don't count. (gen-l10n reads
/// quotes as text here: `use-escaping` is off.)
Set<String> _placeholders(String message) {
final names = <String>{};
// Even depths hold text and placeholders, odd ones a plural's branches.
var depth = 0;
for (var i = 0; i < message.length; i++) {
switch (message[i]) {
case '{':
final name = RegExp(r'\{\s*(\w+)\s*[,}]').matchAsPrefix(message, i);
if (name != null && depth.isEven) {
names.add(name.group(1)!);
}
depth++;
case '}':
depth--;
}
}
return names;
}
void main() {
final template = _messages('halkyon_ui_en.arb');
test('German has every message, with the same placeholders', () {
final german = _messages('halkyon_ui_de.arb');
expect(
template.keys.where((key) => !german.containsKey(key)),
isEmpty,
reason: 'untranslated',
);
expect(
german.keys.where((key) => !template.containsKey(key)),
isEmpty,
reason: 'not in the template',
);
for (final MapEntry(:key, :value) in german.entries) {
expect(_placeholders(value), _placeholders(template[key]!), reason: key);
}
});
test("the English variants override the template's messages only, with "
'the same placeholders', () {
for (final variant in ['halkyon_ui_en_GB.arb', 'halkyon_ui_en_US.arb']) {
for (final MapEntry(:key, :value) in _messages(variant).entries) {
expect(template, contains(key), reason: '$variant: $key');
expect(
_placeholders(value),
_placeholders(template[key]!),
reason: '$variant: $key',
);
}
}
});
}

View file

@ -0,0 +1,47 @@
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() {
test('speaks British English, American English and German', () {
expect(
lookupHalkyonUiLocalizations(const Locale('en')).colorPickerLabel('X'),
'Colour: X',
);
expect(
lookupHalkyonUiLocalizations(
const Locale('en', 'GB'),
).colorPickerLabel('X'),
'Colour: X',
);
expect(
lookupHalkyonUiLocalizations(
const Locale('en', 'US'),
).colorPickerLabel('X'),
'Color: X',
);
expect(
lookupHalkyonUiLocalizations(const Locale('de')).colorPickerLabel('X'),
'Farbe: X',
);
});
testWidgets("an app adding the package's delegate gets its texts in the "
"app's language", (tester) async {
await tester.pumpWidget(
testApp(
locale: const Locale('de'),
ReorderToggle(
reordering: false,
labelled: true,
tooltip: 'Pfade umsortieren',
onToggle: () {},
),
),
);
expect(find.text('Umsortieren'), findsOneWidget);
});
}

View file

@ -0,0 +1,24 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
void main() {
test("Material 3's classes, by the window's width", () {
expect(WindowSize.fromWidth(0), WindowSize.compact);
expect(WindowSize.fromWidth(599.9), WindowSize.compact);
expect(WindowSize.fromWidth(600), WindowSize.medium);
expect(WindowSize.fromWidth(839.9), WindowSize.medium);
expect(WindowSize.fromWidth(840), WindowSize.expanded);
expect(WindowSize.fromWidth(1199.9), WindowSize.expanded);
expect(WindowSize.fromWidth(1200), WindowSize.large);
expect(WindowSize.fromWidth(1599.9), WindowSize.large);
expect(WindowSize.fromWidth(1600), WindowSize.extraLarge);
});
test('a rail from medium on, desktop layouts from expanded on', () {
expect(WindowSize.compact.isAtLeastMedium, isFalse);
expect(WindowSize.medium.isAtLeastMedium, isTrue);
expect(WindowSize.medium.isAtLeastExpanded, isFalse);
expect(WindowSize.expanded.isAtLeastExpanded, isTrue);
expect(WindowSize.extraLarge.isAtLeastExpanded, isTrue);
});
}

View file

@ -0,0 +1,19 @@
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// [home] in an app as a Halkyon app builds one: the theme, and the
/// package's texts beside Material's, in [locale].
Widget testApp(
Widget home, {
Locale locale = const Locale('en'),
Brightness brightness = Brightness.light,
}) => MaterialApp(
theme: appTheme(brightness),
locale: locale,
localizationsDelegates: const [
HalkyonUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: HalkyonUiLocalizations.supportedLocales,
home: home,
);

View file

@ -0,0 +1,89 @@
import 'dart:ui' as ui;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// How many pixels [text] inks in [family] at [weight]: heavier strokes ink
/// more.
Future<int> ink(String text, String family, FontWeight weight) async {
final painter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontFamily: family,
fontSize: 40,
fontWeight: weight,
color: const Color(0xFF000000),
),
),
textDirection: TextDirection.ltr,
)..layout();
final recorder = ui.PictureRecorder();
painter.paint(Canvas(recorder), Offset.zero);
final image = await recorder.endRecording().toImage(
painter.width.ceil(),
painter.height.ceil(),
);
final pixels = (await image.toByteData())!;
var inked = 0;
for (var alpha = 3; alpha < pixels.lengthInBytes; alpha += 4) {
if (pixels.getUint8(alpha) > 128) {
inked++;
}
}
return inked;
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test("the font is the package's own, as an app finds a package's font", () {
expect(appFontFamily, 'packages/halkyon_ui/Noto Sans');
});
test("the theme sets text in Noto Sans, with the apps' CJK fonts for what "
'it lacks, named as an app declares them', () {
for (final brightness in Brightness.values) {
final style = appTheme(brightness).textTheme.bodyLarge!;
expect(style.fontFamily, appFontFamily);
expect(style.fontFamilyFallback, [
'Noto Sans JP',
'Noto Sans SC',
'Noto Sans TC',
'Noto Sans KR',
]);
}
});
test("a language's own CJK font comes first, so shared Han characters "
'take its shapes', () {
expect(appFontFallbackFor(null).first, 'Noto Sans JP');
expect(appFontFallbackFor('en'), appFontFallback);
expect(appFontFallbackFor('ja').first, 'Noto Sans JP');
expect(appFontFallbackFor('zh-Hans').first, 'Noto Sans SC');
expect(appFontFallbackFor('zh').first, 'Noto Sans SC');
expect(appFontFallbackFor('zh-Hant').first, 'Noto Sans TC');
expect(appFontFallbackFor('zh-TW').first, 'Noto Sans TC');
expect(appFontFallbackFor('ko').first, 'Noto Sans KR');
expect(hasOwnFont('ko'), isTrue);
expect(hasOwnFont('de'), isFalse);
// Every font stays, once.
expect(appFontFallbackFor('ko').toSet(), appFontFallback.toSet());
expect(appFontFallbackFor('ko'), hasLength(4));
});
test('Noto Sans draws every weight from its one file', () async {
// In this package's own tests it is the root project, whose assets have
// no `packages/` prefix; an app's test of the name is in example/.
final loader = FontLoader(appFontFamily)
..addFont(rootBundle.load('assets/fonts/NotoSans-Variable.ttf'));
await loader.load();
const text = 'Ἀθῆναι Halkyon';
final regular = await ink(text, appFontFamily, FontWeight.w400);
final bold = await ink(text, appFontFamily, FontWeight.w700);
expect(bold, greaterThan(regular * 1.2));
});
}

View file

@ -0,0 +1,99 @@
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:material_ui/material_ui.dart';
/// WCAG's contrast ratio of two colours, from 1 (none) to 21.
double _contrast(Color a, Color b) {
final (lighter, darker) = (
max(a.computeLuminance(), b.computeLuminance()),
min(a.computeLuminance(), b.computeLuminance()),
);
return (lighter + 0.05) / (darker + 0.05);
}
/// An app's own colours, beside the shared ones.
final class const _AppColors(final Color special)
extends ThemeExtension<_AppColors> {
@override
_AppColors copyWith({Color? special}) => _AppColors(special ?? this.special);
@override
_AppColors lerp(_AppColors? other, double t) =>
other == null ? this : _AppColors(Color.lerp(special, other.special, t)!);
}
void main() {
group('the picker palette', () {
test('has the 20 colours content can be given', () {
expect(PaletteColor.values, hasLength(20));
});
for (final color in PaletteColor.values) {
test(
'${color.name}: its text colour is readable on its fill (WCAG AA)',
() {
expect(
_contrast(color.foreground, color.background),
greaterThanOrEqualTo(4.5),
);
},
);
}
test(
'finds a colour by its stored name, and nothing for an unknown one',
() {
expect(PaletteColor.byName('cobalt'), PaletteColor.cobalt);
expect(PaletteColor.byName('ultraviolet'), isNull);
expect(PaletteColor.byName(null), isNull);
},
);
});
group('the theme', () {
for (final brightness in Brightness.values) {
test('$brightness: Material 3, with the status colours', () {
final theme = appTheme(brightness);
expect(theme.useMaterial3, isTrue);
expect(theme.colorScheme.brightness, brightness);
expect(theme.extras, isA<ThemeExtras>());
});
}
test("takes an app's own extensions beside the shared ones", () {
const colors = _AppColors(Color(0xFF123456));
final theme = appTheme(Brightness.light, extensions: [colors]);
expect(theme.extension<_AppColors>(), colors);
expect(theme.extras, ThemeExtras.light);
});
test('light: cobalt is the primary colour, copper the accent', () {
final scheme = appTheme(Brightness.light).colorScheme;
expect(scheme.primary, Brand.primary);
expect(scheme.tertiary, Brand.accent);
});
test("dark: the primary colour's text stays readable", () {
final scheme = appTheme(Brightness.dark).colorScheme;
expect(
_contrast(scheme.onPrimary, scheme.primary),
greaterThanOrEqualTo(4.5),
);
expect(
_contrast(scheme.onSurface, scheme.surface),
greaterThanOrEqualTo(4.5),
);
});
test("the navigation bar is Material 3 Expressive's shorter one", () {
expect(appTheme(Brightness.light).navigationBarTheme.height, 64);
});
});
}

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);
});
}