feat(home): 更新完成页面UI并优化串口连接状态管理
- 更新设备屏幕尺寸配置从1920*1080调整为1024x600 - 添加完成页面的AppBar导航和返回功能 - 重构CompletePage布局,使用SafeArea和ConstrainedBox适配不同屏幕 - 添加国际化支持的完成按钮文本 - 优化完成页面视觉元素,包括图标大小和间距调整 - 实现串口连接状态的响应式管理,解决UI状态同步问题 - 优化串口运行器的状态更新逻辑,实现乐观更新机制 - 调整完成页面按钮布局,提供完成和重新运行选项
This commit is contained in:
@@ -163,4 +163,4 @@ AppLocalizations 支持中文(zh)和英文(en),使用 `AppLocalizations.of(con
|
|||||||
## 其他说明
|
## 其他说明
|
||||||
|
|
||||||
1. 需求文档:[已确认-污水毒品快检一体机_功能需求文档.md](docs/%E5%B7%B2%E7%A1%AE%E8%AE%A4-%E6%B1%A1%E6%B0%B4%E6%AF%92%E5%93%81%E5%BF%AB%E6%A3%80%E4%B8%80%E4%BD%93%E6%9C%BA_%E5%8A%9F%E8%83%BD%E9%9C%80%E6%B1%82%E6%96%87%E6%A1%A3.md)
|
1. 需求文档:[已确认-污水毒品快检一体机_功能需求文档.md](docs/%E5%B7%B2%E7%A1%AE%E8%AE%A4-%E6%B1%A1%E6%B0%B4%E6%AF%92%E5%93%81%E5%BF%AB%E6%A3%80%E4%B8%80%E4%BD%93%E6%9C%BA_%E5%8A%9F%E8%83%BD%E9%9C%80%E6%B1%82%E6%96%87%E6%A1%A3.md)
|
||||||
2. 运行设备屏幕尺寸为1920*1080(横屏),UI设计必须支持此尺寸
|
2. 运行设备屏幕尺寸为1024x600(横屏),屏幕密度为200,UI设计必须支持此尺寸
|
||||||
@@ -93,6 +93,7 @@ class AppLocalizations {
|
|||||||
// 完成提示
|
// 完成提示
|
||||||
String get runComplete => _localizedValues[locale.languageCode]?['runComplete'] ?? '运行完成';
|
String get runComplete => _localizedValues[locale.languageCode]?['runComplete'] ?? '运行完成';
|
||||||
String get sampleDropGuide => _localizedValues[locale.languageCode]?['sampleDropGuide'] ?? '请将样本滴入检测卡';
|
String get sampleDropGuide => _localizedValues[locale.languageCode]?['sampleDropGuide'] ?? '请将样本滴入检测卡';
|
||||||
|
String get complete => _localizedValues[locale.languageCode]?['complete'] ?? '完成';
|
||||||
|
|
||||||
// 补充缺失的翻译
|
// 补充缺失的翻译
|
||||||
String get lightOn => _localizedValues[locale.languageCode]?['lightOn'] ?? '亮';
|
String get lightOn => _localizedValues[locale.languageCode]?['lightOn'] ?? '亮';
|
||||||
@@ -198,6 +199,7 @@ class AppLocalizations {
|
|||||||
'noData': '暂无数据',
|
'noData': '暂无数据',
|
||||||
'runComplete': '运行完成',
|
'runComplete': '运行完成',
|
||||||
'sampleDropGuide': '请将样本滴入检测卡',
|
'sampleDropGuide': '请将样本滴入检测卡',
|
||||||
|
'complete': '完成',
|
||||||
'lightOn': '亮',
|
'lightOn': '亮',
|
||||||
'lightOff': '暗',
|
'lightOff': '暗',
|
||||||
'enabled': '启用',
|
'enabled': '启用',
|
||||||
@@ -300,6 +302,7 @@ class AppLocalizations {
|
|||||||
'noData': 'No Data',
|
'noData': 'No Data',
|
||||||
'runComplete': 'Complete',
|
'runComplete': 'Complete',
|
||||||
'sampleDropGuide': 'Drop sample to test card',
|
'sampleDropGuide': 'Drop sample to test card',
|
||||||
|
'complete': 'Done',
|
||||||
'lightOn': 'On',
|
'lightOn': 'On',
|
||||||
'lightOff': 'Off',
|
'lightOff': 'Off',
|
||||||
'enabled': 'Enabled',
|
'enabled': 'Enabled',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../../core/database/database_service.dart';
|
import '../../../core/database/database_service.dart';
|
||||||
@@ -16,6 +18,34 @@ final serialPortServiceProvider = Provider<SerialPortService>((ref) {
|
|||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 串口连接状态(响应式)
|
||||||
|
///
|
||||||
|
/// 直接 `ref.watch(serialPortServiceProvider).state` 不会触发 UI 重建,
|
||||||
|
/// 因为 [SerialPortService] 内部 `_state` 的变化不会冒泡到 Provider 层。
|
||||||
|
/// 这里把状态抽出为独立的 StateNotifierProvider,让状态栏等 UI 能即时
|
||||||
|
/// 反映连接/断开事件,避免出现"标题栏显示已连接、实际下发失败"的错觉。
|
||||||
|
class SerialConnectionStateNotifier
|
||||||
|
extends StateNotifier<SerialConnectionState> {
|
||||||
|
SerialConnectionStateNotifier(this._service) : super(_service.state) {
|
||||||
|
_sub = _service.connectionStateChanges.listen((s) => state = s);
|
||||||
|
}
|
||||||
|
|
||||||
|
final SerialPortService _service;
|
||||||
|
late final StreamSubscription<SerialConnectionState> _sub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sub.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final serialConnectionStateProvider = StateNotifierProvider<
|
||||||
|
SerialConnectionStateNotifier, SerialConnectionState>((ref) {
|
||||||
|
final service = ref.watch(serialPortServiceProvider);
|
||||||
|
return SerialConnectionStateNotifier(service);
|
||||||
|
});
|
||||||
|
|
||||||
/// 启动自动连接服务
|
/// 启动自动连接服务
|
||||||
///
|
///
|
||||||
/// 通过 [main] 中的 ProviderContainer 在 runApp 之前触发一次,
|
/// 通过 [main] 中的 ProviderContainer 在 runApp 之前触发一次,
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ class JsonSerialRunner implements Runner {
|
|||||||
final messageId = _msg.nextId();
|
final messageId = _msg.nextId();
|
||||||
_pendingCreateTaskId = messageId;
|
_pendingCreateTaskId = messageId;
|
||||||
final msg = payload.toMessage(messageId, needAck: true);
|
final msg = payload.toMessage(messageId, needAck: true);
|
||||||
|
// 乐观更新:与 RunStateNotifier 的运行态保持一致,
|
||||||
|
// 避免在 create_task 应答到达前的窗口里,pause/stop 被状态守卫静默丢弃。
|
||||||
|
status = RunnerStatus.running;
|
||||||
DeviceLog.info('Runner.start: program="${program.name}" steps=${steps.length} '
|
DeviceLog.info('Runner.start: program="${program.name}" steps=${steps.length} '
|
||||||
'temperature=${program.temperature} airflow=${program.airflowTime}');
|
'temperature=${program.temperature} airflow=${program.airflowTime}');
|
||||||
_msg.send(msg).then((ok) {
|
_msg.send(msg).then((ok) {
|
||||||
@@ -132,9 +135,9 @@ class JsonSerialRunner implements Runner {
|
|||||||
if (ack.ack != _pendingCreateTaskId) return;
|
if (ack.ack != _pendingCreateTaskId) return;
|
||||||
_pendingCreateTaskId = null;
|
_pendingCreateTaskId = null;
|
||||||
DeviceLog.info('Runner received create_task ack: id=${ack.messageId}');
|
DeviceLog.info('Runner received create_task ack: id=${ack.messageId}');
|
||||||
// ack 即视为下位机已接受任务,进入 running 状态
|
// 状态已由 start() 乐观置为 running,此处仅启动本地兜底倒计时。
|
||||||
if (status == RunnerStatus.idle || status == RunnerStatus.error) {
|
// 若发送失败,.then() 已将 status 置为 error,不应再启动倒计时。
|
||||||
status = RunnerStatus.running;
|
if (status == RunnerStatus.running) {
|
||||||
_startLocalTicker();
|
_startLocalTicker();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import '../../../shared/widgets/common_button.dart';
|
|||||||
import '../../device/providers/run_state_provider.dart';
|
import '../../device/providers/run_state_provider.dart';
|
||||||
|
|
||||||
/// 运行完成提示页面
|
/// 运行完成提示页面
|
||||||
|
///
|
||||||
|
/// 页面提供两种返回设备控制页的方式:
|
||||||
|
/// 1. 顶部 AppBar 的返回箭头
|
||||||
|
/// 2. 卡片底部的「完成」主按钮
|
||||||
|
///
|
||||||
|
/// 内部使用 [SingleChildScrollView] 包裹,避免在父级高度受限时按钮被裁切。
|
||||||
class CompletePage extends ConsumerWidget {
|
class CompletePage extends ConsumerWidget {
|
||||||
const CompletePage({super.key});
|
const CompletePage({super.key});
|
||||||
|
|
||||||
@@ -16,115 +22,143 @@ class CompletePage extends ConsumerWidget {
|
|||||||
final runState = ref.watch(runStateProvider);
|
final runState = ref.watch(runStateProvider);
|
||||||
final runNotifier = ref.read(runStateProvider.notifier);
|
final runNotifier = ref.read(runStateProvider.notifier);
|
||||||
|
|
||||||
|
/// 完成并返回设备控制页
|
||||||
|
void finishAndGoHome() {
|
||||||
|
runNotifier.reset();
|
||||||
|
context.go('/');
|
||||||
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
backgroundColor: AppTheme.backgroundColor,
|
||||||
color: AppTheme.backgroundColor,
|
appBar: AppBar(
|
||||||
|
backgroundColor: AppTheme.backgroundColor,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
tooltip: l10n?.complete ?? '完成',
|
||||||
|
onPressed: finishAndGoHome,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
l10n?.runComplete ?? '运行完成',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Container(
|
child: ConstrainedBox(
|
||||||
width: 600,
|
constraints: const BoxConstraints(maxWidth: 600),
|
||||||
padding: const EdgeInsets.all(40),
|
child: SingleChildScrollView(
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
color: Colors.white,
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(16),
|
padding: const EdgeInsets.all(32),
|
||||||
boxShadow: [
|
decoration: BoxDecoration(
|
||||||
BoxShadow(
|
color: Colors.white,
|
||||||
color: Colors.black.withValues(alpha: 0.15),
|
borderRadius: BorderRadius.circular(16),
|
||||||
blurRadius: 20,
|
boxShadow: [
|
||||||
offset: const Offset(0, 10),
|
BoxShadow(
|
||||||
),
|
color: Colors.black.withValues(alpha: 0.15),
|
||||||
],
|
blurRadius: 20,
|
||||||
),
|
offset: const Offset(0, 10),
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
// 成功图标
|
|
||||||
Container(
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.successColor.withValues(alpha: 0.1),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.check_circle,
|
|
||||||
size: 60,
|
|
||||||
color: AppTheme.successColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// 标题
|
|
||||||
Text(
|
|
||||||
l10n?.runComplete ?? '程序运行完成',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.textPrimary,
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 提示信息
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 24,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.warningColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
l10n?.sampleDropGuide ?? '请将样本滴入检测卡',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppTheme.warningColor,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// 操作示意图
|
|
||||||
_buildOperationGuide(),
|
|
||||||
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
|
|
||||||
// 按钮区域
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// 返回首页按钮
|
|
||||||
CommonButton(
|
|
||||||
text: l10n?.backToHome ?? '返回首页',
|
|
||||||
icon: Icons.home,
|
|
||||||
type: ButtonType.primary,
|
|
||||||
onPressed: () {
|
|
||||||
runNotifier.reset();
|
|
||||||
context.go('/');
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 24),
|
|
||||||
|
|
||||||
// 重新运行按钮
|
|
||||||
CommonButton(
|
|
||||||
text: l10n?.runAgain ?? '重新运行',
|
|
||||||
icon: Icons.refresh,
|
|
||||||
type: ButtonType.secondary,
|
|
||||||
onPressed: () {
|
|
||||||
final program = runState.currentProgram;
|
|
||||||
if (program != null) {
|
|
||||||
runNotifier.reset();
|
|
||||||
runNotifier.start(program);
|
|
||||||
context.go('/');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 成功图标
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.successColor.withValues(alpha: 0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
size: 48,
|
||||||
|
color: AppTheme.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
Text(
|
||||||
|
l10n?.runComplete ?? '程序运行完成',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.textPrimary,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// 提示信息
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.warningColor.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
l10n?.sampleDropGuide ?? '请将样本滴入检测卡',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.warningColor,
|
||||||
|
fontSize: 15,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
// 操作示意图
|
||||||
|
_buildOperationGuide(),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// 按钮区域:完成(主按钮)+ 重新运行(次按钮)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 完成按钮 - 主操作,返回设备控制页
|
||||||
|
CommonButton(
|
||||||
|
text: l10n?.complete ?? '完成',
|
||||||
|
icon: Icons.check_circle,
|
||||||
|
type: ButtonType.primary,
|
||||||
|
onPressed: finishAndGoHome,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
|
||||||
|
// 重新运行按钮
|
||||||
|
CommonButton(
|
||||||
|
text: l10n?.runAgain ?? '重新运行',
|
||||||
|
icon: Icons.refresh,
|
||||||
|
type: ButtonType.secondary,
|
||||||
|
onPressed: () {
|
||||||
|
final program = runState.currentProgram;
|
||||||
|
if (program != null) {
|
||||||
|
runNotifier.reset();
|
||||||
|
runNotifier.start(program);
|
||||||
|
context.go('/');
|
||||||
|
} else {
|
||||||
|
finishAndGoHome();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -135,7 +169,7 @@ class CompletePage extends ConsumerWidget {
|
|||||||
/// 操作指引示意图
|
/// 操作指引示意图
|
||||||
Widget _buildOperationGuide() {
|
Widget _buildOperationGuide() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.backgroundColor,
|
color: AppTheme.backgroundColor,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@@ -151,7 +185,7 @@ class CompletePage extends ConsumerWidget {
|
|||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
@@ -171,15 +205,15 @@ class CompletePage extends ConsumerWidget {
|
|||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 50,
|
width: 44,
|
||||||
height: 50,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: AppTheme.primaryColor, size: 24),
|
child: Icon(icon, color: AppTheme.primaryColor, size: 22),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Text(
|
Text(
|
||||||
'$number',
|
'$number',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -188,7 +222,7 @@ class CompletePage extends ConsumerWidget {
|
|||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -199,4 +233,4 @@ class CompletePage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class _StatusBarState extends ConsumerState<StatusBar> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final deviceInfo = ref.watch(deviceInfoProvider);
|
final deviceInfo = ref.watch(deviceInfoProvider);
|
||||||
final serialState = ref.watch(serialPortServiceProvider).state;
|
final serialState = ref.watch(serialConnectionStateProvider);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 56,
|
height: 56,
|
||||||
|
|||||||
Reference in New Issue
Block a user