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