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:
commit
80fa7b9628
14 changed files with 6762 additions and 0 deletions
216
packages/halkyon_ui/test/widget/adaptive_sheet_test.dart
Normal file
216
packages/halkyon_ui/test/widget/adaptive_sheet_test.dart
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
17
packages/halkyon_ui/test/widget/count_stepper_test.dart
Normal file
17
packages/halkyon_ui/test/widget/count_stepper_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
121
packages/halkyon_ui/test/widget/found_by_id_test.dart
Normal file
121
packages/halkyon_ui/test/widget/found_by_id_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
53
packages/halkyon_ui/test/widget/launch_screen_test.dart
Normal file
53
packages/halkyon_ui/test/widget/launch_screen_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
58
packages/halkyon_ui/test/widget/scroll_hiding_fab_test.dart
Normal file
58
packages/halkyon_ui/test/widget/scroll_hiding_fab_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
64
packages/halkyon_ui/test/widget/tab_visibility_test.dart
Normal file
64
packages/halkyon_ui/test/widget/tab_visibility_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
44
packages/halkyon_ui/test/widget/text_limits_test.dart
Normal file
44
packages/halkyon_ui/test/widget/text_limits_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
48
packages/halkyon_ui/test/widget/tinted_frame_test.dart
Normal file
48
packages/halkyon_ui/test/widget/tinted_frame_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
85
packages/halkyon_ui/test/widget/title_with_kicker_test.dart
Normal file
85
packages/halkyon_ui/test/widget/title_with_kicker_test.dart
Normal 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',
|
||||
);
|
||||
});
|
||||
}
|
||||
139
packages/halkyon_ui/test/widget/top_gradient_test.dart
Normal file
139
packages/halkyon_ui/test/widget/top_gradient_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
54
packages/halkyon_ui/test/widget/undo_snack_bar_test.dart
Normal file
54
packages/halkyon_ui/test/widget/undo_snack_bar_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
99
packages/halkyon_ui/test/widget/widget_texts_test.dart
Normal file
99
packages/halkyon_ui/test/widget/widget_texts_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in a new issue