Files
AR-Autopilot/display/lib/screens/cockpit/cockpit_screen.dart
T
alro65 c946d2df6d 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
2026-05-23 11:53:50 -04:00

432 lines
13 KiB
Dart

// =============================================================================
// 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<AutopilotThemeProvider>().current;
final ap = context.watch<AutopilotState>();
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<AutopilotThemeProvider>();
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),
],
);
}
}