From cb138a32482d02e541fcac2abeea5793830b6f72 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Thu, 21 May 2026 00:33:04 -0400 Subject: [PATCH] sprint-4b: Flutter display theme system (AutopilotTheme + 4 palettes + tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- display/analysis_options.yaml | 11 + display/lib/main.dart | 34 ++ .../screens/settings/appearance_settings.dart | 307 ++++++++++++++++++ display/lib/theme/autopilot_theme.dart | 135 ++++++++ display/lib/theme/theme_provider.dart | 59 ++++ display/lib/theme/theme_registry.dart | 44 +++ display/lib/theme/themes/cyan_theme.dart | 65 ++++ display/lib/theme/themes/light_theme.dart | 61 ++++ display/lib/theme/themes/ochre_theme.dart | 66 ++++ display/lib/theme/themes/wine_theme.dart | 67 ++++ display/lib/widgets/themed/compass_rose.dart | 165 ++++++++++ .../lib/widgets/themed/disengage_button.dart | 107 ++++++ display/lib/widgets/themed/mode_selector.dart | 98 ++++++ .../lib/widgets/themed/rudder_indicator.dart | 125 +++++++ display/pubspec.yaml | 23 ++ display/test/theme/theme_contrast_test.dart | 127 ++++++++ display/test/theme/theme_provider_test.dart | 77 +++++ display/test/theme/theme_registry_test.dart | 130 ++++++++ 18 files changed, 1701 insertions(+) create mode 100644 display/analysis_options.yaml create mode 100644 display/lib/main.dart create mode 100644 display/lib/screens/settings/appearance_settings.dart create mode 100644 display/lib/theme/autopilot_theme.dart create mode 100644 display/lib/theme/theme_provider.dart create mode 100644 display/lib/theme/theme_registry.dart create mode 100644 display/lib/theme/themes/cyan_theme.dart create mode 100644 display/lib/theme/themes/light_theme.dart create mode 100644 display/lib/theme/themes/ochre_theme.dart create mode 100644 display/lib/theme/themes/wine_theme.dart create mode 100644 display/lib/widgets/themed/compass_rose.dart create mode 100644 display/lib/widgets/themed/disengage_button.dart create mode 100644 display/lib/widgets/themed/mode_selector.dart create mode 100644 display/lib/widgets/themed/rudder_indicator.dart create mode 100644 display/pubspec.yaml create mode 100644 display/test/theme/theme_contrast_test.dart create mode 100644 display/test/theme/theme_provider_test.dart create mode 100644 display/test/theme/theme_registry_test.dart diff --git a/display/analysis_options.yaml b/display/analysis_options.yaml new file mode 100644 index 0000000..fccfe05 --- /dev/null +++ b/display/analysis_options.yaml @@ -0,0 +1,11 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + - always_declare_return_types + - avoid_print + - prefer_const_constructors + - prefer_const_declarations + - prefer_final_fields + - sort_child_properties_last + - use_key_in_widget_constructors diff --git a/display/lib/main.dart b/display/lib/main.dart new file mode 100644 index 0000000..5ea2930 --- /dev/null +++ b/display/lib/main.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'theme/theme_provider.dart'; +import 'screens/settings/appearance_settings.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + final themeProvider = await AutopilotThemeProvider.load(); + runApp( + ChangeNotifierProvider.value( + value: themeProvider, + child: const ArAutopilotApp(), + ), + ); +} + +class ArAutopilotApp extends StatelessWidget { + const ArAutopilotApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'AR-Autopilot', + debugShowCheckedModeBanner: false, + theme: ThemeData(useMaterial3: true), + // Initial route — Sprint 4 starts with the Appearance screen for demo + initialRoute: AppearanceSettingsScreen.routeName, + routes: { + AppearanceSettingsScreen.routeName: (_) => const AppearanceSettingsScreen(), + }, + ); + } +} diff --git a/display/lib/screens/settings/appearance_settings.dart b/display/lib/screens/settings/appearance_settings.dart new file mode 100644 index 0000000..57fdc41 --- /dev/null +++ b/display/lib/screens/settings/appearance_settings.dart @@ -0,0 +1,307 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../theme/autopilot_theme.dart'; +import '../../theme/theme_provider.dart'; +import '../../theme/theme_registry.dart'; + +/// Appearance settings screen — "Ajustes → Apariencia". +/// +/// Shows a 4-card grid where each card is a 200×120 px miniature preview +/// of the theme (compass rose colours + a sample DODGE button). +/// +/// Tapping a card applies the theme **immediately** with a 400 ms transition. +/// +/// ## Triple-tap shortcut +/// The AR-Autopilot logo in the topbar supports triple-tap to cycle through +/// all 4 themes without opening this screen. Implement the gesture in the +/// topbar widget, calling `provider.setTheme(nextId)`. +/// +/// ## Sprint 5 placeholders +/// "Auto day/night" and "Ambient light sensor" toggles are rendered but +/// disabled — they are implemented in Sprint 5. +class AppearanceSettingsScreen extends StatelessWidget { + const AppearanceSettingsScreen({super.key}); + + static const String routeName = '/settings/appearance'; + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final theme = provider.current; + + return Scaffold( + backgroundColor: theme.background, + appBar: AppBar( + backgroundColor: theme.backgroundMid, + foregroundColor: theme.textMain, + elevation: 0, + title: Text( + 'Apariencia', + style: TextStyle(color: theme.textMain, fontWeight: FontWeight.w600), + ), + ), + body: AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + decoration: theme.backgroundDecoration, + child: ListView( + padding: const EdgeInsets.all(20), + children: [ + _SectionLabel(label: 'Tema visual', theme: theme), + const SizedBox(height: 12), + // ── 4-card theme selector grid ─────────────────────────────── + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: ThemeRegistry.all.map((t) { + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: _ThemeCard( + theme: t, + isSelected: t.id == theme.id, + onTap: () => provider.setTheme(t.id), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: 28), + Divider(color: theme.panelBorder, thickness: 1, height: 1), + const SizedBox(height: 20), + // ── Sprint 5 toggles (disabled placeholders) ───────────────── + _ToggleRow( + theme: theme, + label: 'Cambio automático día/noche', + subtitle: 'De día usa "Claro", de noche usa el seleccionado arriba.', + value: false, + onChanged: null, // Sprint 5 + ), + const SizedBox(height: 16), + _ToggleRow( + theme: theme, + label: 'Sensor de luz ambiental', + subtitle: + 'Si el display tiene sensor, ajusta automáticamente entre claro y el oscuro.', + value: false, + onChanged: null, // Sprint 5 + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } +} + +// ── Private widgets ─────────────────────────────────────────────────────────── + +class _SectionLabel extends StatelessWidget { + const _SectionLabel({required this.label, required this.theme}); + final String label; + final AutopilotTheme theme; + + @override + Widget build(BuildContext context) => Text( + label.toUpperCase(), + style: TextStyle( + color: theme.textMuted, + fontSize: 11, + letterSpacing: 1.2, + fontWeight: FontWeight.w600, + ), + ); +} + +class _ThemeCard extends StatelessWidget { + const _ThemeCard({ + required this.theme, + required this.isSelected, + required this.onTap, + }); + + final AutopilotTheme theme; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + height: 120, + decoration: BoxDecoration( + gradient: theme.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? theme.accentMid : theme.panelBorder, + width: isSelected ? 2 : 1, + ), + boxShadow: isSelected + ? theme.glowShadow(theme.accentGlowColor, theme.accentGlowRadius) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _MiniCompass(theme: theme), + const SizedBox(height: 6), + Text( + theme.displayName, + style: TextStyle( + color: theme.textMain, + fontSize: 9, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + AnimatedContainer( + duration: const Duration(milliseconds: 400), + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? theme.accentMid : theme.textDisabled, + ), + ), + ], + ), + ), + ); + } +} + +/// 40×40 miniature compass preview for the theme card. +class _MiniCompass extends StatelessWidget { + const _MiniCompass({required this.theme}); + final AutopilotTheme theme; + + @override + Widget build(BuildContext context) => SizedBox( + width: 40, + height: 40, + child: CustomPaint(painter: _MiniCompassPainter(theme: theme)), + ); +} + +class _MiniCompassPainter extends CustomPainter { + const _MiniCompassPainter({required this.theme}); + final AutopilotTheme theme; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final r = size.width / 2 - 2; + + // Outer ring + canvas.drawCircle( + center, + r, + Paint() + ..color = theme.accentMid + ..style = PaintingStyle.stroke + ..strokeWidth = 1.5, + ); + + // Heading arc (accent colour) + canvas.drawArc( + Rect.fromCircle(center: center, radius: r - 4), + -1.57, // top + 1.3, + false, + Paint() + ..color = theme.accentLight + ..style = PaintingStyle.stroke + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round, + ); + + // North mark (warm colour — nautical convention) + canvas.drawArc( + Rect.fromCircle(center: center, radius: r), + -1.65, + 0.4, + false, + Paint() + ..color = theme.northColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round, + ); + + // Set-point tick (operator intent — always amber) + final sx = center.dx + (r - 3) * 0.65; + final sy = center.dy - (r - 3) * 0.65; + final ex = center.dx + r * 0.65; + final ey = center.dy - r * 0.65; + canvas.drawLine( + Offset(sx, sy), + Offset(ex, ey), + Paint() + ..color = theme.setLight + ..strokeWidth = 2.5 + ..strokeCap = StrokeCap.round, + ); + } + + @override + bool shouldRepaint(_MiniCompassPainter old) => old.theme != theme; +} + +class _ToggleRow extends StatelessWidget { + const _ToggleRow({ + required this.theme, + required this.label, + required this.subtitle, + required this.value, + required this.onChanged, + }); + + final AutopilotTheme theme; + final String label; + final String subtitle; + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + final enabled = onChanged != null; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + color: enabled ? theme.textMain : theme.textDisabled, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle(color: theme.textMuted, fontSize: 12), + ), + ], + ), + ), + const SizedBox(width: 12), + Switch( + value: value, + onChanged: onChanged, + activeColor: theme.accentMid, + ), + ], + ); + } +} diff --git a/display/lib/theme/autopilot_theme.dart b/display/lib/theme/autopilot_theme.dart new file mode 100644 index 0000000..fa497a4 --- /dev/null +++ b/display/lib/theme/autopilot_theme.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; + +/// Visual theme token set for the AR-Autopilot display app. +/// +/// Every widget reads design tokens from [AutopilotTheme] via +/// [AutopilotThemeProvider]. No widget hard-codes colors. +/// +/// ## Design invariants (apply to ALL themes) +/// 1. **DISENGAGE** is always the highest-contrast element on screen. +/// If the palette makes red ambiguous, it changes to amber (wine theme). +/// 2. **Action buttons** (DODGE, ±1°, ±10°) must be legible at rest — +/// operators work with gloves in rain; hover/tap is not reliable. +/// 3. **Compass heading text** uses [textMain] with [accentGlowColor] glow. +/// 4. **Set-point** always uses [setLight] (amber/gold) — cognitively stable +/// across palettes; represents "operator intent". +/// 5. **North mark** is always a warm color ([northColor]) — nautical convention. +/// 6. **Touch targets**: 48×48 px nominal, 60×60 px for critical controls. +/// 7. **No glow in light mode**: [accentGlowRadius] == 0.0 for `light` theme. +class AutopilotTheme { + const AutopilotTheme({ + required this.id, + required this.displayName, + // Backgrounds + required this.background, + required this.backgroundMid, + required this.backgroundDeep, + required this.backgroundDeepest, + this.backgroundGradient, + // Panels + required this.panelBackground, + required this.panelBorder, + // Text + required this.textMain, + required this.textMuted, + required this.textSoft, + required this.textDisabled, + // Accent — heading arc, active mode indicator, rudder indicator + required this.accentLight, + required this.accentMid, + required this.accentDark, + required this.accentGlowRadius, + required this.accentGlowColor, + // Set-point / desired heading + required this.setLight, + required this.setDark, + required this.setGlow, + // Semantic states + required this.okColor, + required this.warnColor, + required this.northColor, + // DISENGAGE — always high contrast, always unambiguous + required this.disengageBackground, + required this.disengageText, + required this.disengageBorder, + required this.disengageGlow, + // Action buttons (DODGE, ±1°, ±10°) + required this.actionButtonBackground, + required this.actionButtonBorder, + required this.actionButtonText, + required this.actionButtonGlow, + }); + + final String id; + final String displayName; + + // ── Backgrounds ──────────────────────────────────────────────────────────── + /// Dominant background color. Used as solid fill (light) or radial + /// gradient center (dark themes). See [backgroundDecoration]. + final Color background; + final Color backgroundMid; + final Color backgroundDeep; + final Color backgroundDeepest; + + /// Radial gradient for dark themes; `null` for the light theme + /// (which uses a solid [background]). + final Gradient? backgroundGradient; + + // ── Panels ────────────────────────────────────────────────────────────────── + final Gradient panelBackground; + final Color panelBorder; + + // ── Text ──────────────────────────────────────────────────────────────────── + final Color textMain; + final Color textMuted; + final Color textSoft; + final Color textDisabled; + + // ── Accent ────────────────────────────────────────────────────────────────── + final Color accentLight; + final Color accentMid; + final Color accentDark; + + /// Blur radius for accent glow effect. Zero in light mode (no glow). + final double accentGlowRadius; + final Color accentGlowColor; + + // ── Set-point ─────────────────────────────────────────────────────────────── + final Color setLight; + final Color setDark; + final Color setGlow; + + // ── Semantic states ───────────────────────────────────────────────────────── + /// GPS OK, NMEA sentence valid, SOG within range. + final Color okColor; + + /// Heading error, cross-track error warning. + final Color warnColor; + + /// North mark on the compass rose. Always a warm color (never blue). + final Color northColor; + + // ── DISENGAGE ─────────────────────────────────────────────────────────────── + final Gradient disengageBackground; + final Color disengageText; + final Color disengageBorder; + final Color disengageGlow; + + // ── Action buttons (DODGE, ±1°, ±10°) ────────────────────────────────────── + final Gradient actionButtonBackground; + final Color actionButtonBorder; + final Color actionButtonText; + final Color actionButtonGlow; + + // ── Computed helpers ──────────────────────────────────────────────────────── + + /// [BoxDecoration] for the full-screen background container. + BoxDecoration get backgroundDecoration => backgroundGradient != null + ? BoxDecoration(gradient: backgroundGradient) + : BoxDecoration(color: background); + + /// [BoxShadow] list for accented glow (empty in light mode). + List glowShadow(Color color, double radius) => radius > 0 + ? [BoxShadow(color: color, blurRadius: radius, spreadRadius: 1)] + : const []; +} diff --git a/display/lib/theme/theme_provider.dart b/display/lib/theme/theme_provider.dart new file mode 100644 index 0000000..a3f3dd4 --- /dev/null +++ b/display/lib/theme/theme_provider.dart @@ -0,0 +1,59 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'autopilot_theme.dart'; +import 'theme_registry.dart'; + +/// Manages the active [AutopilotTheme] and persists the user's selection. +/// +/// ## Persistence +/// - Stored locally in [SharedPreferences] under [_kThemeKey]. +/// - **NOT sent to the ESP32** — firmware is colour-agnostic. +/// - **NOT synchronised between displays** — each display (bridge / engine room) +/// maintains its own independent preference. +/// +/// ## Usage +/// ```dart +/// // In main.dart — wrap the widget tree: +/// ChangeNotifierProvider( +/// create: (_) => await AutopilotThemeProvider.load(), +/// child: const AutopilotApp(), +/// ) +/// +/// // Read the current theme anywhere in the tree: +/// final theme = context.watch().current; +/// +/// // Switch theme (e.g. from the Appearance screen): +/// await context.read().setTheme('wine'); +/// ``` +class AutopilotThemeProvider extends ChangeNotifier { + static const String _kThemeKey = 'autopilot.theme.id'; + + AutopilotTheme _current; + + AutopilotThemeProvider(this._current); + + /// The currently active [AutopilotTheme]. + AutopilotTheme get current => _current; + + /// Loads the persisted theme preference from [SharedPreferences]. + /// + /// Falls back to [ThemeRegistry.defaultId] if no preference is stored + /// (e.g. first launch) or the stored id is no longer registered. + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getString(_kThemeKey) ?? ThemeRegistry.defaultId; + return AutopilotThemeProvider(ThemeRegistry.byId(id)); + } + + /// Switches to the theme identified by [id] and persists the selection. + /// + /// Unknown ids silently fall back to [ThemeRegistry.defaultId]. + /// Notifies all listeners after the switch so the UI rebuilds. + Future setTheme(String id) async { + _current = ThemeRegistry.byId(id); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_kThemeKey, _current.id); + notifyListeners(); + } +} diff --git a/display/lib/theme/theme_registry.dart b/display/lib/theme/theme_registry.dart new file mode 100644 index 0000000..14eea8d --- /dev/null +++ b/display/lib/theme/theme_registry.dart @@ -0,0 +1,44 @@ +import 'autopilot_theme.dart'; +import 'themes/light_theme.dart'; +import 'themes/cyan_theme.dart'; +import 'themes/wine_theme.dart'; +import 'themes/ochre_theme.dart'; + +/// Central registry of all factory [AutopilotTheme]s. +/// +/// Themes are keyed by their [AutopilotTheme.id] string. +/// +/// ## Display order +/// `light → cyan → wine → ochre` (shown in the Appearance selector grid). +/// +/// ## Custom themes (Sprint 9 / Tier 3) +/// Architecture is ready for integrators to ship custom YAML themes in +/// `/config/themes/*.yaml`. [ThemeRegistry] will merge them with the +/// factory set at startup. Not active until Sprint 9. +abstract final class ThemeRegistry { + /// Theme id used when no preference is stored (first run). + static const String defaultId = 'cyan'; + + static final Map _registry = { + lightTheme.id: lightTheme, + cyanTheme.id: cyanTheme, + wineTheme.id: wineTheme, + ochreTheme.id: ochreTheme, + }; + + /// All available themes in UI display order. + static List get all => const [ + lightTheme, + cyanTheme, + wineTheme, + ochreTheme, + ]; + + /// Returns the theme for [id]. + /// Falls back to [defaultId] (cyan) if [id] is not registered. + static AutopilotTheme byId(String id) => + _registry[id] ?? _registry[defaultId]!; + + /// Whether [id] belongs to a registered theme. + static bool contains(String id) => _registry.containsKey(id); +} diff --git a/display/lib/theme/themes/cyan_theme.dart b/display/lib/theme/themes/cyan_theme.dart new file mode 100644 index 0000000..f80196e --- /dev/null +++ b/display/lib/theme/themes/cyan_theme.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import '../autopilot_theme.dart'; + +/// **cyan** — Cockpit cian (default) +/// +/// The default factory theme. Matches the visual language of professional +/// marine electronics (Raymarine, Garmin, Simrad). Neon cyan on deep navy. +/// Used for twilight / night operation with ambient lighting. +const AutopilotTheme cyanTheme = AutopilotTheme( + id: 'cyan', + displayName: 'Cockpit cian', + + background: Color(0xFF0D1822), + backgroundMid: Color(0xFF0F172A), + backgroundDeep: Color(0xFF0A1220), + backgroundDeepest: Color(0xFF020610), + backgroundGradient: RadialGradient( + colors: [Color(0xFF0D1822), Color(0xFF050810)], + stops: [0.0, 0.7], + ), + + panelBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xE6142030), Color(0xE608101C)], // rgba(20,32,48,0.9) → rgba(8,16,28,0.9) + ), + panelBorder: Color(0x4038BDF8), // rgba(56,189,248,0.25) + + textMain: Color(0xFFE0F2FE), + textMuted: Color(0xFF64748B), + textSoft: Color(0xFFCBD5E1), + textDisabled: Color(0xFF475569), + + accentLight: Color(0xFF7DD3FC), + accentMid: Color(0xFF0EA5E9), + accentDark: Color(0xFF0369A1), + accentGlowRadius: 16.0, + accentGlowColor: Color(0x9938BDF8), // rgba(56,189,248,0.6) + + setLight: Color(0xFFFBBF24), + setDark: Color(0xFFA16207), + setGlow: Color(0x66FBBF24), + + okColor: Color(0xFF4ADE80), + warnColor: Color(0xFFFBBF24), + northColor: Color(0xFFF87171), + + disengageBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFDC2626), Color(0xFF7F1D1D)], + ), + disengageText: Color(0xFFFFFFFF), + disengageBorder: Color(0xFFF87171), + disengageGlow: Color(0x80DC2626), + + actionButtonBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF1E4A72), Color(0xFF0C2540)], // improved contrast + ), + actionButtonBorder: Color(0xFF38BDF8), // accented border, 1px + actionButtonText: Color(0xFFE0F2FE), + actionButtonGlow: Color(0x667DD3FC), // subtle text-shadow +); diff --git a/display/lib/theme/themes/light_theme.dart b/display/lib/theme/themes/light_theme.dart new file mode 100644 index 0000000..72f5a8b --- /dev/null +++ b/display/lib/theme/themes/light_theme.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import '../autopilot_theme.dart'; + +/// **light** — Claro (día) +/// +/// Daytime operation under direct sunlight. Navy-blue accent on cream white. +/// No glow effects — bloom is invisible and distracting in bright ambient light. +const AutopilotTheme lightTheme = AutopilotTheme( + id: 'light', + displayName: 'Claro (día)', + + background: Color(0xFFF5F4EE), + backgroundMid: Color(0xFFFFFFFF), + backgroundDeep: Color(0xFFF1EFE8), + backgroundDeepest: Color(0xFFE8E6DD), + backgroundGradient: null, // solid background — no gradient in light mode + + panelBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFFFFFFF)], + ), + panelBorder: Color(0x1F000000), // rgba(0,0,0,0.12) + + textMain: Color(0xFF2C2C2A), + textMuted: Color(0xFF6B6862), + textSoft: Color(0xFF2C2C2A), + textDisabled: Color(0xFFB4B2A9), + + accentLight: Color(0xFF378ADD), + accentMid: Color(0xFF185FA5), + accentDark: Color(0xFF0C447C), + accentGlowRadius: 0.0, // no glow in daytime mode + accentGlowColor: Colors.transparent, + + setLight: Color(0xFFCA8A04), + setDark: Color(0xFF854F0B), + setGlow: Color(0x33CA8A04), + + okColor: Color(0xFF15803D), + warnColor: Color(0xFFCA8A04), + northColor: Color(0xFFB91C1C), + + disengageBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFDC2626), Color(0xFF991B1B)], + ), + disengageText: Color(0xFFFFFFFF), + disengageBorder: Color(0xFFEF4444), + disengageGlow: Color(0x4DDC2626), + + actionButtonBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFFFFF), Color(0xFFF1EFE8)], + ), + actionButtonBorder: Color(0x33000000), // 0.2 alpha — more visible than 0.12 + actionButtonText: Color(0xFF2C2C2A), + actionButtonGlow: Colors.transparent, +); diff --git a/display/lib/theme/themes/ochre_theme.dart b/display/lib/theme/themes/ochre_theme.dart new file mode 100644 index 0000000..d7ab0cd --- /dev/null +++ b/display/lib/theme/themes/ochre_theme.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; +import '../autopilot_theme.dart'; + +/// **ochre** — Ocre +/// +/// Classic cabin / aged leather aesthetic. Warm amber-gold accent on deep +/// brown. DISENGAGE returns to red (which contrasts well against ochre). +/// okColor is lime-green to stand out from the dominant gold palette. +const AutopilotTheme ochreTheme = AutopilotTheme( + id: 'ochre', + displayName: 'Ocre', + + background: Color(0xFF2A1A08), + backgroundMid: Color(0xFF2A1A08), + backgroundDeep: Color(0xFF1A1004), + backgroundDeepest: Color(0xFF080502), + backgroundGradient: RadialGradient( + colors: [Color(0xFF2A1A08), Color(0xFF0F0904)], + stops: [0.0, 0.7], + ), + + panelBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xEB32200C), Color(0xEB181008)], // rgba(50,32,12,0.92) → rgba(24,16,8,0.92) + ), + panelBorder: Color(0x52D97706), // rgba(217,119,6,0.32) + + textMain: Color(0xFFFEF3C7), + textMuted: Color(0xFFA8855A), + textSoft: Color(0xFFFCD34D), + textDisabled: Color(0xFF6B5435), + + accentLight: Color(0xFFFBBF24), + accentMid: Color(0xFFD97706), + accentDark: Color(0xFF92400E), + accentGlowRadius: 18.0, + accentGlowColor: Color(0xB3D97706), + + setLight: Color(0xFFFDE68A), + setDark: Color(0xFFB45309), + setGlow: Color(0x66FDE68A), + + okColor: Color(0xFF84CC16), // lime-green — contrasts with gold + warnColor: Color(0xFFFDE68A), + northColor: Color(0xFFFDE047), + + // DISENGAGE: red — contrasts well on ochre background + disengageBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFDC2626), Color(0xFF7F1D1D)], + ), + disengageText: Color(0xFFFFFFFF), + disengageBorder: Color(0xFFF87171), + disengageGlow: Color(0x99DC2626), + + actionButtonBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF7A4E1A), Color(0xFF3A240C)], + ), + actionButtonBorder: Color(0xFFFBBF24), + actionButtonText: Color(0xFFFEF3C7), + actionButtonGlow: Color(0x66FBBF24), +); diff --git a/display/lib/theme/themes/wine_theme.dart b/display/lib/theme/themes/wine_theme.dart new file mode 100644 index 0000000..a7d41f0 --- /dev/null +++ b/display/lib/theme/themes/wine_theme.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import '../autopilot_theme.dart'; + +/// **wine** — Vinotinto +/// +/// Premium yacht / aesthetic preference palette. Deep crimson backgrounds +/// with rose accent. IMPORTANT: DISENGAGE switches from red to amber-gold +/// because red blends into the wine cockpit and loses its emergency signal. +/// The amber still carries the "caution/critical action" cognitive association. +const AutopilotTheme wineTheme = AutopilotTheme( + id: 'wine', + displayName: 'Vinotinto', + + background: Color(0xFF2A0A14), + backgroundMid: Color(0xFF2A0A14), + backgroundDeep: Color(0xFF1A0610), + backgroundDeepest: Color(0xFF080205), + backgroundGradient: RadialGradient( + colors: [Color(0xFF2A0A14), Color(0xFF0F0408)], + stops: [0.0, 0.7], + ), + + panelBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xE630121C), Color(0xE614080E)], // rgba(48,18,28,0.9) → rgba(20,8,14,0.9) + ), + panelBorder: Color(0x4DBE1746), // rgba(190,23,70,0.3) + + textMain: Color(0xFFFDE0E7), + textMuted: Color(0xFF8A5560), + textSoft: Color(0xFFF9A8B8), + textDisabled: Color(0xFF6A3848), + + accentLight: Color(0xFFFB7185), + accentMid: Color(0xFFE11D48), + accentDark: Color(0xFF881337), + accentGlowRadius: 18.0, + accentGlowColor: Color(0xB3E11D48), // rgba(225,29,72,0.7) + + setLight: Color(0xFFFBBF24), + setDark: Color(0xFFA16207), + setGlow: Color(0x66FBBF24), + + okColor: Color(0xFFFBBF24), // gold not green — keeps warm palette + warnColor: Color(0xFFFBBF24), + northColor: Color(0xFFFDE047), // yellow not red — red is lost on wine background + + // DISENGAGE: amber-gold instead of red — highest contrast on wine cockpit + disengageBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFBBF24), Color(0xFFB45309)], + ), + disengageText: Color(0xFF1C0A02), // near-black — maximum contrast on amber + disengageBorder: Color(0xFFFDE047), + disengageGlow: Color(0x99FBBF24), + + actionButtonBackground: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF7A1F3A), Color(0xFF3A0E1C)], // improved contrast + ), + actionButtonBorder: Color(0xFFFB7185), // vivid rose border + actionButtonText: Color(0xFFFDE0E7), + actionButtonGlow: Color(0x66FB7185), +); diff --git a/display/lib/widgets/themed/compass_rose.dart b/display/lib/widgets/themed/compass_rose.dart new file mode 100644 index 0000000..d82da58 --- /dev/null +++ b/display/lib/widgets/themed/compass_rose.dart @@ -0,0 +1,165 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../theme/autopilot_theme.dart'; +import '../../theme/theme_provider.dart'; + +/// Full compass rose widget — the dominant visual element of the cockpit. +/// +/// Displays: +/// - Rotating compass ring with degree markings +/// - Heading arc in [AutopilotTheme.accentLight] +/// - Set-point tick in [AutopilotTheme.setLight] +/// - North mark in [AutopilotTheme.northColor] +/// - Centre heading readout in [AutopilotTheme.textMain] with accent glow +/// +/// [headingDeg] is the current vessel heading (0–359.9°, magnetic). +/// [setPointDeg] is the desired heading (the autopilot target). +class CompassRose extends StatelessWidget { + const CompassRose({ + super.key, + required this.headingDeg, + this.setPointDeg, + this.size = 280, + }); + + final double headingDeg; + final double? setPointDeg; + final double size; + + @override + Widget build(BuildContext context) { + final theme = context.watch().current; + return SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _CompassPainter( + theme: theme, + headingDeg: headingDeg, + setPointDeg: setPointDeg, + ), + child: Center( + child: _HeadingReadout(theme: theme, headingDeg: headingDeg), + ), + ), + ); + } +} + +class _HeadingReadout extends StatelessWidget { + const _HeadingReadout({required this.theme, required this.headingDeg}); + final AutopilotTheme theme; + final double headingDeg; + + @override + Widget build(BuildContext context) { + final text = headingDeg.toStringAsFixed(1).padLeft(5, ' '); + return Text( + '$text°', + style: TextStyle( + color: theme.textMain, + fontSize: 36, + fontWeight: FontWeight.w200, + letterSpacing: 2, + fontFeatures: const [FontFeature.tabularFigures()], + shadows: theme.accentGlowRadius > 0 + ? [Shadow(color: theme.accentGlowColor, blurRadius: theme.accentGlowRadius)] + : null, + ), + ); + } +} + +class _CompassPainter extends CustomPainter { + const _CompassPainter({ + required this.theme, + required this.headingDeg, + this.setPointDeg, + }); + + final AutopilotTheme theme; + final double headingDeg; + final double? setPointDeg; + + double _toRad(double deg) => deg * math.pi / 180; + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final radius = size.width / 2 - 8; + + // Outer ring + canvas.drawCircle( + center, + radius, + Paint() + ..color = theme.panelBorder + ..style = PaintingStyle.stroke + ..strokeWidth = 1, + ); + + // Heading arc (±45° around top = current heading) + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius - 8), + _toRad(-90 - 45), + _toRad(90), + false, + Paint() + ..color = theme.accentLight + ..style = PaintingStyle.stroke + ..strokeWidth = 4 + ..strokeCap = StrokeCap.round, + ); + + // North mark (always at top of the ring, rotated opposite to heading) + final northAngle = _toRad(-headingDeg - 90); + final nx = center.dx + radius * math.cos(northAngle); + final ny = center.dy + radius * math.sin(northAngle); + canvas.drawCircle( + Offset(nx, ny), + 5, + Paint()..color = theme.northColor, + ); + + // Set-point tick + if (setPointDeg != null) { + final spAngle = _toRad(setPointDeg! - headingDeg - 90); + canvas.drawLine( + Offset( + center.dx + (radius - 12) * math.cos(spAngle), + center.dy + (radius - 12) * math.sin(spAngle), + ), + Offset( + center.dx + radius * math.cos(spAngle), + center.dy + radius * math.sin(spAngle), + ), + Paint() + ..color = theme.setLight + ..strokeWidth = 3 + ..strokeCap = StrokeCap.round, + ); + } + + // Glow ring (dark themes only) + if (theme.accentGlowRadius > 0) { + canvas.drawCircle( + center, + radius - 8, + Paint() + ..color = theme.accentGlowColor + ..style = PaintingStyle.stroke + ..strokeWidth = theme.accentGlowRadius / 2 + ..maskFilter = MaskFilter.blur(BlurStyle.normal, theme.accentGlowRadius / 2), + ); + } + } + + @override + bool shouldRepaint(_CompassPainter old) => + old.headingDeg != headingDeg || + old.setPointDeg != setPointDeg || + old.theme != theme; +} diff --git a/display/lib/widgets/themed/disengage_button.dart b/display/lib/widgets/themed/disengage_button.dart new file mode 100644 index 0000000..a7a7c28 --- /dev/null +++ b/display/lib/widgets/themed/disengage_button.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../theme/autopilot_theme.dart'; +import '../../theme/theme_provider.dart'; + +/// The DISENGAGE emergency button. +/// +/// Always the highest-contrast element on screen. +/// Minimum touch target: 60×60 px (critical control). +/// +/// The gradient and glow colours adapt to the active theme automatically. +/// In the wine theme the button is amber-gold (not red) — see [wineTheme]. +class DisengageButton extends StatelessWidget { + const DisengageButton({ + super.key, + required this.onPressed, + this.enabled = true, + }); + + final VoidCallback? onPressed; + final bool enabled; + + /// Minimum touch target for a critical control. + static const double kMinSize = 60.0; + + @override + Widget build(BuildContext context) { + final theme = context.watch().current; + return _DisengageButtonContent( + theme: theme, + onPressed: enabled ? onPressed : null, + ); + } +} + +class _DisengageButtonContent extends StatefulWidget { + const _DisengageButtonContent({ + required this.theme, + required this.onPressed, + }); + + final AutopilotTheme theme; + final VoidCallback? onPressed; + + @override + State<_DisengageButtonContent> createState() => + _DisengageButtonContentState(); +} + +class _DisengageButtonContentState + extends State<_DisengageButtonContent> { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final t = widget.theme; + final enabled = widget.onPressed != null; + + return GestureDetector( + onTapDown: enabled ? (_) => setState(() => _pressed = true) : null, + onTapUp: enabled + ? (_) { + setState(() => _pressed = false); + widget.onPressed!(); + } + : null, + onTapCancel: () => setState(() => _pressed = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 120), + constraints: const BoxConstraints( + minWidth: DisengageButton.kMinSize, + minHeight: DisengageButton.kMinSize, + ), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), + decoration: BoxDecoration( + gradient: t.disengageBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: t.disengageBorder, width: 1.5), + boxShadow: [ + BoxShadow( + color: t.disengageGlow, + blurRadius: _pressed ? 4 : 12, + spreadRadius: _pressed ? 0 : 2, + ), + ], + ), + child: Text( + 'DISENGAGE', + textAlign: TextAlign.center, + style: TextStyle( + color: t.disengageText, + fontSize: 13, + fontWeight: FontWeight.w800, + letterSpacing: 1.2, + shadows: [ + Shadow( + color: t.disengageGlow, + blurRadius: 4, + ), + ], + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/mode_selector.dart b/display/lib/widgets/themed/mode_selector.dart new file mode 100644 index 0000000..27d9e5d --- /dev/null +++ b/display/lib/widgets/themed/mode_selector.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../theme/autopilot_theme.dart'; +import '../../theme/theme_provider.dart'; + +/// Autopilot mode selector — STANDBY / HEADING_HOLD / TRACK_KEEP. +/// +/// The active mode is highlighted with [AutopilotTheme.accentMid]. +/// Inactive modes use muted text and panel border. +/// Minimum touch target: 48×48 px per mode button. +enum AutopilotMode { standby, headingHold, trackKeep } + +extension AutopilotModeLabel on AutopilotMode { + String get label => switch (this) { + AutopilotMode.standby => 'STANDBY', + AutopilotMode.headingHold => 'HDG HOLD', + AutopilotMode.trackKeep => 'TRACK', + }; +} + +class ModeSelector extends StatelessWidget { + const ModeSelector({ + super.key, + required this.activeMode, + required this.onModeSelected, + }); + + final AutopilotMode activeMode; + final ValueChanged onModeSelected; + + @override + Widget build(BuildContext context) { + final theme = context.watch().current; + return Container( + decoration: BoxDecoration( + gradient: theme.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.panelBorder), + ), + child: Row( + children: AutopilotMode.values.map((mode) { + final isActive = mode == activeMode; + return Expanded( + child: _ModeButton( + theme: theme, + mode: mode, + isActive: isActive, + onTap: () => onModeSelected(mode), + ), + ); + }).toList(), + ), + ); + } +} + +class _ModeButton extends StatelessWidget { + const _ModeButton({ + required this.theme, + required this.mode, + required this.isActive, + required this.onTap, + }); + + final AutopilotTheme theme; + final AutopilotMode mode; + final bool isActive; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + constraints: const BoxConstraints(minHeight: 48), + alignment: Alignment.center, + decoration: BoxDecoration( + color: isActive ? theme.accentMid.withValues(alpha: 0.15) : Colors.transparent, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + mode.label, + style: TextStyle( + color: isActive ? theme.accentLight : theme.textMuted, + fontSize: 12, + fontWeight: isActive ? FontWeight.w700 : FontWeight.w400, + letterSpacing: 0.8, + shadows: isActive && theme.accentGlowRadius > 0 + ? [Shadow(color: theme.accentGlowColor, blurRadius: 6)] + : null, + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/rudder_indicator.dart b/display/lib/widgets/themed/rudder_indicator.dart new file mode 100644 index 0000000..26c6e60 --- /dev/null +++ b/display/lib/widgets/themed/rudder_indicator.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../theme/autopilot_theme.dart'; +import '../../theme/theme_provider.dart'; + +/// Rudder angle indicator — horizontal bar from –35° (port) to +35° (starboard). +/// +/// [rudderDeg] is the actual rudder angle in degrees. +/// Negative = port, positive = starboard. +/// [limitDeg] is the hard stop (default 35°). +class RudderIndicator extends StatelessWidget { + const RudderIndicator({ + super.key, + required this.rudderDeg, + this.limitDeg = 35.0, + this.height = 48, + }); + + final double rudderDeg; + final double limitDeg; + final double height; + + @override + Widget build(BuildContext context) { + final theme = context.watch().current; + return SizedBox( + height: height, + child: CustomPaint( + painter: _RudderPainter( + theme: theme, + rudderDeg: rudderDeg, + limitDeg: limitDeg, + ), + ), + ); + } +} + +class _RudderPainter extends CustomPainter { + const _RudderPainter({ + required this.theme, + required this.rudderDeg, + required this.limitDeg, + }); + + final AutopilotTheme theme; + final double rudderDeg; + final double limitDeg; + + @override + void paint(Canvas canvas, Size size) { + final t = theme; + final cx = size.width / 2; + final cy = size.height / 2; + final halfW = size.width / 2 - 12; + + // Track + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromCenter(center: Offset(cx, cy), width: size.width - 24, height: 6), + const Radius.circular(3), + ), + Paint()..color = t.panelBorder, + ); + + // Centre line + canvas.drawLine( + Offset(cx, cy - 10), + Offset(cx, cy + 10), + Paint() + ..color = t.textMuted + ..strokeWidth = 1, + ); + + // Rudder fill + final fraction = (rudderDeg / limitDeg).clamp(-1.0, 1.0); + final fillWidth = (halfW * fraction.abs()).clamp(0.0, halfW); + if (fillWidth > 1) { + final left = fraction < 0 ? cx - fillWidth : cx; + canvas.drawRRect( + RRect.fromRectAndRadius( + Rect.fromLTWH(left, cy - 3, fillWidth, 6), + const Radius.circular(2), + ), + Paint()..color = t.accentMid, + ); + } + + // Indicator knob + final kx = cx + halfW * fraction; + canvas.drawCircle( + Offset(kx, cy), + 9, + Paint()..color = t.accentLight, + ); + if (t.accentGlowRadius > 0) { + canvas.drawCircle( + Offset(kx, cy), + 9, + Paint() + ..color = t.accentGlowColor + ..maskFilter = MaskFilter.blur(BlurStyle.normal, t.accentGlowRadius / 2), + ); + } + + // Labels + final labelStyle = TextStyle(color: t.textMuted, fontSize: 10); + _drawLabel(canvas, 'P', Offset(12, cy), labelStyle); + _drawLabel(canvas, 'S', Offset(size.width - 12, cy), labelStyle); + } + + void _drawLabel(Canvas canvas, String text, Offset center, TextStyle style) { + final span = TextSpan(text: text, style: style); + final tp = TextPainter( + text: span, + textDirection: TextDirection.ltr, + )..layout(); + tp.paint(canvas, center - Offset(tp.width / 2, tp.height / 2)); + } + + @override + bool shouldRepaint(_RudderPainter old) => + old.rudderDeg != rudderDeg || old.theme != theme; +} diff --git a/display/pubspec.yaml b/display/pubspec.yaml new file mode 100644 index 0000000..83f7e68 --- /dev/null +++ b/display/pubspec.yaml @@ -0,0 +1,23 @@ +name: ar_autopilot_display +description: AR-Autopilot Flutter display app — marine autopilot cockpit UI for 30-40 m vessels. +version: 0.4.0+4 + +environment: + sdk: '>=3.3.0 <4.0.0' + flutter: '>=3.19.0' + +dependencies: + flutter: + sdk: flutter + # State management — theme provider uses ChangeNotifier, exposed via Provider + provider: ^6.1.2 + # Theme persistence — stores selected theme id locally on the display device + shared_preferences: ^2.3.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^4.0.0 + +flutter: + uses-material-design: true diff --git a/display/test/theme/theme_contrast_test.dart b/display/test/theme/theme_contrast_test.dart new file mode 100644 index 0000000..862abd4 --- /dev/null +++ b/display/test/theme/theme_contrast_test.dart @@ -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 _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.', + ); + }); + }); +} diff --git a/display/test/theme/theme_provider_test.dart b/display/test/theme/theme_provider_test.dart new file mode 100644 index 0000000..85f8f0e --- /dev/null +++ b/display/test/theme/theme_provider_test.dart @@ -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); + }); + }); +} diff --git a/display/test/theme/theme_registry_test.dart b/display/test/theme/theme_registry_test.dart new file mode 100644 index 0000000..6af43a6 --- /dev/null +++ b/display/test/theme/theme_registry_test.dart @@ -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', + ); + } + }); + }); + }); +}