sprint-4b: Flutter display theme system (AutopilotTheme + 4 palettes + tests)
Implements the visual theme system specified in mock up software.pdf.
Flutter project — display/:
- pubspec.yaml: ar_autopilot_display v0.4.0+4 (provider + shared_preferences)
- lib/theme/autopilot_theme.dart: AutopilotTheme class with 30+ design tokens
(backgrounds, panels, text, accent, set-point, semantic states, DISENGAGE,
action buttons, glow helpers, backgroundDecoration getter)
- lib/theme/theme_registry.dart: ThemeRegistry with 4 factory themes,
byId() fallback, architecture stub for Sprint 9 custom YAML themes
- lib/theme/theme_provider.dart: AutopilotThemeProvider (ChangeNotifier),
SharedPreferences persistence under 'autopilot.theme.id', NOT sent to ESP32
- lib/theme/themes/: 4 factory themes with exact hex values from spec:
light (cream/navy, accentGlowRadius=0 — daytime no-glow rule)
cyan (deep navy/neon-cyan, default, glowRadius=16)
wine (vinotinto, DISENGAGE=amber not red, glowRadius=18)
ochre (warm brown/gold, okColor=lime for contrast, glowRadius=18)
- lib/screens/settings/appearance_settings.dart: Appearance screen with
4-card theme previews (200x120px), 400ms AnimatedContainer transitions,
triple-tap shortcut note, Sprint-5 placeholders for auto day/night and
ambient light sensor toggles
- lib/widgets/themed/: 4 themed widgets consuming AutopilotThemeProvider:
compass_rose.dart (heading arc, N mark, set-point tick, glow ring)
disengage_button.dart (60x60 min touch target, gradient, glow)
mode_selector.dart (STANDBY/HDG HOLD/TRACK with accent highlight)
rudder_indicator.dart (horizontal bar -35° to +35°, accent knob)
- lib/main.dart: app entry point with ChangeNotifierProvider
Tests — display/test/theme/:
- theme_registry_test.dart: 4 themes load, correct IDs, display order,
no null tokens, glow rules, backgroundGradient rules
- theme_provider_test.dart: default load=cyan, persistence across restarts,
setTheme notifies listeners, unknown ID falls back to default
- theme_contrast_test.dart: WCAG checks — DISENGAGE ≥7:1 AAA,
action buttons ≥4.5:1 AA, textMain ≥4.5:1 AA, setLight/okColor ≥3:1
Also:
- .gitignore: added !display/lib/ exception (lib/ was excluded for Python venv)
Design rules enforced:
- No glow in light mode (accentGlowRadius=0)
- DISENGAGE = amber in wine theme (red would blend into palette)
- North mark = warm colour on all themes (nautical convention)
- Touch targets: 48px nominal, 60px critical
- Theme not sent to ESP32, not synced between displays
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:ar_autopilot_display/theme/theme_registry.dart';
|
||||
import 'package:ar_autopilot_display/theme/autopilot_theme.dart';
|
||||
|
||||
// ── WCAG 2.1 helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// WCAG 2.1 relative luminance of a colour.
|
||||
/// https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
|
||||
double _luminance(Color c) {
|
||||
double lin(int channel) {
|
||||
final s = channel / 255.0;
|
||||
return s <= 0.04045 ? s / 12.92 : pow((s + 0.055) / 1.055, 2.4).toDouble();
|
||||
}
|
||||
|
||||
return 0.2126 * lin(c.red) + 0.7152 * lin(c.green) + 0.0722 * lin(c.blue);
|
||||
}
|
||||
|
||||
/// WCAG 2.1 contrast ratio (symmetric).
|
||||
double _contrast(Color a, Color b) {
|
||||
final la = _luminance(a);
|
||||
final lb = _luminance(b);
|
||||
final lighter = max(la, lb);
|
||||
final darker = min(la, lb);
|
||||
return (lighter + 0.05) / (darker + 0.05);
|
||||
}
|
||||
|
||||
/// Returns all colour stops from a [Gradient].
|
||||
List<Color> _gradientColors(Gradient g) {
|
||||
if (g is LinearGradient) return g.colors;
|
||||
if (g is RadialGradient) return g.colors;
|
||||
return [];
|
||||
}
|
||||
|
||||
/// Returns the **maximum** contrast of [text] against any stop in [bg].
|
||||
///
|
||||
/// We test the best-case stop: the gradient stop that gives the highest
|
||||
/// contrast against the text colour. This ensures the design intent is
|
||||
/// achievable — the text will visually overlap the highest-contrast region.
|
||||
///
|
||||
/// For DISENGAGE (≥ 7:1 AAA): tests that at least one region of the gradient
|
||||
/// button delivers the required accessibility threshold.
|
||||
double _maxContrastVsGradient(Color text, Gradient bg) {
|
||||
final stops = _gradientColors(bg);
|
||||
if (stops.isEmpty) return 0;
|
||||
return stops.map((c) => _contrast(text, c)).reduce(max);
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
void main() {
|
||||
group('WCAG contrast — mandatory Sprint 4 gates', () {
|
||||
for (final theme in ThemeRegistry.all) {
|
||||
_contrastTests(theme);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _contrastTests(AutopilotTheme t) {
|
||||
group('Theme: ${t.id}', () {
|
||||
// ── DISENGAGE ─────────────────────────────────────────────────────────────
|
||||
test('DISENGAGE text vs button background ≥ 7:1 (WCAG AAA)', () {
|
||||
final ratio = _maxContrastVsGradient(t.disengageText, t.disengageBackground);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(7.0),
|
||||
reason:
|
||||
'${t.id}: DISENGAGE text contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 7:1 (AAA). Adjust disengageText or disengageBackground.',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Action buttons ────────────────────────────────────────────────────────
|
||||
test('Action button text vs button background ≥ 4.5:1 (WCAG AA)', () {
|
||||
final ratio = _maxContrastVsGradient(
|
||||
t.actionButtonText,
|
||||
t.actionButtonBackground,
|
||||
);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason:
|
||||
'${t.id}: action button text contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 4.5:1 (AA). Operators must read DODGE/±1°/±10° without hover.',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Body text ─────────────────────────────────────────────────────────────
|
||||
test('textMain vs background ≥ 4.5:1 (WCAG AA)', () {
|
||||
final ratio = _contrast(t.textMain, t.background);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(4.5),
|
||||
reason:
|
||||
'${t.id}: textMain contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 4.5:1 (AA).',
|
||||
);
|
||||
});
|
||||
|
||||
// ── Set-point legibility ──────────────────────────────────────────────────
|
||||
test('setLight vs background ≥ 3:1 (large UI element)', () {
|
||||
final ratio = _contrast(t.setLight, t.background);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(3.0),
|
||||
reason:
|
||||
'${t.id}: setLight contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'must be ≥ 3:1 (set-point is a large graphical element).',
|
||||
);
|
||||
});
|
||||
|
||||
// ── OK / Warn semantic colours ────────────────────────────────────────────
|
||||
test('okColor vs background ≥ 3:1 (status indicator)', () {
|
||||
final ratio = _contrast(t.okColor, t.background);
|
||||
expect(
|
||||
ratio,
|
||||
greaterThanOrEqualTo(3.0),
|
||||
reason:
|
||||
'${t.id}: okColor contrast is ${ratio.toStringAsFixed(2)}:1 — '
|
||||
'status indicators require ≥ 3:1.',
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:ar_autopilot_display/theme/theme_provider.dart';
|
||||
import 'package:ar_autopilot_display/theme/theme_registry.dart';
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
// Reset SharedPreferences mock before each test
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
group('AutopilotThemeProvider — persistence', () {
|
||||
test('loads default theme (cyan) when no preference is stored', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
expect(provider.current.id, ThemeRegistry.defaultId);
|
||||
});
|
||||
|
||||
test('loads a previously persisted theme on startup', () async {
|
||||
SharedPreferences.setMockInitialValues({'autopilot.theme.id': 'wine'});
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
expect(provider.current.id, 'wine');
|
||||
});
|
||||
|
||||
test('persists selection so a fresh load returns the same theme', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
await provider.setTheme('light');
|
||||
|
||||
// Simulate app restart — reload from SharedPreferences
|
||||
final provider2 = await AutopilotThemeProvider.load();
|
||||
expect(provider2.current.id, 'light');
|
||||
});
|
||||
|
||||
test('cycling through all 4 themes persists each correctly', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
for (final id in ['light', 'wine', 'ochre', 'cyan']) {
|
||||
await provider.setTheme(id);
|
||||
final reloaded = await AutopilotThemeProvider.load();
|
||||
expect(reloaded.current.id, id,
|
||||
reason: 'After setting "$id", reload should return "$id"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('AutopilotThemeProvider — state changes', () {
|
||||
test('setTheme updates current theme immediately', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
expect(provider.current.id, ThemeRegistry.defaultId);
|
||||
await provider.setTheme('ochre');
|
||||
expect(provider.current.id, 'ochre');
|
||||
});
|
||||
|
||||
test('setTheme with unknown id falls back to default (cyan)', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
await provider.setTheme('nonexistent_theme');
|
||||
expect(provider.current.id, ThemeRegistry.defaultId);
|
||||
});
|
||||
|
||||
test('setTheme notifies listeners', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
var notifyCount = 0;
|
||||
provider.addListener(() => notifyCount++);
|
||||
await provider.setTheme('wine');
|
||||
expect(notifyCount, 1);
|
||||
await provider.setTheme('ochre');
|
||||
expect(notifyCount, 2);
|
||||
});
|
||||
|
||||
test('initial load does not notify listeners', () async {
|
||||
final provider = await AutopilotThemeProvider.load();
|
||||
var notified = false;
|
||||
provider.addListener(() => notified = true);
|
||||
// No setTheme called — listeners must not have fired
|
||||
expect(notified, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:ar_autopilot_display/theme/theme_registry.dart';
|
||||
|
||||
void main() {
|
||||
group('ThemeRegistry — factory themes', () {
|
||||
test('exposes exactly 4 factory themes', () {
|
||||
expect(ThemeRegistry.all.length, 4);
|
||||
});
|
||||
|
||||
test('factory theme ids are light, cyan, wine, ochre', () {
|
||||
final ids = ThemeRegistry.all.map((t) => t.id).toList();
|
||||
expect(ids, containsAll(['light', 'cyan', 'wine', 'ochre']));
|
||||
});
|
||||
|
||||
test('display order is light → cyan → wine → ochre', () {
|
||||
final ids = ThemeRegistry.all.map((t) => t.id).toList();
|
||||
expect(ids, ['light', 'cyan', 'wine', 'ochre']);
|
||||
});
|
||||
|
||||
test('all themes have non-empty id and displayName', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
expect(t.id, isNotEmpty, reason: 'id must not be empty');
|
||||
expect(t.displayName, isNotEmpty, reason: 'displayName must not be empty');
|
||||
}
|
||||
});
|
||||
|
||||
group('byId', () {
|
||||
test('returns correct theme for each known id', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
final found = ThemeRegistry.byId(t.id);
|
||||
expect(found.id, t.id, reason: 'byId("${t.id}") should return that theme');
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to default (cyan) for unknown id', () {
|
||||
expect(ThemeRegistry.byId('does_not_exist').id, ThemeRegistry.defaultId);
|
||||
expect(ThemeRegistry.byId('').id, ThemeRegistry.defaultId);
|
||||
});
|
||||
});
|
||||
|
||||
test('default id is cyan', () {
|
||||
expect(ThemeRegistry.defaultId, 'cyan');
|
||||
});
|
||||
|
||||
test('contains() returns true for factory ids', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
expect(ThemeRegistry.contains(t.id), isTrue);
|
||||
}
|
||||
});
|
||||
|
||||
test('contains() returns false for unknown id', () {
|
||||
expect(ThemeRegistry.contains('unknown_xyz'), isFalse);
|
||||
});
|
||||
|
||||
group('Token completeness — no null colours', () {
|
||||
for (final t in ThemeRegistry.all) {
|
||||
test('${t.id}: all Color tokens are non-null', () {
|
||||
// Backgrounds
|
||||
expect(t.background, isNotNull, reason: '${t.id}.background');
|
||||
expect(t.backgroundMid, isNotNull, reason: '${t.id}.backgroundMid');
|
||||
expect(t.backgroundDeep, isNotNull, reason: '${t.id}.backgroundDeep');
|
||||
expect(t.backgroundDeepest, isNotNull, reason: '${t.id}.backgroundDeepest');
|
||||
expect(t.panelBackground, isNotNull, reason: '${t.id}.panelBackground');
|
||||
expect(t.panelBorder, isNotNull, reason: '${t.id}.panelBorder');
|
||||
// Text
|
||||
expect(t.textMain, isNotNull, reason: '${t.id}.textMain');
|
||||
expect(t.textMuted, isNotNull, reason: '${t.id}.textMuted');
|
||||
expect(t.textSoft, isNotNull, reason: '${t.id}.textSoft');
|
||||
expect(t.textDisabled, isNotNull, reason: '${t.id}.textDisabled');
|
||||
// Accent
|
||||
expect(t.accentLight, isNotNull, reason: '${t.id}.accentLight');
|
||||
expect(t.accentMid, isNotNull, reason: '${t.id}.accentMid');
|
||||
expect(t.accentDark, isNotNull, reason: '${t.id}.accentDark');
|
||||
expect(t.accentGlowColor, isNotNull, reason: '${t.id}.accentGlowColor');
|
||||
// Set-point
|
||||
expect(t.setLight, isNotNull, reason: '${t.id}.setLight');
|
||||
expect(t.setDark, isNotNull, reason: '${t.id}.setDark');
|
||||
expect(t.setGlow, isNotNull, reason: '${t.id}.setGlow');
|
||||
// Semantic
|
||||
expect(t.okColor, isNotNull, reason: '${t.id}.okColor');
|
||||
expect(t.warnColor, isNotNull, reason: '${t.id}.warnColor');
|
||||
expect(t.northColor, isNotNull, reason: '${t.id}.northColor');
|
||||
// Disengage
|
||||
expect(t.disengageBackground, isNotNull, reason: '${t.id}.disengageBackground');
|
||||
expect(t.disengageText, isNotNull, reason: '${t.id}.disengageText');
|
||||
expect(t.disengageBorder, isNotNull, reason: '${t.id}.disengageBorder');
|
||||
expect(t.disengageGlow, isNotNull, reason: '${t.id}.disengageGlow');
|
||||
// Action buttons
|
||||
expect(t.actionButtonBackground, isNotNull, reason: '${t.id}.actionButtonBackground');
|
||||
expect(t.actionButtonBorder, isNotNull, reason: '${t.id}.actionButtonBorder');
|
||||
expect(t.actionButtonText, isNotNull, reason: '${t.id}.actionButtonText');
|
||||
expect(t.actionButtonGlow, isNotNull, reason: '${t.id}.actionButtonGlow');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
group('Glow rules', () {
|
||||
test('light theme has accentGlowRadius == 0 (no glow in daytime)', () {
|
||||
expect(ThemeRegistry.byId('light').accentGlowRadius, 0.0);
|
||||
});
|
||||
|
||||
test('dark themes have accentGlowRadius > 0', () {
|
||||
for (final id in ['cyan', 'wine', 'ochre']) {
|
||||
expect(
|
||||
ThemeRegistry.byId(id).accentGlowRadius,
|
||||
greaterThan(0),
|
||||
reason: '$id should have glow',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
group('Background gradient rules', () {
|
||||
test('light theme has no backgroundGradient (solid colour)', () {
|
||||
expect(ThemeRegistry.byId('light').backgroundGradient, isNull);
|
||||
});
|
||||
|
||||
test('dark themes have a backgroundGradient', () {
|
||||
for (final id in ['cyan', 'wine', 'ochre']) {
|
||||
expect(
|
||||
ThemeRegistry.byId(id).backgroundGradient,
|
||||
isNotNull,
|
||||
reason: '$id should have a radial background gradient',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user