Files
AR-Autopilot/display/lib/screens/settings/port_settings_screen.dart
T
alro65 abe9b764c7 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
2026-05-24 01:28:04 -04:00

368 lines
12 KiB
Dart

// =============================================================================
// 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,
),
),
),
);
}
}