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:
2026-05-21 00:33:04 -04:00
parent e4812e9b44
commit cb138a3248
18 changed files with 1701 additions and 0 deletions
@@ -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);
});
});
}