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:
@@ -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: '<\n1°', 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: '1°\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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user