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,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,
),
],
);
}
}