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
+11
View File
@@ -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
+34
View File
@@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final themeProvider = await AutopilotThemeProvider.load();
runApp(
ChangeNotifierProvider<AutopilotThemeProvider>.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(),
},
);
}
}
@@ -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<AutopilotThemeProvider>();
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<bool>? 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,
),
],
);
}
}
+135
View File
@@ -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<BoxShadow> glowShadow(Color color, double radius) => radius > 0
? [BoxShadow(color: color, blurRadius: radius, spreadRadius: 1)]
: const [];
}
+59
View File
@@ -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<AutopilotThemeProvider>(
/// create: (_) => await AutopilotThemeProvider.load(),
/// child: const AutopilotApp(),
/// )
///
/// // Read the current theme anywhere in the tree:
/// final theme = context.watch<AutopilotThemeProvider>().current;
///
/// // Switch theme (e.g. from the Appearance screen):
/// await context.read<AutopilotThemeProvider>().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<AutopilotThemeProvider> 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<void> setTheme(String id) async {
_current = ThemeRegistry.byId(id);
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kThemeKey, _current.id);
notifyListeners();
}
}
+44
View File
@@ -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<String, AutopilotTheme> _registry = {
lightTheme.id: lightTheme,
cyanTheme.id: cyanTheme,
wineTheme.id: wineTheme,
ochreTheme.id: ochreTheme,
};
/// All available themes in UI display order.
static List<AutopilotTheme> 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);
}
+65
View File
@@ -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
);
+61
View File
@@ -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,
);
+66
View File
@@ -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),
);
+67
View File
@@ -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),
);
@@ -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 (0359.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<AutopilotThemeProvider>().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;
}
@@ -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<AutopilotThemeProvider>().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,
),
],
),
),
),
);
}
}
@@ -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<AutopilotMode> onModeSelected;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().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,
),
),
),
);
}
}
@@ -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<AutopilotThemeProvider>().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;
}
+23
View File
@@ -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
+127
View File
@@ -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);
});
});
}
+130
View File
@@ -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',
);
}
});
});
});
}