sprint-4b: Flutter display theme system (AutopilotTheme + 4 palettes + tests)

Implements the visual theme system specified in mock up software.pdf.

Flutter project — display/:
- pubspec.yaml: ar_autopilot_display v0.4.0+4 (provider + shared_preferences)
- lib/theme/autopilot_theme.dart: AutopilotTheme class with 30+ design tokens
  (backgrounds, panels, text, accent, set-point, semantic states, DISENGAGE,
  action buttons, glow helpers, backgroundDecoration getter)
- lib/theme/theme_registry.dart: ThemeRegistry with 4 factory themes,
  byId() fallback, architecture stub for Sprint 9 custom YAML themes
- lib/theme/theme_provider.dart: AutopilotThemeProvider (ChangeNotifier),
  SharedPreferences persistence under 'autopilot.theme.id', NOT sent to ESP32
- lib/theme/themes/: 4 factory themes with exact hex values from spec:
    light (cream/navy, accentGlowRadius=0 — daytime no-glow rule)
    cyan (deep navy/neon-cyan, default, glowRadius=16)
    wine (vinotinto, DISENGAGE=amber not red, glowRadius=18)
    ochre (warm brown/gold, okColor=lime for contrast, glowRadius=18)
- lib/screens/settings/appearance_settings.dart: Appearance screen with
  4-card theme previews (200x120px), 400ms AnimatedContainer transitions,
  triple-tap shortcut note, Sprint-5 placeholders for auto day/night and
  ambient light sensor toggles
- lib/widgets/themed/: 4 themed widgets consuming AutopilotThemeProvider:
    compass_rose.dart (heading arc, N mark, set-point tick, glow ring)
    disengage_button.dart (60x60 min touch target, gradient, glow)
    mode_selector.dart (STANDBY/HDG HOLD/TRACK with accent highlight)
    rudder_indicator.dart (horizontal bar -35° to +35°, accent knob)
- lib/main.dart: app entry point with ChangeNotifierProvider

Tests — display/test/theme/:
- theme_registry_test.dart: 4 themes load, correct IDs, display order,
  no null tokens, glow rules, backgroundGradient rules
- theme_provider_test.dart: default load=cyan, persistence across restarts,
  setTheme notifies listeners, unknown ID falls back to default
- theme_contrast_test.dart: WCAG checks — DISENGAGE ≥7:1 AAA,
  action buttons ≥4.5:1 AA, textMain ≥4.5:1 AA, setLight/okColor ≥3:1

Also:
- .gitignore: added !display/lib/ exception (lib/ was excluded for Python venv)

Design rules enforced:
- No glow in light mode (accentGlowRadius=0)
- DISENGAGE = amber in wine theme (red would blend into palette)
- North mark = warm colour on all themes (nautical convention)
- Touch targets: 48px nominal, 60px critical
- Theme not sent to ESP32, not synced between displays

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 00:33:04 -04:00
parent e4812e9b44
commit cb138a3248
18 changed files with 1701 additions and 0 deletions
@@ -0,0 +1,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;
}