feat(display): Sprint 7 — USB serial link to AR-Concentrador via NMEA $PARP
- 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
This commit is contained in:
@@ -31,6 +31,7 @@ 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});
|
||||
@@ -138,14 +139,47 @@ class _TopBar extends StatelessWidget {
|
||||
status: ap.isConnected ? StatusLevel.ok : StatusLevel.warn,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.pushNamed(
|
||||
context, AppearanceSettingsScreen.routeName),
|
||||
child: Icon(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
// =============================================================================
|
||||
// screens/settings/port_settings_screen.dart — COM port configuration
|
||||
// =============================================================================
|
||||
//
|
||||
// Lets the operator choose which COM port is the concentrador RX-OUT
|
||||
// (the one the display reads from) and which is the TX-IN
|
||||
// (the one the display writes commands to).
|
||||
//
|
||||
// Ports are persisted in SharedPreferences and auto-applied at startup.
|
||||
//
|
||||
// Layout:
|
||||
// • Two dropdowns — RX port, TX port — populated from available COM ports
|
||||
// • [Conectar] button — tries to open both ports; shows error if it fails
|
||||
// • [Desconectar] button — drops to demo mode
|
||||
// • Status row — green ● CONECTADO / grey ● DEMO
|
||||
// =============================================================================
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../data/autopilot_state.dart';
|
||||
import '../../theme/autopilot_theme.dart';
|
||||
import '../../theme/theme_provider.dart';
|
||||
|
||||
class PortSettingsScreen extends StatefulWidget {
|
||||
const PortSettingsScreen({super.key});
|
||||
|
||||
static const String routeName = '/settings/ports';
|
||||
|
||||
@override
|
||||
State<PortSettingsScreen> createState() => _PortSettingsScreenState();
|
||||
}
|
||||
|
||||
class _PortSettingsScreenState extends State<PortSettingsScreen> {
|
||||
static const _kRxKey = 'port.rx';
|
||||
static const _kTxKey = 'port.tx';
|
||||
|
||||
List<String> _ports = [];
|
||||
String? _rxPort;
|
||||
String? _txPort;
|
||||
bool _connecting = false;
|
||||
String? _errorMsg;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadPorts();
|
||||
}
|
||||
|
||||
Future<void> _loadPorts() async {
|
||||
final available = AutopilotState.availablePorts();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
setState(() {
|
||||
_ports = available;
|
||||
_rxPort = prefs.getString(_kRxKey);
|
||||
_txPort = prefs.getString(_kTxKey);
|
||||
// If saved port no longer exists, clear it.
|
||||
if (_rxPort != null && !_ports.contains(_rxPort)) _rxPort = null;
|
||||
if (_txPort != null && !_ports.contains(_txPort)) _txPort = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _connect() async {
|
||||
if (_rxPort == null || _txPort == null) return;
|
||||
setState(() {
|
||||
_connecting = true;
|
||||
_errorMsg = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final ap = context.read<AutopilotState>();
|
||||
await ap.connectToSerial(rxPort: _rxPort!, txPort: _txPort!);
|
||||
|
||||
// Persist the working configuration
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kRxKey, _rxPort!);
|
||||
await prefs.setString(_kTxKey, _txPort!);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Conectado — RX: $_rxPort TX: $_txPort'),
|
||||
backgroundColor: Colors.green.shade700,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _errorMsg = 'Error al abrir puertos: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _connecting = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnect() async {
|
||||
final ap = context.read<AutopilotState>();
|
||||
await ap.disconnectSerial();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Desconectado — modo demo activo'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = context.watch<AutopilotThemeProvider>().current;
|
||||
final ap = context.watch<AutopilotState>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: theme.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: theme.backgroundMid,
|
||||
foregroundColor: theme.textMain,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
'Conexión al Concentrador',
|
||||
style: TextStyle(color: theme.textMain, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
body: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
decoration: theme.backgroundDecoration,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
children: [
|
||||
// ── Status ────────────────────────────────────────────────────────
|
||||
_StatusRow(theme: theme, connected: ap.isConnected),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Port selection ────────────────────────────────────────────────
|
||||
_SectionLabel(label: 'Puerto RX (leer datos del concentrador)', theme: theme),
|
||||
const SizedBox(height: 8),
|
||||
_PortDropdown(
|
||||
theme: theme,
|
||||
value: _rxPort,
|
||||
ports: _ports,
|
||||
onChanged: (v) => setState(() => _rxPort = v),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_SectionLabel(label: 'Puerto TX (enviar comandos al concentrador)', theme: theme),
|
||||
const SizedBox(height: 8),
|
||||
_PortDropdown(
|
||||
theme: theme,
|
||||
value: _txPort,
|
||||
ports: _ports,
|
||||
onChanged: (v) => setState(() => _txPort = v),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Note
|
||||
Text(
|
||||
'Conectar el cable USB-OUT del concentrador al puerto RX,\n'
|
||||
'y el USB-IN al puerto TX. Ambos a 115 200 baud, 8N1.',
|
||||
style: TextStyle(color: theme.textMuted, fontSize: 11),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Error message ─────────────────────────────────────────────────
|
||||
if (_errorMsg != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.disengageBackground.colors.first.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.disengageBorder.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Text(
|
||||
_errorMsg!,
|
||||
style: TextStyle(color: theme.disengageText, fontSize: 12),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
|
||||
// ── Buttons ───────────────────────────────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ActionButton(
|
||||
theme: theme,
|
||||
label: _connecting ? 'Conectando…' : 'Conectar',
|
||||
enabled: !_connecting && _rxPort != null && _txPort != null,
|
||||
primary: true,
|
||||
onPressed: _connect,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _ActionButton(
|
||||
theme: theme,
|
||||
label: 'Desconectar',
|
||||
enabled: ap.isConnected && !_connecting,
|
||||
primary: false,
|
||||
onPressed: _disconnect,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Refresh ports ─────────────────────────────────────────────────
|
||||
TextButton(
|
||||
onPressed: _loadPorts,
|
||||
child: Text(
|
||||
'Actualizar lista de puertos',
|
||||
style: TextStyle(color: theme.textMuted, fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Private widgets ───────────────────────────────────────────────────────────
|
||||
|
||||
class _StatusRow extends StatelessWidget {
|
||||
const _StatusRow({required this.theme, required this.connected});
|
||||
final AutopilotTheme theme;
|
||||
final bool connected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = connected ? theme.okColor : theme.textMuted;
|
||||
final label = connected ? 'CONECTADO AL CONCENTRADOR' : 'MODO DEMO (sin conexión)';
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
boxShadow: connected
|
||||
? [BoxShadow(color: color.withValues(alpha: 0.5), blurRadius: 8)]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.8,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
const _SectionLabel({required this.label, required this.theme});
|
||||
final String label;
|
||||
final AutopilotTheme theme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(
|
||||
label.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: theme.textMuted,
|
||||
fontSize: 10,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _PortDropdown extends StatelessWidget {
|
||||
const _PortDropdown({
|
||||
required this.theme,
|
||||
required this.value,
|
||||
required this.ports,
|
||||
required this.onChanged,
|
||||
});
|
||||
final AutopilotTheme theme;
|
||||
final String? value;
|
||||
final List<String> ports;
|
||||
final ValueChanged<String?> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: theme.panelBackground,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: theme.panelBorder),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: ports.contains(value) ? value : null,
|
||||
hint: Text(
|
||||
ports.isEmpty ? 'Sin puertos disponibles' : 'Seleccionar puerto…',
|
||||
style: TextStyle(color: theme.textMuted, fontSize: 13),
|
||||
),
|
||||
dropdownColor: theme.backgroundMid,
|
||||
style: TextStyle(color: theme.textMain, fontSize: 13),
|
||||
icon: Icon(Icons.expand_more, color: theme.textMuted),
|
||||
isExpanded: true,
|
||||
items: ports
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p)))
|
||||
.toList(),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionButton extends StatelessWidget {
|
||||
const _ActionButton({
|
||||
required this.theme,
|
||||
required this.label,
|
||||
required this.enabled,
|
||||
required this.primary,
|
||||
required this.onPressed,
|
||||
});
|
||||
final AutopilotTheme theme;
|
||||
final String label;
|
||||
final bool enabled;
|
||||
final bool primary;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = primary ? theme.okColor : theme.accentMid;
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? color.withValues(alpha: 0.15)
|
||||
: theme.backgroundDeep,
|
||||
borderRadius: BorderRadius.circular(7),
|
||||
border: Border.all(
|
||||
color: enabled ? color : theme.panelBorder,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: enabled ? color : theme.textDisabled,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user