Files
AR-Autopilot/display/lib/screens/settings/appearance_settings.dart
T
alro65 cb138a3248 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>
2026-05-21 00:33:04 -04:00

308 lines
9.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
],
);
}
}