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