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>
108 lines
3.0 KiB
Dart
108 lines
3.0 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|