From c946d2df6daa86ff34fd780b7041d82a437c1e21 Mon Sep 17 00:00:00 2001 From: alro1965 Date: Sat, 23 May 2026 11:53:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(display):=20Sprint=204=20=E2=80=94=20panta?= =?UTF-8?q?lla=20principal=20del=20autopilot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- display/lib/data/autopilot_state.dart | 167 +++++++ display/lib/main.dart | 23 +- .../lib/screens/cockpit/cockpit_screen.dart | 431 ++++++++++++++++++ display/lib/widgets/themed/engage_button.dart | 95 ++++ .../widgets/themed/heading_adjust_bar.dart | 188 ++++++++ display/lib/widgets/themed/status_chip.dart | 70 +++ 6 files changed, 969 insertions(+), 5 deletions(-) create mode 100644 display/lib/data/autopilot_state.dart create mode 100644 display/lib/screens/cockpit/cockpit_screen.dart create mode 100644 display/lib/widgets/themed/engage_button.dart create mode 100644 display/lib/widgets/themed/heading_adjust_bar.dart create mode 100644 display/lib/widgets/themed/status_chip.dart diff --git a/display/lib/data/autopilot_state.dart b/display/lib/data/autopilot_state.dart new file mode 100644 index 0000000..7ac1b7b --- /dev/null +++ b/display/lib/data/autopilot_state.dart @@ -0,0 +1,167 @@ +// ============================================================================= +// data/autopilot_state.dart — Live autopilot data model +// ============================================================================= +// +// Sprint 4: simulated demo data (vessel drifting / heading-hold correction). +// Sprint 7: internals replaced by Modbus RTU over USB serial. +// The public API (fields + methods) must not change between sprints. +// ============================================================================= + +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; + +import '../widgets/themed/mode_selector.dart'; + +/// Live state of the autopilot and navigation instruments. +/// +/// Consumed by [CockpitScreen] via `context.watch()`. +/// +/// ## Data sources (Sprint 4 → Sprint 7) +/// Sprint 4: internal [Timer]-driven demo that simulates a vessel at sea. +/// Sprint 7: [AutopilotStateModbus] subclass (or internal swap) feeds real +/// Modbus RTU data received over USB serial from the AR-Concentrador. +class AutopilotState extends ChangeNotifier { + // ── Navigation data ────────────────────────────────────────────────────────── + /// Current vessel heading from NMEA 2000 backbone (degrees, magnetic, 0–359.9). + double headingDeg = 125.0; + + /// Operator-commanded heading setpoint sent to autopilot (degrees, magnetic). + double setpointDeg = 125.0; + + /// Rudder angle in degrees. Negative = port, positive = starboard. + double rudderDeg = 0.0; + + /// Speed over ground (knots). + double sogKn = 6.2; + + /// Course over ground (degrees, true). + double cogDeg = 127.0; + + /// Rate of turn (degrees per minute). Positive = turning starboard. + double rotDpm = 0.0; + + /// Water depth below keel (metres). + double depthM = 42.5; + + // ── Autopilot state ────────────────────────────────────────────────────────── + /// Current autopilot mode as reported by the concentrador $PARP,STATUS. + AutopilotMode mode = AutopilotMode.standby; + + /// True when the Modbus RTU link to the concentrador is active. + /// Sprint 4: always false (demo mode label shown in UI). + bool isConnected = false; + + // ── Internal ───────────────────────────────────────────────────────────────── + Timer? _demoTimer; + final _rng = math.Random(); + + AutopilotState() { + _startDemo(); + } + + // --------------------------------------------------------------------------- + // Demo simulation (Sprint 4 only) + // --------------------------------------------------------------------------- + + void _startDemo() { + _demoTimer = Timer.periodic(const Duration(milliseconds: 500), (_) { + _tick(); + }); + } + + void _tick() { + switch (mode) { + case AutopilotMode.standby: + // Vessel drifts slightly — small random walk on heading and rudder. + headingDeg = (headingDeg + (_rng.nextDouble() - 0.5) * 0.5) % 360; + if (headingDeg < 0) headingDeg += 360; + rudderDeg = (rudderDeg + (_rng.nextDouble() - 0.5) * 0.8) + .clamp(-5.0, 5.0); + rotDpm = rudderDeg * 0.3 + (_rng.nextDouble() - 0.5) * 0.2; + + case AutopilotMode.headingHold: + // Simulated P-controller: autopilot drives rudder toward setpoint. + final error = _angleDiff(setpointDeg, headingDeg); + rudderDeg = (error * 1.2 + (_rng.nextDouble() - 0.5) * 0.5) + .clamp(-35.0, 35.0); + headingDeg = + (headingDeg + error * 0.025 + (_rng.nextDouble() - 0.5) * 0.08) + % 360; + if (headingDeg < 0) headingDeg += 360; + rotDpm = error * 0.4; + + case AutopilotMode.trackKeep: + // Sprint 6: identical to headingHold for demo purposes. + final error = _angleDiff(setpointDeg, headingDeg); + rudderDeg = + (error * 1.2).clamp(-35.0, 35.0); + headingDeg = (headingDeg + error * 0.025) % 360; + if (headingDeg < 0) headingDeg += 360; + rotDpm = error * 0.4; + } + + // COG tracks heading with a small lag. + cogDeg = (headingDeg + rudderDeg * 0.15 + (_rng.nextDouble() - 0.5) * 0.3) + % 360; + if (cogDeg < 0) cogDeg += 360; + + notifyListeners(); + } + + /// Signed difference in [−180, +180]: target − current. + double _angleDiff(double target, double current) { + double d = (target - current) % 360; + if (d > 180) d -= 360; + if (d < -180) d += 360; + return d; + } + + // --------------------------------------------------------------------------- + // Commands — called by UI; Sprint 7 will also send Modbus frames here. + // --------------------------------------------------------------------------- + + /// Engage heading hold on the current heading. + void engage() { + setpointDeg = headingDeg; + mode = AutopilotMode.headingHold; + notifyListeners(); + } + + /// Return to STANDBY (manual helm). + void disengage() { + mode = AutopilotMode.standby; + notifyListeners(); + } + + /// Adjust the heading setpoint while in heading-hold mode. + /// No-op in STANDBY or TRACK. + void adjustSetpoint(double deltaDeg) { + if (mode != AutopilotMode.headingHold) return; + setpointDeg = (setpointDeg + deltaDeg) % 360; + if (setpointDeg < 0) setpointDeg += 360; + notifyListeners(); + } + + /// Handle ModeSelector taps. + void selectMode(AutopilotMode newMode) { + switch (newMode) { + case AutopilotMode.standby: + disengage(); + case AutopilotMode.headingHold: + engage(); + case AutopilotMode.trackKeep: + // Sprint 6: engage track-keep mode (requires GPS cross-track error). + mode = AutopilotMode.trackKeep; + setpointDeg = headingDeg; + notifyListeners(); + } + } + + @override + void dispose() { + _demoTimer?.cancel(); + super.dispose(); + } +} diff --git a/display/lib/main.dart b/display/lib/main.dart index 5ea2930..0cdf2af 100644 --- a/display/lib/main.dart +++ b/display/lib/main.dart @@ -1,15 +1,27 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'data/autopilot_state.dart'; import 'theme/theme_provider.dart'; +import 'screens/cockpit/cockpit_screen.dart'; import 'screens/settings/appearance_settings.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); final themeProvider = await AutopilotThemeProvider.load(); runApp( - ChangeNotifierProvider.value( - value: themeProvider, + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: themeProvider, + ), + // AutopilotState drives the cockpit UI. + // Sprint 4: internal demo timer (vessel simulation). + // Sprint 7: replace with AutopilotStateModbus or wire Modbus RTU here. + ChangeNotifierProvider( + create: (_) => AutopilotState(), + ), + ], child: const ArAutopilotApp(), ), ); @@ -24,10 +36,11 @@ class ArAutopilotApp extends StatelessWidget { title: 'AR-Autopilot', debugShowCheckedModeBanner: false, theme: ThemeData(useMaterial3: true), - // Initial route — Sprint 4 starts with the Appearance screen for demo - initialRoute: AppearanceSettingsScreen.routeName, + initialRoute: CockpitScreen.routeName, routes: { - AppearanceSettingsScreen.routeName: (_) => const AppearanceSettingsScreen(), + CockpitScreen.routeName: (_) => const CockpitScreen(), + AppearanceSettingsScreen.routeName: (_) => + const AppearanceSettingsScreen(), }, ); } diff --git a/display/lib/screens/cockpit/cockpit_screen.dart b/display/lib/screens/cockpit/cockpit_screen.dart new file mode 100644 index 0000000..989ffef --- /dev/null +++ b/display/lib/screens/cockpit/cockpit_screen.dart @@ -0,0 +1,431 @@ +// ============================================================================= +// screens/cockpit/cockpit_screen.dart — AR-Autopilot main cockpit view +// ============================================================================= +// +// Sprint 4: static layout with demo data (AutopilotState simulation). +// Sprint 7: AutopilotState internals replaced by Modbus RTU over USB serial. +// +// Layout (top → bottom): +// TopBar — logo, title, NMEA/GPS status, settings gear +// ModeSelector — STANDBY | HDG HOLD | TRACK +// CompassRose — dominant visual; shows heading + setpoint tick +// DataStrip — SOG · COG · ROT · DEPTH +// HeadingAdjust — << < [SET 048°] > >> +// RudderRow — label + horizontal rudder indicator +// ActionRow — [ ENGAGE ] [ DISENGAGE ] +// ============================================================================= + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../../data/autopilot_state.dart'; +import '../../theme/autopilot_theme.dart'; +import '../../theme/theme_provider.dart'; +import '../../widgets/themed/compass_rose.dart'; +import '../../widgets/themed/disengage_button.dart'; +import '../../widgets/themed/engage_button.dart'; +import '../../widgets/themed/heading_adjust_bar.dart'; +import '../../widgets/themed/mode_selector.dart'; +import '../../widgets/themed/rudder_indicator.dart'; +import '../../widgets/themed/status_chip.dart'; +import '../settings/appearance_settings.dart'; + +class CockpitScreen extends StatelessWidget { + const CockpitScreen({super.key}); + + static const String routeName = '/'; + + @override + Widget build(BuildContext context) { + final theme = context.watch().current; + final ap = context.watch(); + + return Scaffold( + backgroundColor: theme.background, + body: AnimatedContainer( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + decoration: theme.backgroundDecoration, + child: SafeArea( + child: Column( + children: [ + _TopBar(theme: theme, ap: ap), + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: ModeSelector( + activeMode: ap.mode, + onModeSelected: ap.selectMode, + ), + ), + Expanded( + child: _CockpitBody(theme: theme, ap: ap), + ), + ], + ), + ), + ), + ); + } +} + +// ── Top bar ─────────────────────────────────────────────────────────────────── + +class _TopBar extends StatelessWidget { + const _TopBar({required this.theme, required this.ap}); + + final AutopilotTheme theme; + final AutopilotState ap; + + @override + Widget build(BuildContext context) { + return Container( + height: 54, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: theme.backgroundMid.withValues(alpha: 0.9), + border: Border( + bottom: BorderSide(color: theme.panelBorder, width: 0.5), + ), + ), + child: Row( + children: [ + // Logo — triple-tap cycles themes (design invariant from Sprint 3) + _ThemeCycleLogo(theme: theme), + const SizedBox(width: 10), + Text( + 'AR-AUTOPILOT', + style: TextStyle( + color: theme.textMain, + fontSize: 14, + fontWeight: FontWeight.w700, + letterSpacing: 1.8, + ), + ), + if (!ap.isConnected) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: theme.warnColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + border: Border.all( + color: theme.warnColor.withValues(alpha: 0.4), + ), + ), + child: Text( + 'DEMO', + style: TextStyle( + color: theme.warnColor, + fontSize: 9, + fontWeight: FontWeight.w700, + letterSpacing: 1, + ), + ), + ), + ], + const Spacer(), + StatusChip( + theme: theme, + label: 'NMEA', + status: ap.isConnected ? StatusLevel.ok : StatusLevel.off, + ), + const SizedBox(width: 14), + StatusChip( + theme: theme, + label: 'GPS', + status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn, + ), + const SizedBox(width: 16), + GestureDetector( + onTap: () => Navigator.pushNamed( + context, AppearanceSettingsScreen.routeName), + child: Icon( + Icons.settings_outlined, + color: theme.textMuted, + size: 22, + ), + ), + ], + ), + ); + } +} + +/// AR Electronics logo that cycles themes on triple-tap. +/// +/// Counts taps within 600 ms; three rapid taps rotate through the 4 themes. +/// This implements the shortcut described in [AppearanceSettingsScreen]. +class _ThemeCycleLogo extends StatefulWidget { + const _ThemeCycleLogo({required this.theme}); + + final AutopilotTheme theme; + + @override + State<_ThemeCycleLogo> createState() => _ThemeCycleLogoState(); +} + +class _ThemeCycleLogoState extends State<_ThemeCycleLogo> { + static const _kWindow = Duration(milliseconds: 600); + static const _kIds = ['light', 'cyan', 'wine', 'ochre']; + + int _taps = 0; + Timer? _resetTimer; + + void _onTap() { + _resetTimer?.cancel(); + _taps++; + if (_taps >= 3) { + _taps = 0; + final provider = context.read(); + final idx = _kIds.indexOf(provider.current.id); + provider.setTheme(_kIds[(idx + 1) % _kIds.length]); + } else { + _resetTimer = Timer(_kWindow, () => _taps = 0); + } + } + + @override + void dispose() { + _resetTimer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _onTap, + child: Image.asset( + 'assets/images/ar_logo_full.png', + height: 34, + errorBuilder: (_, __, ___) => Icon( + Icons.anchor, + color: widget.theme.accentMid, + size: 28, + ), + ), + ); + } +} + +// ── Cockpit body ────────────────────────────────────────────────────────────── + +class _CockpitBody extends StatelessWidget { + const _CockpitBody({required this.theme, required this.ap}); + + final AutopilotTheme theme; + final AutopilotState ap; + + bool get _engaged => ap.mode != AutopilotMode.standby; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + // Compass scales between 200 and 320 px based on available width. + final compassSize = + (constraints.maxWidth * 0.68).clamp(200.0, 320.0); + + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 16), + child: Column( + children: [ + // ── Compass rose ───────────────────────────────────────────── + Center( + child: CompassRose( + headingDeg: ap.headingDeg, + setPointDeg: _engaged ? ap.setpointDeg : null, + size: compassSize, + ), + ), + const SizedBox(height: 14), + + // ── Instrument data strip ──────────────────────────────────── + _DataStrip(theme: theme, ap: ap), + const SizedBox(height: 14), + + // ── Heading setpoint + adjust buttons ──────────────────────── + HeadingAdjustBar( + setpointDeg: ap.setpointDeg, + enabled: _engaged, + onAdjust: ap.adjustSetpoint, + ), + const SizedBox(height: 14), + + // ── Rudder indicator ───────────────────────────────────────── + _RudderRow(theme: theme, rudderDeg: ap.rudderDeg), + const SizedBox(height: 20), + + // ── ENGAGE / DISENGAGE row ─────────────────────────────────── + Row( + children: [ + Expanded( + child: EngageButton( + onPressed: !_engaged ? ap.engage : null, + enabled: !_engaged, + ), + ), + const SizedBox(width: 14), + Expanded( + child: DisengageButton( + onPressed: _engaged ? ap.disengage : null, + enabled: _engaged, + ), + ), + ], + ), + ], + ), + ); + }, + ); + } +} + +// ── Data strip ──────────────────────────────────────────────────────────────── + +class _DataStrip extends StatelessWidget { + const _DataStrip({required this.theme, required this.ap}); + + final AutopilotTheme theme; + final AutopilotState ap; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + gradient: theme.panelBackground, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.panelBorder), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _DataCell( + theme: theme, + label: 'SOG', + value: '${ap.sogKn.toStringAsFixed(1)} kn', + ), + _VerticalDivider(theme: theme), + _DataCell( + theme: theme, + label: 'COG', + value: '${ap.cogDeg.toStringAsFixed(0).padLeft(3, '0')}°', + ), + _VerticalDivider(theme: theme), + _DataCell( + theme: theme, + label: 'ROT', + value: '${ap.rotDpm.toStringAsFixed(1)}°/m', + ), + _VerticalDivider(theme: theme), + _DataCell( + theme: theme, + label: 'PROF', + value: '${ap.depthM.toStringAsFixed(1)} m', + ), + ], + ), + ); + } +} + +class _VerticalDivider extends StatelessWidget { + const _VerticalDivider({required this.theme}); + + final AutopilotTheme theme; + + @override + Widget build(BuildContext context) => + Container(width: 1, height: 30, color: theme.panelBorder); +} + +class _DataCell extends StatelessWidget { + const _DataCell({ + required this.theme, + required this.label, + required this.value, + }); + + final AutopilotTheme theme; + final String label; + final String value; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + label, + style: TextStyle( + color: theme.textMuted, + fontSize: 9, + letterSpacing: 1.2, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 3), + Text( + value, + style: TextStyle( + color: theme.textMain, + fontSize: 15, + fontWeight: FontWeight.w300, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ); + } +} + +// ── Rudder row ──────────────────────────────────────────────────────────────── + +class _RudderRow extends StatelessWidget { + const _RudderRow({required this.theme, required this.rudderDeg}); + + final AutopilotTheme theme; + final double rudderDeg; + + String _label(double deg) { + if (deg.abs() < 0.5) return 'CENTRO'; + final side = deg < 0 ? 'BABOR' : 'ESTRIBOR'; + return '${deg.abs().toStringAsFixed(1)}° $side'; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Text( + 'TIMÓN', + style: TextStyle( + color: theme.textMuted, + fontSize: 9, + letterSpacing: 1.2, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + _label(rudderDeg), + style: TextStyle( + color: theme.textSoft, + fontSize: 12, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ], + ), + const SizedBox(height: 6), + RudderIndicator(rudderDeg: rudderDeg), + ], + ); + } +} diff --git a/display/lib/widgets/themed/engage_button.dart b/display/lib/widgets/themed/engage_button.dart new file mode 100644 index 0000000..b704b11 --- /dev/null +++ b/display/lib/widgets/themed/engage_button.dart @@ -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 createState() => _EngageButtonState(); +} + +class _EngageButtonState extends State { + bool _pressed = false; + + @override + Widget build(BuildContext context) { + final theme = context.watch().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, + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/heading_adjust_bar.dart b/display/lib/widgets/themed/heading_adjust_bar.dart new file mode 100644 index 0000000..488ae7a --- /dev/null +++ b/display/lib/widgets/themed/heading_adjust_bar.dart @@ -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 onAdjust; + + @override + Widget build(BuildContext context) { + final theme = context.watch().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 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()], + ), + ), + ), + ); + } +} diff --git a/display/lib/widgets/themed/status_chip.dart b/display/lib/widgets/themed/status_chip.dart new file mode 100644 index 0000000..c697280 --- /dev/null +++ b/display/lib/widgets/themed/status_chip.dart @@ -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, + ), + ), + ], + ); + } +}