Files
AR-Autopilot/display/test/theme/theme_contrast_test.dart
alro65 cb138a3248 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>
2026-05-21 00:33:04 -04:00

128 lines
5.1 KiB
Dart

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.',
);
});
});
}