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,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;
|
||||
}
|
||||
Reference in New Issue
Block a user