cb138a3248
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>
126 lines
3.2 KiB
Dart
126 lines
3.2 KiB
Dart
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;
|
||
}
|