halkyon/packages/halkyon_ui/test/theme/theme_test.dart
Tizian 「ティツィアーン」 cc7511f060
feat(ui): in dark mode, filled and floating buttons in cobalt itself, white on it
In dark mode the primary colour is cobalt lightened (#AEB9FF), readable as
text and thin marks on the dark surfaces, and filled buttons took it: Todo's
send button glared bright beside everything else; floating buttons took
Material's dark primary container. Both are cobalt itself now, white on it,
as in light mode (decision 6 of the developer's list, 9 Nov 2026); links,
tabs and switches keep the lighter tone. Filled icon buttons take no theme
of their own in Flutter (the icon buttons' theme is the plain ones' too),
so they take filledIconButtonStyle: Learn's study and send buttons,
Todo's send buttons and Locate me, Meditate's stop dictating.

Whats-New: In dark mode, filled buttons and the + button are cobalt with a white icon, no longer a bright light blue.
Whats-New-De: Im dunklen Modus sind gefüllte Schaltflächen und die +-Schaltfläche kobaltblau mit weißem Symbol statt grell hellblau.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-11-10 14:22:59 +01:00

174 lines
5.4 KiB
Dart

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),
);
});
group('dark: filled buttons and floating buttons in cobalt itself, white '
"on it (the developer's review, 9 Nov 2026)", () {
final theme = appTheme(Brightness.dark);
const none = <WidgetState>{};
test('a filled button', () {
final style = theme.filledButtonTheme.style!;
expect(style.backgroundColor!.resolve(none), Brand.primary);
expect(style.foregroundColor!.resolve(none), Colors.white);
expect(
style.backgroundColor!.resolve({WidgetState.disabled}),
isNot(Brand.primary),
reason: "disabled, Material's own",
);
});
test('a floating button', () {
expect(theme.floatingActionButtonTheme.backgroundColor, Brand.primary);
expect(theme.floatingActionButtonTheme.foregroundColor, Colors.white);
});
test('readable: white on cobalt', () {
expect(_contrast(Colors.white, Brand.primary), greaterThan(7));
});
test("links, tabs and switches keep the primary colour's lighter "
'tone', () {
expect(theme.colorScheme.primary, isNot(Brand.primary));
});
testWidgets('a filled icon button, styled by filledIconButtonStyle', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: Builder(
builder: (context) => IconButton.filled(
style: filledIconButtonStyle(context),
onPressed: () {},
icon: const Icon(Icons.arrow_upward),
),
),
),
);
final material = tester.widget<Material>(
find.descendant(
of: find.byType(IconButton),
matching: find.byType(Material),
),
);
expect(material.color, Brand.primary);
expect(
tester
.widget<IconTheme>(
find
.ancestor(
of: find.byIcon(Icons.arrow_upward),
matching: find.byType(IconTheme),
)
.first,
)
.data
.color,
Colors.white,
);
});
});
test('light: the buttons as Material 3 has them', () {
final theme = appTheme(Brightness.light);
expect(theme.filledButtonTheme.style, isNull);
expect(theme.floatingActionButtonTheme.backgroundColor, isNull);
});
test("the navigation bar is Material 3 Expressive's shorter one", () {
expect(appTheme(Brightness.light).navigationBarTheme.height, 64);
});
});
}