// ============================================================================= // 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'; import '../settings/port_settings_screen.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), PopupMenuButton( icon: Icon( Icons.settings_outlined, color: theme.textMuted, size: 22, ), color: theme.backgroundMid, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), side: BorderSide(color: theme.panelBorder), ), onSelected: (route) => Navigator.pushNamed(context, route), itemBuilder: (_) => [ PopupMenuItem( value: PortSettingsScreen.routeName, child: Row( children: [ Icon(Icons.usb, color: theme.accentMid, size: 18), const SizedBox(width: 10), Text( 'Puertos COM', style: TextStyle(color: theme.textMain, fontSize: 13), ), ], ), ), PopupMenuItem( value: AppearanceSettingsScreen.routeName, child: Row( children: [ Icon(Icons.palette_outlined, color: theme.accentMid, size: 18), const SizedBox(width: 10), Text( 'Apariencia', style: TextStyle(color: theme.textMain, fontSize: 13), ), ], ), ), ], ), ], ), ); } } /// 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), ], ); } }