import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:usb_serial/usb_serial.dart'; import '../../../core/localization/app_localizations.dart'; import '../../../core/theme/app_theme.dart'; import '../../device/models/serial_config.dart'; import '../../device/providers/serial_provider.dart'; import '../../device/services/device_message.dart'; import '../../device/services/serial_port_service.dart'; /// 串口配置面板 /// /// 展示当前连接状态、可用设备列表、串口参数,并提供 /// 「刷新设备 / 连接 / 断开 / 测试」等操作。 class SerialConfigPanel extends ConsumerStatefulWidget { const SerialConfigPanel({super.key}); @override ConsumerState createState() => _SerialConfigPanelState(); } class _SerialConfigPanelState extends ConsumerState { List _devices = const []; UsbDevice? _selectedDevice; bool _loadingDevices = false; bool _operating = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _refreshDevices()); } Future _refreshDevices() async { setState(() => _loadingDevices = true); try { final service = ref.read(serialPortServiceProvider); final list = await service.listDevices(); if (!mounted) return; setState(() { _devices = list; _loadingDevices = false; if (_selectedDevice == null && list.isNotEmpty) { _selectedDevice = list.first; } }); } catch (e) { if (!mounted) return; setState(() => _loadingDevices = false); _showSnack('${AppLocalizations.of(context)?.scanFailed ?? '扫描设备失败'}: $e', AppTheme.errorColor); } } Future _connect() async { final device = _selectedDevice; if (device == null) { _showSnack(AppLocalizations.of(context)?.selectSerialFirst ?? '请先选择串口设备', AppTheme.warningColor); return; } setState(() => _operating = true); final service = ref.read(serialPortServiceProvider); final config = ref.read(serialConfigProvider); final ok = await service.connect(device, config); if (!mounted) return; setState(() => _operating = false); _showSnack( ok ? (AppLocalizations.of(context)?.connectSuccess ?? '连接成功') : ('${AppLocalizations.of(context)?.connectFailed ?? '连接失败'}: ${service.lastError ?? (AppLocalizations.of(context)?.unknownError ?? '未知错误')}'), ok ? AppTheme.successColor : AppTheme.errorColor, ); } Future _disconnect() async { setState(() => _operating = true); final service = ref.read(serialPortServiceProvider); await service.disconnect(); if (!mounted) return; setState(() => _operating = false); _showSnack(AppLocalizations.of(context)?.disconnected ?? '已断开串口', AppTheme.infoColor); } Future _testConnection() async { final service = ref.read(serialPortServiceProvider); if (!service.isConnected) { _showSnack(AppLocalizations.of(context)?.connectFirst ?? '请先连接串口', AppTheme.warningColor); return; } final msgService = ref.read(deviceMessageServiceProvider); final ok = await msgService.send(DeviceMessage.request( messageId: msgService.nextId(), type: DeviceMessageType.deviceInfo, data: const {}, needAck: true, )); if (!mounted) return; _showSnack( ok ? (AppLocalizations.of(context)?.sendTestFrame ?? '已发送测试查询') : (AppLocalizations.of(context)?.connectFailed ?? '发送失败'), ok ? AppTheme.successColor : AppTheme.errorColor, ); } void _showSnack(String msg, Color color) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(msg), backgroundColor: color), ); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final config = ref.watch(serialConfigProvider); final state = ref.watch(serialPortServiceProvider).state; return ListView( padding: const EdgeInsets.all(0), children: [ _buildStatusCard(state, l10n), const SizedBox(height: 16), _buildDeviceCard(l10n), const SizedBox(height: 16), _buildParamCard(config, l10n), const SizedBox(height: 16), _buildActionRow(l10n), ], ); } // -- 状态卡 ---------------------------------------------------------- Widget _buildStatusCard(SerialConnectionState state, AppLocalizations? l10n) { final color = switch (state) { SerialConnectionState.connected => AppTheme.successColor, SerialConnectionState.connecting => AppTheme.warningColor, SerialConnectionState.error => AppTheme.errorColor, SerialConnectionState.disconnected => AppTheme.idleColor, }; final text = switch (state) { SerialConnectionState.connected => l10n?.serialConnected ?? '已连接', SerialConnectionState.connecting => l10n?.serialConnecting ?? '连接中...', SerialConnectionState.error => l10n?.serialError ?? '错误', SerialConnectionState.disconnected => l10n?.serialDisconnected ?? '未连接', }; return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTheme.backgroundColor, borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withValues(alpha: 0.4)), ), child: Row( children: [ Container( width: 12, height: 12, decoration: BoxDecoration(color: color, shape: BoxShape.circle), ), const SizedBox(width: 12), Text('${l10n?.serialStatus ?? '串口状态'}: $text', style: TextStyle( color: AppTheme.textPrimary, fontSize: 16, fontWeight: FontWeight.w600, )), ], ), ); } // -- 设备列表 -------------------------------------------------------- Widget _buildDeviceCard(AppLocalizations? l10n) { return _SectionCard( title: l10n?.availableSerialDevices ?? '可用串口设备', trailing: TextButton.icon( onPressed: _loadingDevices ? null : _refreshDevices, icon: const Icon(Icons.refresh, size: 18), label: Text(l10n?.refresh ?? '刷新'), ), child: _loadingDevices ? const Padding( padding: EdgeInsets.all(24), child: Center(child: CircularProgressIndicator()), ) : _devices.isEmpty ? _buildEmptyDevice() : Column( children: _devices .map((d) => _deviceTile(d)) .toList(growable: false), ), ); } Widget _buildEmptyDevice() { final l10n = AppLocalizations.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 8), child: Column( children: [ Icon(Icons.usb_off, size: 40, color: AppTheme.idleColor), const SizedBox(height: 8), Text(l10n?.noSerialDevice ?? '未检测到 USB 串口设备', style: TextStyle(color: AppTheme.textSecondary)), const SizedBox(height: 4), Text(l10n?.serialDeviceHint ?? '请确认下位机已上电并通过 USB 接入设备', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12)), ], ), ); } Widget _deviceTile(UsbDevice d) { final selected = _selectedDevice == d; return InkWell( onTap: () => setState(() => _selectedDevice = d), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( color: selected ? AppTheme.primaryColor.withValues(alpha: 0.08) : Colors.transparent, border: Border( bottom: BorderSide(color: AppTheme.borderSubtle, width: 0.5), ), ), child: Row( children: [ Icon( selected ? Icons.radio_button_checked : Icons.radio_button_off, color: selected ? AppTheme.primaryColor : AppTheme.idleColor, size: 20, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(SerialPortService.deviceLabel(d), style: TextStyle( color: AppTheme.textPrimary, fontWeight: FontWeight.w500, )), const SizedBox(height: 2), Text( 'VID: 0x${(d.vid ?? 0).toRadixString(16).toUpperCase()} ' 'PID: 0x${(d.pid ?? 0).toRadixString(16).toUpperCase()}', style: TextStyle( color: AppTheme.textSecondary, fontSize: 12), ), ], ), ), ], ), ), ); } // -- 参数配置 -------------------------------------------------------- Widget _buildParamCard(SerialConfig config, AppLocalizations? l10n) { return _SectionCard( title: l10n?.serialParams ?? '串口参数', child: Column( children: [ _baudRateRow(config, l10n), _dropdownRow( label: l10n?.dataBits ?? '数据位', value: config.dataBits, options: const [5, 6, 7, 8], display: (v) => '$v', onChanged: (v) => _updateConfig((c) => c.copyWith(dataBits: v)), ), _dropdownRow( label: l10n?.stopBits ?? '停止位', value: config.stopBits, options: const [1, 2], display: (v) => '$v', onChanged: (v) => _updateConfig((c) => c.copyWith(stopBits: v)), ), _dropdownRow( label: l10n?.parity ?? '校验位', value: config.parity, options: SerialParity.values, display: (v) => switch (v) { SerialParity.none => l10n?.parityNone ?? '无', SerialParity.odd => l10n?.parityOdd ?? '奇', SerialParity.even => l10n?.parityEven ?? '偶', SerialParity.mark => l10n?.parityMark ?? '标记', SerialParity.space => l10n?.paritySpace ?? '空', }, onChanged: (v) => _updateConfig((c) => c.copyWith(parity: v)), ), _dropdownRow( label: l10n?.flowControl ?? '流控', value: config.flowControl, options: SerialFlowControl.values, display: (v) => switch (v) { SerialFlowControl.none => l10n?.parityNone ?? '无', SerialFlowControl.rtsCts => 'RTS/CTS', SerialFlowControl.xonXoff => 'XON/XOFF', SerialFlowControl.dtrDsr => 'DTR/DSR', }, onChanged: (v) => _updateConfig((c) => c.copyWith(flowControl: v)), ), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: Text( l10n?.autoSaveParams ?? '参数修改后自动保存', style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), ), ), ], ), ); } Widget _baudRateRow(SerialConfig config, AppLocalizations? l10n) { final ctrl = TextEditingController(text: config.baudRate.toString()); return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ SizedBox( width: 80, child: Text(l10n?.baudRate ?? '波特率', style: TextStyle(color: AppTheme.textPrimary))), const SizedBox(width: 12), SizedBox( width: 140, child: TextField( controller: ctrl, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(), ), onSubmitted: (v) => _applyBaudRate(v, config), onEditingComplete: () => _applyBaudRate(ctrl.text, config), ), ), const SizedBox(width: 12), Expanded( child: Wrap( spacing: 6, runSpacing: 6, children: SerialConfig.commonBaudRates .map((b) => _baudChip(b, config.baudRate)) .toList(), ), ), ], ), ); } Widget _baudChip(int baud, int current) { final selected = baud == current; return ChoiceChip( label: Text('$baud'), selected: selected, onSelected: (_) => _updateConfig((c) => c.copyWith(baudRate: baud)), labelStyle: TextStyle( color: selected ? Colors.white : AppTheme.textPrimary, fontSize: 12, ), selectedColor: AppTheme.primaryColor, backgroundColor: AppTheme.backgroundColor, ); } Widget _dropdownRow({ required String label, required T value, required List options, required String Function(T) display, required ValueChanged onChanged, }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ SizedBox( width: 80, child: Text(label, style: TextStyle(color: AppTheme.textPrimary))), const SizedBox(width: 12), Expanded( child: DropdownButtonFormField( value: value, isDense: true, decoration: const InputDecoration( isDense: true, contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(), ), items: options .map((o) => DropdownMenuItem( value: o, child: Text(display(o)), )) .toList(), onChanged: (v) { if (v != null) onChanged(v); }, ), ), ], ), ); } void _applyBaudRate(String raw, SerialConfig config) { final v = int.tryParse(raw.trim()); if (v == null || v <= 0) return; if (v == config.baudRate) return; _updateConfig((c) => c.copyWith(baudRate: v)); } Future _updateConfig( SerialConfig Function(SerialConfig) mutator) async { await ref.read(serialConfigProvider.notifier).update(mutator); } // -- 操作按钮 -------------------------------------------------------- Widget _buildActionRow(AppLocalizations? l10n) { return Wrap( spacing: 12, runSpacing: 12, children: [ ElevatedButton.icon( onPressed: _operating ? null : _connect, icon: const Icon(Icons.link), label: Text(l10n?.connect ?? '连接'), ), ElevatedButton.icon( onPressed: _operating ? null : _disconnect, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.errorColor), icon: const Icon(Icons.link_off), label: Text(l10n?.disconnect ?? '断开'), ), OutlinedButton.icon( onPressed: _operating ? null : _testConnection, icon: const Icon(Icons.send), label: Text(l10n?.sendTestFrame ?? '发送测试帧'), ), ], ); } } class _SectionCard extends StatelessWidget { final String title; final Widget? trailing; final Widget child; const _SectionCard({ required this.title, required this.child, this.trailing, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), border: Border.all(color: AppTheme.borderSubtle), ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ Expanded( child: Text( title, style: TextStyle( color: AppTheme.textPrimary, fontSize: 16, fontWeight: FontWeight.w600, ), ), ), ?trailing, ], ), const Divider(), child, ], ), ); } }