feat(display): Sprint 4 — pantalla principal del autopilot

Archivos nuevos:
  display/lib/data/autopilot_state.dart
    - ChangeNotifier con todos los datos del autopilot
    - Simulación de velero en mar (heading drift / P-controller)
    - API pública estable: engage(), disengage(), adjustSetpoint(), selectMode()
    - Sprint 7: los internos se reemplazan por Modbus RTU, la API no cambia

  display/lib/screens/cockpit/cockpit_screen.dart
    - Pantalla principal: TopBar, ModeSelector, CompassRose, DataStrip,
      HeadingAdjustBar, RudderIndicator, ENGAGE/DISENGAGE
    - Logo con triple-tap para ciclar temas (StateWidget con Timer)
    - Indicador DEMO visible cuando isConnected == false
    - Engranaje → AppearanceSettingsScreen

  display/lib/widgets/themed/engage_button.dart
    - Botón verde con glow; dimmed cuando ya está engaged

  display/lib/widgets/themed/heading_adjust_bar.dart
    - Botones << < [SET 048.0°] > >>
    - Deshabilitado cuando mode == STANDBY

  display/lib/widgets/themed/status_chip.dart
    - Indicador de punto + label para NMEA / GPS (ok / warn / off)

Modificado:
  display/lib/main.dart
    - MultiProvider: agrega AutopilotState al árbol de providers
    - Ruta inicial: CockpitScreen.routeName ('/') en lugar de Appearance

AR_electronics — AR-Autopilot Project
This commit is contained in:
2026-05-23 11:53:50 -04:00
parent d4d12caac7
commit c946d2df6d
6 changed files with 969 additions and 5 deletions
@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// ENGAGE button — activates heading-hold at the current vessel heading.
///
/// Shown enabled (green, glowing) only when autopilot is in STANDBY.
/// When already engaged, [enabled] is false and the button dims.
///
/// Minimum touch target: 60×60 px (critical control, see design invariant §6).
class EngageButton extends StatefulWidget {
const EngageButton({
super.key,
required this.onPressed,
this.enabled = true,
});
final VoidCallback? onPressed;
final bool enabled;
@override
State<EngageButton> createState() => _EngageButtonState();
}
class _EngageButtonState extends State<EngageButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
final enabled = widget.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: 60, minHeight: 60),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: BoxDecoration(
gradient: enabled
? LinearGradient(
colors: [
theme.okColor.withValues(alpha: 0.65),
theme.okColor.withValues(alpha: 0.35),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: LinearGradient(
colors: [theme.panelBorder, theme.panelBorder],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: enabled ? theme.okColor : theme.panelBorder,
width: 1.5,
),
boxShadow: enabled
? [
BoxShadow(
color: theme.okColor
.withValues(alpha: _pressed ? 0.2 : 0.45),
blurRadius: _pressed ? 4 : 14,
spreadRadius: _pressed ? 0 : 2,
),
]
: [],
),
child: Text(
'ENGAGE',
textAlign: TextAlign.center,
style: TextStyle(
color: enabled ? Colors.white : theme.textDisabled,
fontSize: 13,
fontWeight: FontWeight.w800,
letterSpacing: 1.2,
shadows: enabled
? [Shadow(color: theme.okColor, blurRadius: 4)]
: null,
),
),
),
);
}
}
@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../theme/autopilot_theme.dart';
import '../../theme/theme_provider.dart';
/// Four heading-adjust buttons around a central setpoint readout.
///
/// Layout (left → right):
/// [ << 10° ] [ < 1° ] SET 048.0° [ 1° > ] [ 10° >> ]
///
/// All buttons are disabled when [enabled] is false (autopilot not engaged).
/// The setpoint display dims when disabled to reinforce the inactive state.
///
/// [onAdjust] receives the signed delta in degrees (+1, 1, +10, 10).
class HeadingAdjustBar extends StatelessWidget {
const HeadingAdjustBar({
super.key,
required this.setpointDeg,
required this.enabled,
required this.onAdjust,
});
final double setpointDeg;
final bool enabled;
final ValueChanged<double> onAdjust;
@override
Widget build(BuildContext context) {
final theme = context.watch<AutopilotThemeProvider>().current;
return Row(
children: [
_AdjustButton(
theme: theme, label: '<<\n10°', delta: -10,
enabled: enabled, onAdjust: onAdjust),
const SizedBox(width: 6),
_AdjustButton(
theme: theme, label: '<\n', delta: -1,
enabled: enabled, onAdjust: onAdjust),
const SizedBox(width: 10),
Expanded(
child: _SetpointDisplay(
theme: theme,
setpointDeg: setpointDeg,
enabled: enabled,
),
),
const SizedBox(width: 10),
_AdjustButton(
theme: theme, label: '\n>', delta: 1,
enabled: enabled, onAdjust: onAdjust),
const SizedBox(width: 6),
_AdjustButton(
theme: theme, label: '10°\n>>', delta: 10,
enabled: enabled, onAdjust: onAdjust),
],
);
}
}
// ── Setpoint display ──────────────────────────────────────────────────────────
class _SetpointDisplay extends StatelessWidget {
const _SetpointDisplay({
required this.theme,
required this.setpointDeg,
required this.enabled,
});
final AutopilotTheme theme;
final double setpointDeg;
final bool enabled;
@override
Widget build(BuildContext context) {
return Container(
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: theme.panelBackground,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: enabled
? theme.setLight.withValues(alpha: 0.5)
: theme.panelBorder,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'SET',
style: TextStyle(
color: theme.textMuted,
fontSize: 9,
letterSpacing: 1.2,
),
),
const SizedBox(height: 2),
Text(
'${setpointDeg.toStringAsFixed(1)}°',
style: TextStyle(
color: enabled ? theme.setLight : theme.textDisabled,
fontSize: 20,
fontWeight: FontWeight.w300,
fontFeatures: const [FontFeature.tabularFigures()],
shadows: enabled && theme.accentGlowRadius > 0
? [Shadow(color: theme.setGlow, blurRadius: 8)]
: null,
),
),
],
),
);
}
}
// ── Adjust button ─────────────────────────────────────────────────────────────
class _AdjustButton extends StatefulWidget {
const _AdjustButton({
required this.theme,
required this.label,
required this.delta,
required this.enabled,
required this.onAdjust,
});
final AutopilotTheme theme;
final String label;
final double delta;
final bool enabled;
final ValueChanged<double> onAdjust;
@override
State<_AdjustButton> createState() => _AdjustButtonState();
}
class _AdjustButtonState extends State<_AdjustButton> {
bool _pressed = false;
@override
Widget build(BuildContext context) {
final t = widget.theme;
final enabled = widget.enabled;
return GestureDetector(
onTapDown: enabled ? (_) => setState(() => _pressed = true) : null,
onTapUp: enabled
? (_) {
setState(() => _pressed = false);
widget.onAdjust(widget.delta);
}
: null,
onTapCancel: () => setState(() => _pressed = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
constraints: const BoxConstraints(minWidth: 48, minHeight: 52),
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: enabled ? t.actionButtonBackground : null,
color: enabled ? null : t.backgroundDeep,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: enabled && !_pressed
? t.actionButtonBorder
: t.panelBorder,
),
boxShadow: enabled && !_pressed
? t.glowShadow(t.actionButtonGlow, t.accentGlowRadius / 2)
: [],
),
child: Text(
widget.label,
textAlign: TextAlign.center,
style: TextStyle(
color: enabled ? t.actionButtonText : t.textDisabled,
fontSize: 11,
fontWeight: FontWeight.w600,
height: 1.35,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
),
);
}
}
@@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../theme/autopilot_theme.dart';
/// Connection / data-source status indicator — coloured dot + label.
///
/// Used in the [CockpitScreen] top-bar to show NMEA and GPS link state.
enum StatusLevel {
/// Data valid and link active.
ok,
/// Data stale, link degraded, or GPS fix lost.
warn,
/// Link absent (serial port not open, concentrador not connected).
off,
}
class StatusChip extends StatelessWidget {
const StatusChip({
super.key,
required this.theme,
required this.label,
required this.status,
});
final AutopilotTheme theme;
final String label;
final StatusLevel status;
Color get _dotColor => switch (status) {
StatusLevel.ok => theme.okColor,
StatusLevel.warn => theme.warnColor,
StatusLevel.off => theme.textDisabled,
};
@override
Widget build(BuildContext context) {
final dot = _dotColor;
final glowing = status == StatusLevel.ok && theme.accentGlowRadius > 0;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: dot,
boxShadow: glowing
? [BoxShadow(color: dot.withValues(alpha: 0.6), blurRadius: 6)]
: null,
),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
color:
status == StatusLevel.off ? theme.textDisabled : theme.textMuted,
fontSize: 10,
letterSpacing: 0.5,
fontWeight: FontWeight.w500,
),
),
],
);
}
}