Files
kuaishai2/lib/features/settings/widgets/serial_config_panel.dart
Developer 3d849bd468 feat(i18n): 完成全量 UI 文本国际化,替换所有硬编码中文为 AppLocalizations 调用
- core/localization: 新增约 60 个翻译键(含参数化方法),中英双语覆盖
- shared/widgets: CommonDialog 默认参数国际化
- features/home: 完成页操作步骤指引、状态栏串口连接状态、程序列表状态标签
- features/programs: 表头状态列、表单验证提示、导入/模板操作反馈、删除确认(参数化)
- features/program_detail: 步骤列表/表单标题、删除确认、速度档位显示(参数化)
- features/device: run_state_provider 错误消息改为错误码
- features/settings: 升级页、密码面板、语言面板、U盘导入面板、串口配置面板全部替换

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-12 15:09:47 +08:00

503 lines
17 KiB
Dart

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<SerialConfigPanel> createState() => _SerialConfigPanelState();
}
class _SerialConfigPanelState extends ConsumerState<SerialConfigPanel> {
List<UsbDevice> _devices = const [];
UsbDevice? _selectedDevice;
bool _loadingDevices = false;
bool _operating = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _refreshDevices());
}
Future<void> _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<void> _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<void> _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<void> _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 <String, dynamic>{},
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<int>(
label: l10n?.dataBits ?? '数据位',
value: config.dataBits,
options: const [5, 6, 7, 8],
display: (v) => '$v',
onChanged: (v) => _updateConfig((c) => c.copyWith(dataBits: v)),
),
_dropdownRow<int>(
label: l10n?.stopBits ?? '停止位',
value: config.stopBits,
options: const [1, 2],
display: (v) => '$v',
onChanged: (v) => _updateConfig((c) => c.copyWith(stopBits: v)),
),
_dropdownRow<SerialParity>(
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<SerialFlowControl>(
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<T>({
required String label,
required T value,
required List<T> options,
required String Function(T) display,
required ValueChanged<T> 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<T>(
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<void> _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,
],
),
);
}
}