abe9b764c7
- Add flutter_libserialport dependency (pubspec.yaml) - New ParpCodec: XOR-checksum NMEA parser + command builders for all PARP sentences - New ConcentradorService: manages two independent COM ports (RX-OUT broadcast, TX-IN commands) at 115200/8N1; auto-fires onConnectionChanged on link drop - AutopilotState: dual-mode operation (demo timer OR live serial); connectToSerial / disconnectSerial; command methods (engage/disengage/adjustSetpoint) forward to ConcentradorService when connected; falls back to demo on disconnect - New PortSettingsScreen (/settings/ports): RX+TX dropdowns populated from SerialPort.availablePorts, persisted in SharedPreferences; Connect/Disconnect buttons with error display and snackbar feedback - main.dart: auto-connect to saved ports on startup (silent fail → demo mode); registers /settings/ports route - CockpitScreen: gear icon replaced with PopupMenuButton (Puertos COM / Apariencia) AR_electronics — AR-Autopilot Project
466 lines
15 KiB
Dart
466 lines
15 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';
|
|
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<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),
|
|
PopupMenuButton<String>(
|
|
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<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),
|
|
],
|
|
);
|
|
}
|
|
}
|