Compare commits
10 Commits
67e2c7c76c
...
8c2e26ec87
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2e26ec87 | ||
|
|
87c4b669a0 | ||
|
|
736c36a98e | ||
|
|
37d2af70b7 | ||
|
|
3ab2232845 | ||
|
|
55bdaa9211 | ||
|
|
cbe1e6b470 | ||
|
|
16fbb7d54b | ||
|
|
d91791edaf | ||
|
|
d53c41c300 |
@@ -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)
|
||||
2. 运行设备屏幕尺寸为1920*1080(横屏),UI设计必须支持此尺寸
|
||||
2. 运行设备屏幕尺寸为1024x600(横屏),屏幕密度为200,UI设计必须支持此尺寸
|
||||
@@ -4,6 +4,9 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
|
||||
<!-- USB Host:与下位机(USB 转串口)通信 -->
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="true" />
|
||||
|
||||
<application
|
||||
android:label="kuaishai2"
|
||||
android:name="${applicationName}"
|
||||
@@ -28,6 +31,15 @@
|
||||
<category android:name="android.intent.category.HOME"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
<!-- USB 设备过滤:当下位机(USB 转串口)插入时,系统会启动本 Activity 并附带设备信息。
|
||||
配合 @xml/usb_device_filter 中声明的 vendor-id 列表使用。
|
||||
若下位机使用其它芯片,可在此追加 vendor-id。 -->
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED"
|
||||
android:resource="@xml/usb_device_filter" />
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package com.xiarui.kuaishai2
|
||||
|
||||
import android.content.ContentValues
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.provider.MediaStore
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* Kiosk 模式主 Activity
|
||||
@@ -14,12 +22,80 @@ import io.flutter.embedding.android.FlutterActivity
|
||||
*/
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val downloadsChannel = "com.xiarui.kuaishai2/downloads"
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// 保持屏幕常亮
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, downloadsChannel)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"saveToDownloads" -> handleSaveToDownloads(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleSaveToDownloads(
|
||||
call: io.flutter.plugin.common.MethodCall,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val filename = call.argument<String>("filename")
|
||||
val bytes = call.argument<ByteArray>("bytes")
|
||||
if (filename.isNullOrEmpty() || bytes == null) {
|
||||
result.error("ARG_ERROR", "filename 和 bytes 必填", null)
|
||||
return
|
||||
}
|
||||
try {
|
||||
val savedPath = saveToDownloads(filename, bytes)
|
||||
result.success(savedPath)
|
||||
} catch (e: Exception) {
|
||||
result.error("SAVE_FAILED", e.message ?: "保存失败", null)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存到公共 Downloads 目录。
|
||||
* - Android 10+:通过 MediaStore.Downloads 写入,无需 WRITE_EXTERNAL_STORAGE 权限
|
||||
* - Android 9 及以下:直接写 /storage/emulated/0/Download/,需 WRITE_EXTERNAL_STORAGE
|
||||
*/
|
||||
private fun saveToDownloads(filename: String, bytes: ByteArray): String {
|
||||
val mimeType =
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val values = ContentValues().apply {
|
||||
put(MediaStore.Downloads.DISPLAY_NAME, filename)
|
||||
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
||||
put(MediaStore.Downloads.IS_PENDING, 1)
|
||||
}
|
||||
val resolver = contentResolver
|
||||
val uri = resolver.insert(
|
||||
MediaStore.Downloads.EXTERNAL_CONTENT_URI,
|
||||
values,
|
||||
) ?: throw IllegalStateException("无法创建 MediaStore 记录")
|
||||
resolver.openOutputStream(uri)?.use { it.write(bytes) }
|
||||
?: throw IllegalStateException("无法打开输出流")
|
||||
values.clear()
|
||||
values.put(MediaStore.Downloads.IS_PENDING, 0)
|
||||
resolver.update(uri, values, null, null)
|
||||
"${Environment.DIRECTORY_DOWNLOADS}/$filename"
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
val dir = Environment.getExternalStoragePublicDirectory(
|
||||
Environment.DIRECTORY_DOWNLOADS,
|
||||
)
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val file = File(dir, filename)
|
||||
FileOutputStream(file).use { it.write(bytes) }
|
||||
file.absolutePath
|
||||
}
|
||||
}
|
||||
|
||||
override fun onWindowFocusChanged(hasFocus: Boolean) {
|
||||
super.onWindowFocusChanged(hasFocus)
|
||||
if (hasFocus) {
|
||||
|
||||
12
android/app/src/main/res/xml/usb_device_filter.xml
Normal file
12
android/app/src/main/res/xml/usb_device_filter.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
USB 设备过滤:下位机使用常见 USB 转串口芯片时自动识别
|
||||
包含:CH340 / CH341 / CH343 / CH9103、FTDI、CP210x、PL2303
|
||||
若下位机使用其他芯片,可继续追加 vendor-id + product-id
|
||||
-->
|
||||
<resources>
|
||||
<usb-device vendor-id="0x1A86" /> <!-- CH340 / CH341 / CH343 / CH9103 系列 -->
|
||||
<usb-device vendor-id="0x0403" /> <!-- FTDI 系列 -->
|
||||
<usb-device vendor-id="0x10C4" /> <!-- Silicon Labs CP210x 系列 -->
|
||||
<usb-device vendor-id="0x067B" /> <!-- Prolific PL2303 系列 -->
|
||||
</resources>
|
||||
178
docs/下位机交互数据模型.md
Normal file
178
docs/下位机交互数据模型.md
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
title: 默认模块
|
||||
language_tabs:
|
||||
- shell: Shell
|
||||
- http: HTTP
|
||||
- javascript: JavaScript
|
||||
- ruby: Ruby
|
||||
- python: Python
|
||||
- php: PHP
|
||||
- java: Java
|
||||
- go: Go
|
||||
toc_footers: []
|
||||
includes: []
|
||||
search: true
|
||||
code_clipboard: true
|
||||
highlight_theme: darkula
|
||||
headingLevel: 2
|
||||
generator: "@tarslib/widdershins v4.0.30"
|
||||
|
||||
---
|
||||
|
||||
# 默认模块
|
||||
|
||||
Base URLs:
|
||||
|
||||
# Authentication
|
||||
|
||||
# 数据模型
|
||||
|
||||
<h2 id="tocS_设备基本信息">设备基本信息</h2>
|
||||
|
||||
<a id="schema设备基本信息"></a>
|
||||
<a id="schema_设备基本信息"></a>
|
||||
<a id="tocS设备基本信息"></a>
|
||||
<a id="tocs设备基本信息"></a>
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "string",
|
||||
"type": "string",
|
||||
"ack": "string",
|
||||
"need_ack": true,
|
||||
"data": {
|
||||
"door_status": "string",
|
||||
"task_status": "string",
|
||||
"light_status": "string"
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 属性
|
||||
|
||||
|名称|类型|必选|约束|中文名|说明|
|
||||
|---|---|---|---|---|---|
|
||||
|message_id|string|true|none||none|
|
||||
|type|string|true|none|类型|device_info|
|
||||
|ack|string|true|none||none|
|
||||
|need_ack|boolean|true|none||none|
|
||||
|data|object|true|none||none|
|
||||
|» door_status|string|true|none|门状态|open=开,close=关|
|
||||
|» task_status|string|true|none|任务运行状态|running=运行中,pause=暂停,idle=空闲|
|
||||
|» light_status|string|true|none|灯状态|on=开,off= 关|
|
||||
|
||||
<h2 id="tocS_下发任务">下发任务</h2>
|
||||
|
||||
<a id="schema下发任务"></a>
|
||||
<a id="schema_下发任务"></a>
|
||||
<a id="tocS下发任务"></a>
|
||||
<a id="tocs下发任务"></a>
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "string",
|
||||
"type": "string",
|
||||
"ack": "string",
|
||||
"need_ack": true,
|
||||
"data": {
|
||||
"steps": [
|
||||
{
|
||||
"no": 0,
|
||||
"slot": 0,
|
||||
"name": "string",
|
||||
"mixtime": 0,
|
||||
"pulltime": 0,
|
||||
"volume": 0,
|
||||
"speed": 0
|
||||
}
|
||||
],
|
||||
"temperature": 0,
|
||||
"airflowtime": 0
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 属性
|
||||
|
||||
|名称|类型|必选|约束|中文名|说明|
|
||||
|---|---|---|---|---|---|
|
||||
|message_id|string|true|none||由uuid生成唯一识别码|
|
||||
|type|string|true|none|指令类型|create_task|
|
||||
|ack|string|true|none||需要响应的消息id|
|
||||
|need_ack|boolean|true|none|是否需要响应|true|
|
||||
|data|object|true|none||none|
|
||||
|» steps|[object]|true|none|步骤列表|none|
|
||||
|»» no|integer|true|none|步骤号|none|
|
||||
|»» slot|integer|true|none|槽位号|none|
|
||||
|»» name|string|true|none|步骤名称|none|
|
||||
|»» mixtime|integer|true|none|搅拌时间|单位:秒|
|
||||
|»» pulltime|integer|true|none|吸磁时间|单位:秒|
|
||||
|»» volume|integer|true|none|容积|范围 0-2000|
|
||||
|»» speed|integer|true|none|速度等级|范围:1-10|
|
||||
|» temperature|integer|true|none|加热温度|none|
|
||||
|» airflowtime|integer|true|none|吹气时间|单位:秒|
|
||||
|
||||
<h2 id="tocS_灯光控制">灯光控制</h2>
|
||||
|
||||
<a id="schema灯光控制"></a>
|
||||
<a id="schema_灯光控制"></a>
|
||||
<a id="tocS灯光控制"></a>
|
||||
<a id="tocs灯光控制"></a>
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "string",
|
||||
"type": "string",
|
||||
"ack": " ",
|
||||
"need_ack": true,
|
||||
"data": {
|
||||
"status": "string"
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 属性
|
||||
|
||||
|名称|类型|必选|约束|中文名|说明|
|
||||
|---|---|---|---|---|---|
|
||||
|message_id|string|true|none||none|
|
||||
|type|string|true|none|类型|none|
|
||||
|ack|string|true|none||none|
|
||||
|need_ack|boolean|true|none||none|
|
||||
|data|object|true|none||none|
|
||||
|» status|string|true|none|开光|on / off|
|
||||
|
||||
<h2 id="tocS_任务控制">任务控制</h2>
|
||||
|
||||
<a id="schema任务控制"></a>
|
||||
<a id="schema_任务控制"></a>
|
||||
<a id="tocS任务控制"></a>
|
||||
<a id="tocs任务控制"></a>
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "string",
|
||||
"type": "string",
|
||||
"ack": "string",
|
||||
"need_ack": true,
|
||||
"data": {
|
||||
"status": "string"
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### 属性
|
||||
|
||||
|名称|类型|必选|约束|中文名|说明|
|
||||
|---|---|---|---|---|---|
|
||||
|message_id|string|true|none||none|
|
||||
|type|string|true|none|类型|control|
|
||||
|ack|string¦null|true|none||none|
|
||||
|need_ack|boolean|true|none||none|
|
||||
|data|object|true|none||none|
|
||||
|» status|string|true|none|状态|continue=继续,stop=停止,暂停=pause|
|
||||
|
||||
@@ -20,7 +20,7 @@ class DatabaseService {
|
||||
|
||||
return await openDatabase(
|
||||
path,
|
||||
version: 2,
|
||||
version: 3,
|
||||
onCreate: _onCreate,
|
||||
onUpgrade: _onUpgrade,
|
||||
);
|
||||
@@ -34,7 +34,9 @@ class DatabaseService {
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
status INTEGER DEFAULT 1
|
||||
status INTEGER DEFAULT 1,
|
||||
temperature INTEGER DEFAULT 50,
|
||||
airflow_time INTEGER DEFAULT 60
|
||||
)
|
||||
''');
|
||||
|
||||
@@ -49,10 +51,8 @@ class DatabaseService {
|
||||
mix_time INTEGER DEFAULT 0,
|
||||
magnet_time INTEGER DEFAULT 0,
|
||||
volume INTEGER DEFAULT 0,
|
||||
mix_speed TEXT DEFAULT '中速',
|
||||
blow_speed TEXT DEFAULT '中速',
|
||||
blow_time INTEGER DEFAULT 0,
|
||||
needle_speed INTEGER DEFAULT 5,
|
||||
speed INTEGER DEFAULT 5,
|
||||
FOREIGN KEY (program_id) REFERENCES programs(id) ON DELETE CASCADE
|
||||
)
|
||||
''');
|
||||
@@ -82,6 +82,15 @@ class DatabaseService {
|
||||
// 初始化默认密码
|
||||
await db.insert('settings', {'key': 'password', 'value': '123456'});
|
||||
}
|
||||
if (oldVersion < 3) {
|
||||
// v3: 重构字段
|
||||
// programs 增加 temperature, airflow_time
|
||||
await db.execute('ALTER TABLE programs ADD COLUMN temperature INTEGER DEFAULT 50');
|
||||
await db.execute('ALTER TABLE programs ADD COLUMN airflow_time INTEGER DEFAULT 60');
|
||||
// steps 删除 mix_speed, blow_speed, needle_speed;增加 speed
|
||||
// sqflite 不支持 DROP COLUMN,旧字段保留但不再使用
|
||||
await db.execute('ALTER TABLE steps ADD COLUMN speed INTEGER DEFAULT 5');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> close() async {
|
||||
@@ -91,6 +100,30 @@ class DatabaseService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 通用 KV 读:读取 settings 表中 [key] 对应的 value;不存在返回 null
|
||||
Future<String?> readSetting(String key) async {
|
||||
final db = await database;
|
||||
final rows = await db.query(
|
||||
'settings',
|
||||
columns: ['value'],
|
||||
where: 'key = ?',
|
||||
whereArgs: [key],
|
||||
limit: 1,
|
||||
);
|
||||
if (rows.isEmpty) return null;
|
||||
return rows.first['value'] as String?;
|
||||
}
|
||||
|
||||
/// 通用 KV 写:以 INSERT OR REPLACE 写入 settings 表
|
||||
Future<void> writeSetting(String key, String value) async {
|
||||
final db = await database;
|
||||
await db.insert(
|
||||
'settings',
|
||||
{'key': key, 'value': value},
|
||||
conflictAlgorithm: ConflictAlgorithm.replace,
|
||||
);
|
||||
}
|
||||
|
||||
/// 初始化测试数据(仅调试模式使用)
|
||||
Future<void> initTestData() async {
|
||||
final db = await database;
|
||||
@@ -103,11 +136,11 @@ class DatabaseService {
|
||||
|
||||
// 插入测试程序并添加步骤
|
||||
final testPrograms = [
|
||||
{'code': 'P001', 'name': '标准检测程序', 'created_at': '2026-05-19', 'status': 1},
|
||||
{'code': 'P002', 'name': '快速检测程序', 'created_at': '2026-05-18', 'status': 1},
|
||||
{'code': 'P003', 'name': '深度检测程序', 'created_at': '2026-05-17', 'status': 1},
|
||||
{'code': 'P004', 'name': '样本预处理程序', 'created_at': '2026-05-16', 'status': 0},
|
||||
{'code': 'P005', 'name': '磁珠分离程序', 'created_at': '2026-05-15', 'status': 1},
|
||||
{'code': 'P001', 'name': '标准检测程序', 'created_at': '2026-05-19', 'status': 1, 'temperature': 50, 'airflow_time': 60},
|
||||
{'code': 'P002', 'name': '快速检测程序', 'created_at': '2026-05-18', 'status': 1, 'temperature': 45, 'airflow_time': 30},
|
||||
{'code': 'P003', 'name': '深度检测程序', 'created_at': '2026-05-17', 'status': 1, 'temperature': 60, 'airflow_time': 90},
|
||||
{'code': 'P004', 'name': '样本预处理程序', 'created_at': '2026-05-16', 'status': 0, 'temperature': 50, 'airflow_time': 60},
|
||||
{'code': 'P005', 'name': '磁珠分离程序', 'created_at': '2026-05-15', 'status': 1, 'temperature': 55, 'airflow_time': 60},
|
||||
];
|
||||
|
||||
for (final program in testPrograms) {
|
||||
@@ -123,10 +156,8 @@ class DatabaseService {
|
||||
'mix_time': 60,
|
||||
'magnet_time': 0,
|
||||
'volume': 100,
|
||||
'mix_speed': '中速',
|
||||
'blow_speed': '中速',
|
||||
'blow_time': 0,
|
||||
'needle_speed': 5,
|
||||
'speed': 5,
|
||||
},
|
||||
{
|
||||
'program_id': programId,
|
||||
@@ -136,10 +167,8 @@ class DatabaseService {
|
||||
'mix_time': 0,
|
||||
'magnet_time': 30,
|
||||
'volume': 0,
|
||||
'mix_speed': '中速',
|
||||
'blow_speed': '中速',
|
||||
'blow_time': 0,
|
||||
'needle_speed': 5,
|
||||
'speed': 5,
|
||||
},
|
||||
{
|
||||
'program_id': programId,
|
||||
@@ -149,10 +178,8 @@ class DatabaseService {
|
||||
'mix_time': 0,
|
||||
'magnet_time': 0,
|
||||
'volume': 0,
|
||||
'mix_speed': '中速',
|
||||
'blow_speed': '高速',
|
||||
'blow_time': 10,
|
||||
'needle_speed': 8,
|
||||
'speed': 8,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class AppLocalizations {
|
||||
String get running => _localizedValues[locale.languageCode]?['running'] ?? '运行中';
|
||||
String get idle => _localizedValues[locale.languageCode]?['idle'] ?? '未运行';
|
||||
String get lighting => _localizedValues[locale.languageCode]?['lighting'] ?? '照明';
|
||||
String get deviceControl => _localizedValues[locale.languageCode]?['deviceControl'] ?? '设备控制';
|
||||
|
||||
// 程序管理
|
||||
String get programs => _localizedValues[locale.languageCode]?['programs'] ?? '程序管理';
|
||||
@@ -29,17 +30,18 @@ class AppLocalizations {
|
||||
String get editProgram => _localizedValues[locale.languageCode]?['editProgram'] ?? '编辑程序';
|
||||
String get deleteProgram => _localizedValues[locale.languageCode]?['deleteProgram'] ?? '删除程序';
|
||||
String get importProgram => _localizedValues[locale.languageCode]?['importProgram'] ?? '导入程序';
|
||||
String get downloadTemplate => _localizedValues[locale.languageCode]?['downloadTemplate'] ?? '下载模板';
|
||||
String get viewDetails => _localizedValues[locale.languageCode]?['viewDetails'] ?? '查看详情';
|
||||
String get selectedProgram => _localizedValues[locale.languageCode]?['selectedProgram'] ?? '当前选中程序';
|
||||
String get selectedProgramLabel => _localizedValues[locale.languageCode]?['selectedProgramLabel'] ?? '当前选中';
|
||||
String get availablePrograms => _localizedValues[locale.languageCode]?['availablePrograms'] ?? '可用程序';
|
||||
String get ceramicNotInstalled => _localizedValues[locale.languageCode]?['ceramicNotInstalled'] ?? '瓷套棒: 未安装 — 禁止启动';
|
||||
String get ceramicInstalled => _localizedValues[locale.languageCode]?['ceramicInstalled'] ?? '瓷套棒: 已安装';
|
||||
String get runningMonitor => _localizedValues[locale.languageCode]?['runningMonitor'] ?? '运行状态监控';
|
||||
String get currentHole => _localizedValues[locale.languageCode]?['currentHole'] ?? '当前孔位';
|
||||
String get stepParams => _localizedValues[locale.languageCode]?['stepParams'] ?? '步骤参数';
|
||||
String get speed => _localizedValues[locale.languageCode]?['speed'] ?? '转速';
|
||||
String get speed => _localizedValues[locale.languageCode]?['speed'] ?? '速度';
|
||||
String get speedLevel => _localizedValues[locale.languageCode]?['speedLevel'] ?? '档';
|
||||
String get temperature => _localizedValues[locale.languageCode]?['temperature'] ?? '温度';
|
||||
String get airflowTime => _localizedValues[locale.languageCode]?['airflowTime'] ?? '吹气时间';
|
||||
String get duration => _localizedValues[locale.languageCode]?['duration'] ?? '持续时间';
|
||||
String get sampleVolume => _localizedValues[locale.languageCode]?['sampleVolume'] ?? '样品体积';
|
||||
String get pleaseSelectProgram => _localizedValues[locale.languageCode]?['pleaseSelectProgram'] ?? '请选择要运行的程序';
|
||||
@@ -54,6 +56,7 @@ class AppLocalizations {
|
||||
String get remainingTime => _localizedValues[locale.languageCode]?['remainingTime'] ?? '剩余时间';
|
||||
String get progress => _localizedValues[locale.languageCode]?['progress'] ?? '进度';
|
||||
String get ceramicSleeveConfirm => _localizedValues[locale.languageCode]?['ceramicSleeveConfirm'] ?? '运行前请确认已安装瓷套棒';
|
||||
String get ceramicSleeveConfirmMessage => _localizedValues[locale.languageCode]?['ceramicSleeveConfirmMessage'] ?? '请确认已放置瓷套棒后再启动程序。';
|
||||
String get paused => _localizedValues[locale.languageCode]?['paused'] ?? '已暂停';
|
||||
String get stopConfirm => _localizedValues[locale.languageCode]?['stopConfirm'] ?? '确定要停止当前运行的程序吗?';
|
||||
String get currentProgram => _localizedValues[locale.languageCode]?['currentProgram'] ?? '当前程序';
|
||||
@@ -68,15 +71,7 @@ class AppLocalizations {
|
||||
String get mixTime => _localizedValues[locale.languageCode]?['mixTime'] ?? '混合时间';
|
||||
String get magnetTime => _localizedValues[locale.languageCode]?['magnetTime'] ?? '吸磁时间';
|
||||
String get volume => _localizedValues[locale.languageCode]?['volume'] ?? '容积';
|
||||
String get mixSpeed => _localizedValues[locale.languageCode]?['mixSpeed'] ?? '混合速度';
|
||||
String get blowSpeed => _localizedValues[locale.languageCode]?['blowSpeed'] ?? '吹气速度';
|
||||
String get blowTime => _localizedValues[locale.languageCode]?['blowTime'] ?? '吹气时间';
|
||||
String get needleSpeed => _localizedValues[locale.languageCode]?['needleSpeed'] ?? '下针速度';
|
||||
|
||||
// 速度选项
|
||||
String get lowSpeed => _localizedValues[locale.languageCode]?['lowSpeed'] ?? '低速';
|
||||
String get mediumSpeed => _localizedValues[locale.languageCode]?['mediumSpeed'] ?? '中速';
|
||||
String get highSpeed => _localizedValues[locale.languageCode]?['highSpeed'] ?? '高速';
|
||||
|
||||
// 设置
|
||||
String get settings => _localizedValues[locale.languageCode]?['settings'] ?? '系统设置';
|
||||
@@ -98,6 +93,7 @@ class AppLocalizations {
|
||||
// 完成提示
|
||||
String get runComplete => _localizedValues[locale.languageCode]?['runComplete'] ?? '运行完成';
|
||||
String get sampleDropGuide => _localizedValues[locale.languageCode]?['sampleDropGuide'] ?? '请将样本滴入检测卡';
|
||||
String get complete => _localizedValues[locale.languageCode]?['complete'] ?? '完成';
|
||||
|
||||
// 补充缺失的翻译
|
||||
String get lightOn => _localizedValues[locale.languageCode]?['lightOn'] ?? '亮';
|
||||
@@ -140,6 +136,7 @@ class AppLocalizations {
|
||||
'running': '运行中',
|
||||
'idle': '未运行',
|
||||
'lighting': '照明',
|
||||
'deviceControl': '设备控制',
|
||||
'programs': '程序管理',
|
||||
'programList': '程序列表',
|
||||
'programName': '程序名称',
|
||||
@@ -149,17 +146,18 @@ class AppLocalizations {
|
||||
'editProgram': '编辑程序',
|
||||
'deleteProgram': '删除程序',
|
||||
'importProgram': '导入程序',
|
||||
'downloadTemplate': '下载模板',
|
||||
'viewDetails': '查看详情',
|
||||
'selectedProgram': '当前选中程序',
|
||||
'selectedProgramLabel': '当前选中',
|
||||
'availablePrograms': '可用程序',
|
||||
'ceramicNotInstalled': '瓷套棒: 未安装 — 禁止启动',
|
||||
'ceramicInstalled': '瓷套棒: 已安装',
|
||||
'runningMonitor': '运行状态监控',
|
||||
'currentHole': '当前孔位',
|
||||
'stepParams': '步骤参数',
|
||||
'speed': '转速',
|
||||
'speed': '速度',
|
||||
'speedLevel': '档',
|
||||
'temperature': '温度',
|
||||
'airflowTime': '吹气时间',
|
||||
'duration': '持续时间',
|
||||
'sampleVolume': '样品体积',
|
||||
'pleaseSelectProgram': '请选择要运行的程序',
|
||||
@@ -172,6 +170,7 @@ class AppLocalizations {
|
||||
'remainingTime': '剩余时间',
|
||||
'progress': '进度',
|
||||
'ceramicSleeveConfirm': '运行前请确认已安装瓷套棒',
|
||||
'ceramicSleeveConfirmMessage': '请确认已放置瓷套棒后再启动程序。',
|
||||
'paused': '已暂停',
|
||||
'stopConfirm': '确定要停止当前运行的程序吗?',
|
||||
'currentProgram': '当前程序',
|
||||
@@ -184,13 +183,7 @@ class AppLocalizations {
|
||||
'mixTime': '混合时间',
|
||||
'magnetTime': '吸磁时间',
|
||||
'volume': '容积',
|
||||
'mixSpeed': '混合速度',
|
||||
'blowSpeed': '吹气速度',
|
||||
'blowTime': '吹气时间',
|
||||
'needleSpeed': '下针速度',
|
||||
'lowSpeed': '低速',
|
||||
'mediumSpeed': '中速',
|
||||
'highSpeed': '高速',
|
||||
'settings': '系统设置',
|
||||
'language': '语言设置',
|
||||
'password': '密码修改',
|
||||
@@ -206,6 +199,7 @@ class AppLocalizations {
|
||||
'noData': '暂无数据',
|
||||
'runComplete': '运行完成',
|
||||
'sampleDropGuide': '请将样本滴入检测卡',
|
||||
'complete': '完成',
|
||||
'lightOn': '亮',
|
||||
'lightOff': '暗',
|
||||
'enabled': '启用',
|
||||
@@ -245,6 +239,7 @@ class AppLocalizations {
|
||||
'running': 'Running',
|
||||
'idle': 'Idle',
|
||||
'lighting': 'Lighting',
|
||||
'deviceControl': 'Device Control',
|
||||
'programs': 'Programs',
|
||||
'programList': 'Program List',
|
||||
'programName': 'Program Name',
|
||||
@@ -254,17 +249,18 @@ class AppLocalizations {
|
||||
'editProgram': 'Edit Program',
|
||||
'deleteProgram': 'Delete Program',
|
||||
'importProgram': 'Import Program',
|
||||
'downloadTemplate': 'Download Template',
|
||||
'viewDetails': 'View Details',
|
||||
'selectedProgram': 'Selected Program',
|
||||
'selectedProgramLabel': 'Selected',
|
||||
'availablePrograms': 'Available Programs',
|
||||
'ceramicNotInstalled': 'Ceramic sleeve: Not installed — Cannot start',
|
||||
'ceramicInstalled': 'Ceramic sleeve: Installed',
|
||||
'runningMonitor': 'Running Status Monitor',
|
||||
'currentHole': 'Current Position',
|
||||
'stepParams': 'Step Parameters',
|
||||
'speed': 'Speed',
|
||||
'speedLevel': 'level',
|
||||
'temperature': 'Temperature',
|
||||
'airflowTime': 'Airflow Time',
|
||||
'duration': 'Duration',
|
||||
'sampleVolume': 'Sample Volume',
|
||||
'pleaseSelectProgram': 'Please select a program',
|
||||
@@ -277,6 +273,7 @@ class AppLocalizations {
|
||||
'remainingTime': 'Remaining',
|
||||
'progress': 'Progress',
|
||||
'ceramicSleeveConfirm': 'Please confirm ceramic sleeve is installed',
|
||||
'ceramicSleeveConfirmMessage': 'Please make sure the ceramic sleeve is in place before starting the program.',
|
||||
'paused': 'Paused',
|
||||
'stopConfirm': 'Are you sure to stop the running program?',
|
||||
'currentProgram': 'Current Program',
|
||||
@@ -289,13 +286,7 @@ class AppLocalizations {
|
||||
'mixTime': 'Mix Time',
|
||||
'magnetTime': 'Magnet Time',
|
||||
'volume': 'Volume',
|
||||
'mixSpeed': 'Mix Speed',
|
||||
'blowSpeed': 'Blow Speed',
|
||||
'blowTime': 'Blow Time',
|
||||
'needleSpeed': 'Needle Speed',
|
||||
'lowSpeed': 'Low',
|
||||
'mediumSpeed': 'Medium',
|
||||
'highSpeed': 'High',
|
||||
'settings': 'Settings',
|
||||
'language': 'Language',
|
||||
'password': 'Password',
|
||||
@@ -311,6 +302,7 @@ class AppLocalizations {
|
||||
'noData': 'No Data',
|
||||
'runComplete': 'Complete',
|
||||
'sampleDropGuide': 'Drop sample to test card',
|
||||
'complete': 'Done',
|
||||
'lightOn': 'On',
|
||||
'lightOff': 'Off',
|
||||
'enabled': 'Enabled',
|
||||
|
||||
@@ -15,7 +15,12 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
GoRoute(
|
||||
path: '/',
|
||||
name: 'home',
|
||||
builder: (context, state) => const HomePage(),
|
||||
builder: (context, state) {
|
||||
// 支持 ?tab=N 查询参数,用于从其他页面跳回首页并切换到指定 tab
|
||||
final tabParam = state.uri.queryParameters['tab'];
|
||||
final initialTab = int.tryParse(tabParam ?? '') ?? 0;
|
||||
return HomePage(initialTab: initialTab);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/programs',
|
||||
|
||||
126
lib/features/device/models/serial_config.dart
Normal file
126
lib/features/device/models/serial_config.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'dart:convert';
|
||||
|
||||
/// 串口奇偶校验
|
||||
enum SerialParity { none, odd, even, mark, space }
|
||||
|
||||
/// 串口流控
|
||||
enum SerialFlowControl { none, rtsCts, xonXoff, dtrDsr }
|
||||
|
||||
/// 串口配置
|
||||
///
|
||||
/// 持久化到 settings 表的 `serial_config` key 中;
|
||||
/// 打开串口时根据此配置构造底层 `UsbConfig`。
|
||||
class SerialConfig {
|
||||
/// 设备 Vendor ID(十六进制)
|
||||
final int vendorId;
|
||||
|
||||
/// 设备 Product ID(十六进制),0 表示不指定
|
||||
final int productId;
|
||||
|
||||
/// 波特率
|
||||
final int baudRate;
|
||||
|
||||
/// 数据位 (5/6/7/8)
|
||||
final int dataBits;
|
||||
|
||||
/// 停止位 (1/2)
|
||||
final int stopBits;
|
||||
|
||||
/// 校验位
|
||||
final SerialParity parity;
|
||||
|
||||
/// 流控
|
||||
final SerialFlowControl flowControl;
|
||||
|
||||
/// 读超时(毫秒)
|
||||
final int readTimeoutMs;
|
||||
|
||||
/// 写超时(毫秒)
|
||||
final int writeTimeoutMs;
|
||||
|
||||
const SerialConfig({
|
||||
this.vendorId = 0x1A86,
|
||||
this.productId = 0x7523,
|
||||
this.baudRate = 9600,
|
||||
this.dataBits = 8,
|
||||
this.stopBits = 1,
|
||||
this.parity = SerialParity.none,
|
||||
this.flowControl = SerialFlowControl.none,
|
||||
this.readTimeoutMs = 2000,
|
||||
this.writeTimeoutMs = 2000,
|
||||
});
|
||||
|
||||
/// 默认配置
|
||||
static const SerialConfig defaults = SerialConfig();
|
||||
|
||||
/// 常用波特率
|
||||
static const List<int> commonBaudRates = [
|
||||
1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200, 230400, 460800,
|
||||
];
|
||||
|
||||
SerialConfig copyWith({
|
||||
int? vendorId,
|
||||
int? productId,
|
||||
int? baudRate,
|
||||
int? dataBits,
|
||||
int? stopBits,
|
||||
SerialParity? parity,
|
||||
SerialFlowControl? flowControl,
|
||||
int? readTimeoutMs,
|
||||
int? writeTimeoutMs,
|
||||
}) {
|
||||
return SerialConfig(
|
||||
vendorId: vendorId ?? this.vendorId,
|
||||
productId: productId ?? this.productId,
|
||||
baudRate: baudRate ?? this.baudRate,
|
||||
dataBits: dataBits ?? this.dataBits,
|
||||
stopBits: stopBits ?? this.stopBits,
|
||||
parity: parity ?? this.parity,
|
||||
flowControl: flowControl ?? this.flowControl,
|
||||
readTimeoutMs: readTimeoutMs ?? this.readTimeoutMs,
|
||||
writeTimeoutMs: writeTimeoutMs ?? this.writeTimeoutMs,
|
||||
);
|
||||
}
|
||||
|
||||
/// 编码为 JSON 字符串(用于持久化)
|
||||
String toJsonString() => jsonEncode({
|
||||
'vendorId': vendorId,
|
||||
'productId': productId,
|
||||
'baudRate': baudRate,
|
||||
'dataBits': dataBits,
|
||||
'stopBits': stopBits,
|
||||
'parity': parity.name,
|
||||
'flowControl': flowControl.name,
|
||||
'readTimeoutMs': readTimeoutMs,
|
||||
'writeTimeoutMs': writeTimeoutMs,
|
||||
});
|
||||
|
||||
/// 从 JSON 字符串解码;解析失败时返回默认值
|
||||
factory SerialConfig.fromJsonString(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return defaults;
|
||||
try {
|
||||
final map = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return SerialConfig(
|
||||
vendorId: (map['vendorId'] as num?)?.toInt() ?? defaults.vendorId,
|
||||
productId: (map['productId'] as num?)?.toInt() ?? defaults.productId,
|
||||
baudRate: (map['baudRate'] as num?)?.toInt() ?? defaults.baudRate,
|
||||
dataBits: (map['dataBits'] as num?)?.toInt() ?? defaults.dataBits,
|
||||
stopBits: (map['stopBits'] as num?)?.toInt() ?? defaults.stopBits,
|
||||
parity: SerialParity.values.firstWhere(
|
||||
(e) => e.name == map['parity'],
|
||||
orElse: () => defaults.parity,
|
||||
),
|
||||
flowControl: SerialFlowControl.values.firstWhere(
|
||||
(e) => e.name == map['flowControl'],
|
||||
orElse: () => defaults.flowControl,
|
||||
),
|
||||
readTimeoutMs:
|
||||
(map['readTimeoutMs'] as num?)?.toInt() ?? defaults.readTimeoutMs,
|
||||
writeTimeoutMs:
|
||||
(map['writeTimeoutMs'] as num?)?.toInt() ?? defaults.writeTimeoutMs,
|
||||
);
|
||||
} catch (_) {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,18 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../programs/models/program.dart';
|
||||
import '../../programs/models/step.dart';
|
||||
import '../../programs/services/program_service.dart';
|
||||
import '../services/mock_runner.dart';
|
||||
import '../services/runner_interface.dart';
|
||||
import 'serial_provider.dart';
|
||||
|
||||
/// 运行状态枚举
|
||||
enum RunStatus {
|
||||
idle, // 待机
|
||||
running, // 运行中
|
||||
paused, // 已暂停
|
||||
completed,// 已完成
|
||||
error, // 错误
|
||||
idle, // 待机
|
||||
running, // 运行中
|
||||
paused, // 已暂停
|
||||
completed, // 已完成
|
||||
error, // 错误
|
||||
}
|
||||
|
||||
/// 运行状态
|
||||
@@ -23,6 +24,7 @@ class RunState {
|
||||
final int remainingSeconds;
|
||||
final double progress;
|
||||
final String? currentWell;
|
||||
final String? errorMessage;
|
||||
|
||||
const RunState({
|
||||
this.status = RunStatus.idle,
|
||||
@@ -32,6 +34,7 @@ class RunState {
|
||||
this.remainingSeconds = 0,
|
||||
this.progress = 0,
|
||||
this.currentWell,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
RunState copyWith({
|
||||
@@ -42,17 +45,21 @@ class RunState {
|
||||
int? remainingSeconds,
|
||||
double? progress,
|
||||
String? currentWell,
|
||||
String? errorMessage,
|
||||
bool clearProgram = false,
|
||||
bool clearWell = false,
|
||||
bool clearError = false,
|
||||
}) {
|
||||
return RunState(
|
||||
status: status ?? this.status,
|
||||
currentProgram: clearProgram ? null : (currentProgram ?? this.currentProgram),
|
||||
currentProgram:
|
||||
clearProgram ? null : (currentProgram ?? this.currentProgram),
|
||||
steps: steps ?? this.steps,
|
||||
currentStepIndex: currentStepIndex ?? this.currentStepIndex,
|
||||
remainingSeconds: remainingSeconds ?? this.remainingSeconds,
|
||||
progress: progress ?? this.progress,
|
||||
currentWell: clearWell ? null : (currentWell ?? this.currentWell),
|
||||
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,8 +75,8 @@ class RunState {
|
||||
final minutes = (remainingSeconds % 3600) ~/ 60;
|
||||
final seconds = remainingSeconds % 60;
|
||||
return '${hours.toString().padLeft(2, '0')}:'
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
'${minutes.toString().padLeft(2, '0')}:'
|
||||
'${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// 格式化进度百分比
|
||||
@@ -80,18 +87,24 @@ class RunState {
|
||||
|
||||
/// 运行状态 Notifier
|
||||
class RunStateNotifier extends StateNotifier<RunState> {
|
||||
final MockRunner _runner;
|
||||
final Runner _runner;
|
||||
final ProgramService _programService;
|
||||
|
||||
RunStateNotifier(this._runner, this._programService) : super(const RunState());
|
||||
|
||||
/// 开始运行程序
|
||||
Future<void> start(Program program) async {
|
||||
// 获取程序步骤(这里使用模拟数据,实际应从数据库读取)
|
||||
final steps = await _loadSteps(program.id!);
|
||||
if (state.status == RunStatus.running ||
|
||||
state.status == RunStatus.paused) {
|
||||
return;
|
||||
}
|
||||
|
||||
final steps = await _programService.getStepsByProgramId(program.id!);
|
||||
if (steps.isEmpty) {
|
||||
state = state.copyWith(status: RunStatus.error);
|
||||
state = state.copyWith(
|
||||
status: RunStatus.error,
|
||||
errorMessage: '程序步骤为空',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -101,26 +114,36 @@ class RunStateNotifier extends StateNotifier<RunState> {
|
||||
steps: steps,
|
||||
currentStepIndex: 0,
|
||||
progress: 0,
|
||||
currentWell: steps.first.position,
|
||||
clearError: true,
|
||||
);
|
||||
|
||||
_runner.start(
|
||||
program,
|
||||
steps,
|
||||
(stepIndex, remaining, progress, well) {
|
||||
state = state.copyWith(
|
||||
currentStepIndex: stepIndex,
|
||||
remainingSeconds: remaining,
|
||||
progress: progress,
|
||||
currentWell: well,
|
||||
);
|
||||
},
|
||||
() {
|
||||
state = state.copyWith(
|
||||
status: RunStatus.completed,
|
||||
progress: 1,
|
||||
clearWell: true,
|
||||
);
|
||||
},
|
||||
RunnerCallbacks(
|
||||
onProgress: (stepIndex, remaining, progress, well) {
|
||||
state = state.copyWith(
|
||||
currentStepIndex: stepIndex,
|
||||
remainingSeconds: remaining,
|
||||
progress: progress,
|
||||
currentWell: well,
|
||||
);
|
||||
},
|
||||
onComplete: () {
|
||||
state = state.copyWith(
|
||||
status: RunStatus.completed,
|
||||
progress: 1,
|
||||
currentWell: steps.last.position,
|
||||
);
|
||||
},
|
||||
onError: (msg) {
|
||||
state = state.copyWith(
|
||||
status: RunStatus.error,
|
||||
errorMessage: msg,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -142,26 +165,17 @@ class RunStateNotifier extends StateNotifier<RunState> {
|
||||
|
||||
/// 停止运行
|
||||
void stop() {
|
||||
_runner.stop();
|
||||
if (state.status == RunStatus.running ||
|
||||
state.status == RunStatus.paused) {
|
||||
_runner.stop();
|
||||
}
|
||||
state = const RunState(status: RunStatus.idle);
|
||||
}
|
||||
|
||||
/// 重置状态
|
||||
void reset() {
|
||||
stop();
|
||||
}
|
||||
|
||||
/// 加载程序步骤(从数据库读取)
|
||||
Future<List<Step>> _loadSteps(int programId) async {
|
||||
return await _programService.getStepsByProgramId(programId);
|
||||
}
|
||||
void reset() => stop();
|
||||
}
|
||||
|
||||
/// MockRunner Provider
|
||||
final mockRunnerProvider = Provider<MockRunner>((ref) {
|
||||
return MockRunner();
|
||||
});
|
||||
|
||||
/// ProgramService Provider
|
||||
final programServiceProvider = Provider<ProgramService>((ref) {
|
||||
return ProgramService.instance;
|
||||
@@ -170,7 +184,7 @@ final programServiceProvider = Provider<ProgramService>((ref) {
|
||||
/// 运行状态 Provider
|
||||
final runStateProvider =
|
||||
StateNotifierProvider<RunStateNotifier, RunState>((ref) {
|
||||
final runner = ref.watch(mockRunnerProvider);
|
||||
final runner = ref.watch(runnerProvider);
|
||||
final programService = ref.watch(programServiceProvider);
|
||||
return RunStateNotifier(runner, programService);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/database/database_service.dart';
|
||||
import '../models/serial_config.dart';
|
||||
import '../services/auto_serial_connect.dart';
|
||||
import '../services/device_message_service.dart';
|
||||
import '../services/json_protocol.dart';
|
||||
import '../services/runner_interface.dart';
|
||||
@@ -15,6 +18,46 @@ final serialPortServiceProvider = Provider<SerialPortService>((ref) {
|
||||
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 之前触发一次,
|
||||
/// 服务内部立即尝试连接第一个 USB 串口设备,失败时按 3s 间隔重试。
|
||||
final autoSerialConnectProvider = Provider<AutoSerialConnect>((ref) {
|
||||
final service = ref.watch(serialPortServiceProvider);
|
||||
final auto = AutoSerialConnect(service);
|
||||
auto.start();
|
||||
ref.onDispose(auto.dispose);
|
||||
return auto;
|
||||
});
|
||||
|
||||
/// JSON 协议编解码器(可在调试/真机协议不一致时整体替换)
|
||||
final jsonProtocolProvider = Provider<JsonProtocolService>((ref) {
|
||||
return JsonProtocolService();
|
||||
|
||||
109
lib/features/device/services/auto_serial_connect.dart
Normal file
109
lib/features/device/services/auto_serial_connect.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:usb_serial/usb_serial.dart';
|
||||
|
||||
import '../models/serial_config.dart';
|
||||
import 'device_log.dart';
|
||||
import 'serial_port_service.dart';
|
||||
|
||||
/// 启动自动连接服务
|
||||
///
|
||||
/// App 启动时调用 [start],自动连接第一个可用的 USB 串口设备:
|
||||
/// - 固定 115200 波特率 / 8 数据位 / 1 停止位 / 默认其它
|
||||
/// - 连接失败时每 3 秒重试一次
|
||||
/// - 连接成功后停止重试;连接断开后重新进入重试循环
|
||||
class AutoSerialConnect {
|
||||
/// 重试间隔
|
||||
static const Duration retryInterval = Duration(seconds: 3);
|
||||
|
||||
/// 自动连接使用的固定参数
|
||||
static const SerialConfig autoConfig = SerialConfig(
|
||||
baudRate: 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
);
|
||||
|
||||
final SerialPortService _service;
|
||||
Timer? _retryTimer;
|
||||
StreamSubscription<SerialConnectionState>? _stateSub;
|
||||
bool _disposed = false;
|
||||
|
||||
AutoSerialConnect(this._service);
|
||||
|
||||
/// 启动自动连接:立即尝试一次,失败则按 [retryInterval] 周期重试
|
||||
void start() {
|
||||
if (_stateSub != null) return;
|
||||
_stateSub = _service.connectionStateChanges.listen(_onStateChange);
|
||||
unawaited(_tryConnect());
|
||||
}
|
||||
|
||||
void _onStateChange(SerialConnectionState s) {
|
||||
if (_disposed) return;
|
||||
switch (s) {
|
||||
case SerialConnectionState.connected:
|
||||
// 连接成功:停止重试
|
||||
_cancelRetry();
|
||||
case SerialConnectionState.disconnected:
|
||||
case SerialConnectionState.error:
|
||||
// 设备断开或出错:进入重试
|
||||
_scheduleRetry();
|
||||
case SerialConnectionState.connecting:
|
||||
// 正在连接中,忽略
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _tryConnect() async {
|
||||
if (_disposed) return;
|
||||
if (_service.isConnected ||
|
||||
_service.state == SerialConnectionState.connecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
final List<UsbDevice> devices;
|
||||
try {
|
||||
devices = await _service.listDevices();
|
||||
} catch (e) {
|
||||
DeviceLog.warn('列出 USB 设备失败: $e,${retryInterval.inSeconds}s 后重试');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
if (devices.isEmpty) {
|
||||
DeviceLog.info('未检测到 USB 串口设备,${retryInterval.inSeconds}s 后重试');
|
||||
_scheduleRetry();
|
||||
return;
|
||||
}
|
||||
|
||||
final device = devices.first;
|
||||
final ok = await _service.connect(device, autoConfig);
|
||||
if (!ok) {
|
||||
DeviceLog.warn('串口自动连接失败: '
|
||||
'${_service.lastError ?? "未知错误"},${retryInterval.inSeconds}s 后重试');
|
||||
_scheduleRetry();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleRetry() {
|
||||
if (_disposed) return;
|
||||
if (_retryTimer != null) return;
|
||||
_retryTimer = Timer(retryInterval, () {
|
||||
_retryTimer = null;
|
||||
unawaited(_tryConnect());
|
||||
});
|
||||
}
|
||||
|
||||
void _cancelRetry() {
|
||||
_retryTimer?.cancel();
|
||||
_retryTimer = null;
|
||||
}
|
||||
|
||||
/// 释放资源
|
||||
Future<void> dispose() async {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_cancelRetry();
|
||||
await _stateSub?.cancel();
|
||||
_stateSub = null;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// 下位机消息类型
|
||||
///
|
||||
/// 对应《下位机交互数据模型.md》中定义的四种 type 字段。
|
||||
@@ -147,18 +149,11 @@ class DeviceMessage {
|
||||
|
||||
/// 消息 ID 生成器
|
||||
///
|
||||
/// 使用时间戳 + 随机数生成全局唯一 ID(避免引入 uuid 依赖)。
|
||||
/// 格式:`<millis>-<rand>`,例如 `1717500000000-1a2b3c`
|
||||
/// 基于 [Uuid.v4] 生成全局唯一 ID(128 位随机,标准 36 字符 hex-with-dashes 形式)。
|
||||
/// 例如 `550e8400-e29b-41d4-a716-446655440000`。
|
||||
class MessageIdGenerator {
|
||||
int _counter = 0;
|
||||
static const Uuid _uuid = Uuid();
|
||||
|
||||
/// 生成下一个唯一 ID
|
||||
String next() {
|
||||
_counter = (_counter + 1) & 0xFFFFFF;
|
||||
final ts = DateTime.now().millisecondsSinceEpoch.toRadixString(36);
|
||||
final rand = (_counter.toRadixString(36) +
|
||||
(DateTime.now().microsecondsSinceEpoch & 0xFFFF).toRadixString(36))
|
||||
.padLeft(4, '0');
|
||||
return '$ts-$rand';
|
||||
}
|
||||
String next() => _uuid.v4();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'device_log.dart';
|
||||
import 'device_message.dart';
|
||||
|
||||
/// JSON 协议层帧编解码器
|
||||
@@ -67,7 +68,7 @@ class JsonProtocolService {
|
||||
(_buffer[2] << 8) |
|
||||
_buffer[3];
|
||||
if (len <= 0 || len > _maxFrameBytes) {
|
||||
// 长度异常,丢弃首字节重新对齐
|
||||
DeviceLog.warn('tryDecode: 异常长度=$len 丢弃首字节 0x${_buffer[0].toRadixString(16).padLeft(2, '0')}');
|
||||
_buffer.removeAt(0);
|
||||
return (null, 0);
|
||||
}
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
import 'dart:async';
|
||||
import '../../programs/models/step.dart';
|
||||
import '../../programs/models/program.dart';
|
||||
|
||||
/// 模拟运行器回调
|
||||
typedef RunProgressCallback = void Function(
|
||||
int currentStepIndex,
|
||||
int remainingSeconds,
|
||||
double progress,
|
||||
String currentWell,
|
||||
);
|
||||
|
||||
typedef RunCompleteCallback = void Function();
|
||||
|
||||
/// 模拟运行器
|
||||
/// 用于在没有实际硬件连接时模拟程序执行过程
|
||||
class MockRunner {
|
||||
Timer? _timer;
|
||||
Program? _currentProgram;
|
||||
List<Step> _steps = [];
|
||||
int _currentStepIndex = 0;
|
||||
int _remainingSeconds = 0;
|
||||
bool _isPaused = false;
|
||||
RunProgressCallback? _onProgress;
|
||||
RunCompleteCallback? _onComplete;
|
||||
|
||||
/// 是否正在运行
|
||||
bool get isRunning => _timer != null && !_isPaused;
|
||||
|
||||
/// 是否已暂停
|
||||
bool get isPaused => _isPaused;
|
||||
|
||||
/// 当前程序
|
||||
Program? get currentProgram => _currentProgram;
|
||||
|
||||
/// 开始运行程序
|
||||
void start(
|
||||
Program program,
|
||||
List<Step> steps,
|
||||
RunProgressCallback onProgress,
|
||||
RunCompleteCallback onComplete,
|
||||
) {
|
||||
_currentProgram = program;
|
||||
_steps = steps;
|
||||
_onProgress = onProgress;
|
||||
_onComplete = onComplete;
|
||||
_currentStepIndex = 0;
|
||||
_isPaused = false;
|
||||
|
||||
if (steps.isEmpty) {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
// 开始执行第一个步骤
|
||||
_startStep(steps[0]);
|
||||
}
|
||||
|
||||
/// 暂停运行
|
||||
void pause() {
|
||||
if (_timer != null && !_isPaused) {
|
||||
_isPaused = true;
|
||||
_timer!.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 继续运行
|
||||
void resume() {
|
||||
if (_isPaused && _currentProgram != null) {
|
||||
_isPaused = false;
|
||||
_resumeStep();
|
||||
}
|
||||
}
|
||||
|
||||
/// 停止运行
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_currentProgram = null;
|
||||
_steps = [];
|
||||
_currentStepIndex = 0;
|
||||
_remainingSeconds = 0;
|
||||
_isPaused = false;
|
||||
}
|
||||
|
||||
/// 开始执行步骤
|
||||
void _startStep(Step step) {
|
||||
// 计算步骤总时间(混合时间 + 吸磁时间 + 吹气时间)
|
||||
_remainingSeconds = step.mixTime + step.magnetTime + step.blowTime;
|
||||
|
||||
// 如果步骤时间为0,设置最小演示时间(5秒)
|
||||
if (_remainingSeconds == 0) {
|
||||
_remainingSeconds = 5;
|
||||
}
|
||||
|
||||
// 启动定时器,每秒更新
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_remainingSeconds--;
|
||||
|
||||
// 计算总进度
|
||||
final totalSeconds = _calculateTotalSeconds();
|
||||
final elapsedSeconds = _calculateElapsedSeconds();
|
||||
final progress = totalSeconds > 0 ? elapsedSeconds / totalSeconds : 0.0;
|
||||
|
||||
// 回调进度更新
|
||||
_onProgress?.call(
|
||||
_currentStepIndex,
|
||||
_remainingSeconds,
|
||||
progress,
|
||||
step.position,
|
||||
);
|
||||
|
||||
// 步骤完成
|
||||
if (_remainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
_timer = null;
|
||||
_nextStep();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 继续执行步骤(从暂停恢复)
|
||||
void _resumeStep() {
|
||||
if (_currentStepIndex >= _steps.length) return;
|
||||
|
||||
final step = _steps[_currentStepIndex];
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
_remainingSeconds--;
|
||||
|
||||
final totalSeconds = _calculateTotalSeconds();
|
||||
final elapsedSeconds = _calculateElapsedSeconds();
|
||||
final progress = totalSeconds > 0 ? elapsedSeconds / totalSeconds : 0.0;
|
||||
|
||||
_onProgress?.call(
|
||||
_currentStepIndex,
|
||||
_remainingSeconds,
|
||||
progress,
|
||||
step.position,
|
||||
);
|
||||
|
||||
if (_remainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
_timer = null;
|
||||
_nextStep();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 执行下一个步骤
|
||||
void _nextStep() {
|
||||
_currentStepIndex++;
|
||||
|
||||
if (_currentStepIndex >= _steps.length) {
|
||||
// 所有步骤完成
|
||||
_onComplete?.call();
|
||||
stop();
|
||||
} else {
|
||||
// 执行下一个步骤
|
||||
_startStep(_steps[_currentStepIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
/// 计算总执行时间
|
||||
int _calculateTotalSeconds() {
|
||||
int total = 0;
|
||||
for (final step in _steps) {
|
||||
int stepTime = step.mixTime + step.magnetTime + step.blowTime;
|
||||
if (stepTime == 0) stepTime = 5;
|
||||
total += stepTime;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 计算已执行时间
|
||||
int _calculateElapsedSeconds() {
|
||||
int elapsed = 0;
|
||||
for (int i = 0; i < _currentStepIndex; i++) {
|
||||
int stepTime = _steps[i].mixTime + _steps[i].magnetTime + _steps[i].blowTime;
|
||||
if (stepTime == 0) stepTime = 5;
|
||||
elapsed += stepTime;
|
||||
}
|
||||
// 加上当前步骤已执行的时间
|
||||
final currentStep = _steps[_currentStepIndex];
|
||||
int currentStepTime = currentStep.mixTime + currentStep.magnetTime + currentStep.blowTime;
|
||||
if (currentStepTime == 0) currentStepTime = 5;
|
||||
elapsed += currentStepTime - _remainingSeconds;
|
||||
return elapsed;
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import '../../programs/models/program.dart';
|
||||
import '../../programs/models/step.dart';
|
||||
import 'runner_interface.dart';
|
||||
|
||||
/// 模拟运行器(用于开发测试)
|
||||
/// 模拟硬件运行过程
|
||||
class MockRunner implements Runner {
|
||||
@override
|
||||
RunnerStatus status = RunnerStatus.idle;
|
||||
|
||||
bool _isRunning = false;
|
||||
int _currentStep = 0;
|
||||
int _remainingSeconds = 0;
|
||||
RunnerCallbacks? _callbacks;
|
||||
List<Step> _steps = [];
|
||||
|
||||
@override
|
||||
void start(Program program, List<Step> steps, RunnerCallbacks callbacks) {
|
||||
if (steps.isEmpty) {
|
||||
callbacks.onError?.call('No steps to run');
|
||||
status = RunnerStatus.error;
|
||||
return;
|
||||
}
|
||||
|
||||
_steps = steps;
|
||||
_callbacks = callbacks;
|
||||
_currentStep = 0;
|
||||
_isRunning = true;
|
||||
status = RunnerStatus.running;
|
||||
|
||||
// 开始模拟运行
|
||||
_runSimulation();
|
||||
}
|
||||
|
||||
void _runSimulation() {
|
||||
if (!_isRunning || _currentStep >= _steps.length) {
|
||||
_completeRun();
|
||||
return;
|
||||
}
|
||||
|
||||
final step = _steps[_currentStep];
|
||||
// 计算步骤时间(混合时间 + 吸磁时间 + 吹气时间 + 5秒最小)
|
||||
final stepTime = (step.mixTime ?? 0) + (step.magnetTime ?? 0) + (step.blowTime ?? 0) + 5;
|
||||
_remainingSeconds = stepTime.clamp(5, 300);
|
||||
|
||||
// 模拟倒计时
|
||||
_simulateStepProgress(stepTime);
|
||||
}
|
||||
|
||||
void _simulateStepProgress(int totalSeconds) {
|
||||
// 简化模拟:每秒更新进度
|
||||
int elapsed = 0;
|
||||
while (_isRunning && elapsed < totalSeconds) {
|
||||
elapsed++;
|
||||
final remaining = totalSeconds - elapsed;
|
||||
final progress = elapsed / totalSeconds;
|
||||
|
||||
_callbacks?.onProgress?.call(
|
||||
_currentStep,
|
||||
remaining,
|
||||
(_currentStep + progress) / _steps.length,
|
||||
_steps[_currentStep].position,
|
||||
);
|
||||
|
||||
// 实际实现需要使用 Timer
|
||||
// await Future.delayed(Duration(seconds: 1));
|
||||
}
|
||||
|
||||
if (_isRunning) {
|
||||
_currentStep++;
|
||||
_runSimulation();
|
||||
}
|
||||
}
|
||||
|
||||
void _completeRun() {
|
||||
status = RunnerStatus.completed;
|
||||
_isRunning = false;
|
||||
_callbacks?.onComplete?.call();
|
||||
}
|
||||
|
||||
@override
|
||||
void pause() {
|
||||
if (status == RunnerStatus.running) {
|
||||
_isRunning = false;
|
||||
status = RunnerStatus.paused;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void resume() {
|
||||
if (status == RunnerStatus.paused) {
|
||||
_isRunning = true;
|
||||
status = RunnerStatus.running;
|
||||
// 继续运行
|
||||
_runSimulation();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void stop() {
|
||||
_isRunning = false;
|
||||
status = RunnerStatus.idle;
|
||||
_currentStep = 0;
|
||||
_remainingSeconds = 0;
|
||||
}
|
||||
|
||||
@override
|
||||
RunnerStatus getStatus() => status;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
stop();
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,9 @@ class JsonSerialRunner implements Runner {
|
||||
final messageId = _msg.nextId();
|
||||
_pendingCreateTaskId = messageId;
|
||||
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} '
|
||||
'temperature=${program.temperature} airflow=${program.airflowTime}');
|
||||
_msg.send(msg).then((ok) {
|
||||
@@ -132,9 +135,9 @@ class JsonSerialRunner implements Runner {
|
||||
if (ack.ack != _pendingCreateTaskId) return;
|
||||
_pendingCreateTaskId = null;
|
||||
DeviceLog.info('Runner received create_task ack: id=${ack.messageId}');
|
||||
// ack 即视为下位机已接受任务,进入 running 状态
|
||||
if (status == RunnerStatus.idle || status == RunnerStatus.error) {
|
||||
status = RunnerStatus.running;
|
||||
// 状态已由 start() 乐观置为 running,此处仅启动本地兜底倒计时。
|
||||
// 若发送失败,.then() 已将 status 置为 error,不应再启动倒计时。
|
||||
if (status == RunnerStatus.running) {
|
||||
_startLocalTicker();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ import '../../../shared/widgets/common_button.dart';
|
||||
import '../../device/providers/run_state_provider.dart';
|
||||
|
||||
/// 运行完成提示页面
|
||||
///
|
||||
/// 页面提供两种返回设备控制页的方式:
|
||||
/// 1. 顶部 AppBar 的返回箭头
|
||||
/// 2. 卡片底部的「完成」主按钮
|
||||
///
|
||||
/// 内部使用 [SingleChildScrollView] 包裹,避免在父级高度受限时按钮被裁切。
|
||||
class CompletePage extends ConsumerWidget {
|
||||
const CompletePage({super.key});
|
||||
|
||||
@@ -16,115 +22,143 @@ class CompletePage extends ConsumerWidget {
|
||||
final runState = ref.watch(runStateProvider);
|
||||
final runNotifier = ref.read(runStateProvider.notifier);
|
||||
|
||||
/// 完成并返回设备控制页
|
||||
void finishAndGoHome() {
|
||||
runNotifier.reset();
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
color: AppTheme.backgroundColor,
|
||||
backgroundColor: 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: Container(
|
||||
width: 600,
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
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: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.15),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
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() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.backgroundColor,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -151,7 +185,7 @@ class CompletePage extends ConsumerWidget {
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
@@ -171,15 +205,15 @@ class CompletePage extends ConsumerWidget {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
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(
|
||||
'$number',
|
||||
style: TextStyle(
|
||||
@@ -188,7 +222,7 @@ class CompletePage extends ConsumerWidget {
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../../core/database/database_service.dart';
|
||||
import '../../../core/localization/app_localizations.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../device/providers/run_state_provider.dart';
|
||||
import '../../programs/pages/programs_page.dart';
|
||||
@@ -12,9 +13,13 @@ import '../widgets/running_control_panel.dart';
|
||||
import '../widgets/run_status_monitor.dart';
|
||||
|
||||
/// 首页 - 设备控制面板 (暗色工业风格)
|
||||
/// 布局:状态栏 + 导航标签栏 + 内容区(设备控制/程序管理/系统设置)
|
||||
/// 布局:状态栏(含标签导航) + 内容区(设备控制/程序管理/系统设置)
|
||||
///
|
||||
/// [initialTab] 用于从子页面(如程序详情页)跳转回首页时指定要显示的 tab
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
final int initialTab;
|
||||
|
||||
const HomePage({super.key, this.initialTab = 0});
|
||||
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
@@ -22,17 +27,19 @@ class HomePage extends ConsumerStatefulWidget {
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int _currentIndex = 0;
|
||||
late int _currentIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.initialTab.clamp(0, 2);
|
||||
DatabaseService.instance.initTestData();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final runState = ref.watch(runStateProvider);
|
||||
final l10n = AppLocalizations.of(context);
|
||||
|
||||
// 监听运行完成状态,自动跳转
|
||||
ref.listen<RunState>(runStateProvider, (prev, next) {
|
||||
@@ -44,19 +51,25 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
}
|
||||
});
|
||||
|
||||
final tabs = <StatusBarTab>[
|
||||
StatusBarTab(icon: Icons.dashboard, label: l10n?.deviceControl ?? '设备控制'),
|
||||
StatusBarTab(icon: Icons.list_alt, label: l10n?.programs ?? '程序管理'),
|
||||
StatusBarTab(icon: Icons.settings, label: l10n?.settings ?? '系统设置'),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
color: AppTheme.bgDeep,
|
||||
child: Column(
|
||||
children: [
|
||||
// 状态栏
|
||||
// 状态栏(内嵌标签导航)
|
||||
StatusBar(
|
||||
isRunning: runState.status == RunStatus.running,
|
||||
tabs: tabs,
|
||||
currentTabIndex: _currentIndex,
|
||||
onTabChanged: (index) => setState(() => _currentIndex = index),
|
||||
),
|
||||
|
||||
// 导航标签栏
|
||||
_buildTabBar(),
|
||||
|
||||
// 内容区
|
||||
Expanded(
|
||||
child: IndexedStack(
|
||||
@@ -74,85 +87,30 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
);
|
||||
}
|
||||
|
||||
/// 导航标签栏
|
||||
Widget _buildTabBar() {
|
||||
const tabs = [
|
||||
(icon: Icons.dashboard, label: '设备控制'),
|
||||
(icon: Icons.list_alt, label: '程序管理'),
|
||||
(icon: Icons.settings, label: '系统设置'),
|
||||
];
|
||||
|
||||
return Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: List.generate(tabs.length, (index) {
|
||||
final tab = tabs[index];
|
||||
final isSelected = _currentIndex == index;
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _currentIndex = index),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? AppTheme.accentPrimary : AppTheme.cardBg,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? AppTheme.accentPrimary
|
||||
: AppTheme.borderSubtle,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
tab.icon,
|
||||
size: 18,
|
||||
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
tab.label,
|
||||
style: TextStyle(
|
||||
color: isSelected ? Colors.white : AppTheme.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 设备控制页面内容
|
||||
Widget _buildDeviceControlPage(RunState runState) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 20),
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧:程序列表(运行时锁定)
|
||||
Opacity(
|
||||
opacity: runState.status == RunStatus.idle ? 1.0 : 0.6,
|
||||
child: IgnorePointer(
|
||||
ignoring: runState.status != RunStatus.idle,
|
||||
child: const ProgramList(),
|
||||
// 左侧:程序列表(运行时锁定),占 2/5 宽度
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Opacity(
|
||||
opacity: runState.status == RunStatus.idle ? 1.0 : 0.6,
|
||||
child: IgnorePointer(
|
||||
ignoring: runState.status != RunStatus.idle,
|
||||
child: const ProgramList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
// 右侧:运行控制区域
|
||||
// 右侧:运行控制区域,占 3/5 宽度
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: Column(
|
||||
children: [
|
||||
const Expanded(child: RunningControlPanel()),
|
||||
const RunningControlPanel(),
|
||||
if (runState.status != RunStatus.idle) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Expanded(child: RunStatusMonitor()),
|
||||
|
||||
@@ -18,7 +18,6 @@ class ProgramList extends ConsumerWidget {
|
||||
final programsNotifier = ref.read(programsProvider.notifier);
|
||||
|
||||
return Container(
|
||||
width: 380,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
|
||||
@@ -25,73 +25,81 @@ class RunStatusMonitor extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppTheme.borderSubtle, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题 + 程序名
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n?.runningMonitor ?? '运行状态监控',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textHeading,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题 + 程序名
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
l10n?.runningMonitor ?? '运行状态监控',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textHeading,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
runState.currentProgram?.name ?? '',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.accentPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
const Spacer(),
|
||||
Flexible(
|
||||
child: Text(
|
||||
runState.currentProgram?.name ?? '',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.accentPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 进度信息横排 (孔位 / 步骤 / 剩余时间)
|
||||
Row(
|
||||
children: [
|
||||
// 当前孔位
|
||||
_buildInfoBlock(
|
||||
label: l10n?.currentHole ?? '当前孔位',
|
||||
value: runState.currentWell ?? '--',
|
||||
valueColor: AppTheme.textHeading,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
// 当前步骤
|
||||
_buildInfoBlock(
|
||||
label: l10n?.currentStep ?? '当前步骤',
|
||||
value: '${l10n?.stepNo ?? '步骤'} ${runState.currentStepIndex + 1}',
|
||||
subValue: runState.currentStep?.name ?? '--',
|
||||
valueColor: AppTheme.accentInfo,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
// 剩余时间
|
||||
_buildInfoBlock(
|
||||
label: l10n?.remainingTime ?? '剩余时间',
|
||||
value: runState.formattedRemainingTime,
|
||||
valueColor: AppTheme.textHeading,
|
||||
valueSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
// 进度信息横排 (孔位 / 步骤 / 剩余时间)
|
||||
Row(
|
||||
children: [
|
||||
// 当前孔位
|
||||
_buildInfoBlock(
|
||||
label: l10n?.currentHole ?? '当前孔位',
|
||||
value: runState.currentWell ?? '--',
|
||||
valueColor: AppTheme.textHeading,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
// 当前步骤
|
||||
Flexible(
|
||||
child: _buildInfoBlock(
|
||||
label: l10n?.currentStep ?? '当前步骤',
|
||||
value: '${l10n?.stepNo ?? '步骤'} ${runState.currentStepIndex + 1}',
|
||||
subValue: runState.currentStep?.name ?? '--',
|
||||
valueColor: AppTheme.accentInfo,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
// 剩余时间
|
||||
_buildInfoBlock(
|
||||
label: l10n?.remainingTime ?? '剩余时间',
|
||||
value: runState.formattedRemainingTime,
|
||||
valueColor: AppTheme.textHeading,
|
||||
valueSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 总进度条
|
||||
_buildProgressBar(l10n, runState),
|
||||
// 总进度条
|
||||
_buildProgressBar(l10n, runState),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 步骤参数
|
||||
if (runState.currentStep != null)
|
||||
_buildStepParams(l10n, runState.currentStep!),
|
||||
],
|
||||
// 步骤参数
|
||||
if (runState.currentStep != null)
|
||||
_buildStepParams(l10n, runState.currentStep!),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -190,53 +198,26 @@ class RunStatusMonitor extends ConsumerWidget {
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
if (step.mixTime > 0)
|
||||
_buildParamRow(
|
||||
l10n?.speed ?? '转速',
|
||||
'${step.mixSpeed}',
|
||||
),
|
||||
if (step.magnetTime > 0)
|
||||
_buildParamRow(
|
||||
l10n?.temperature ?? '温度',
|
||||
'65.0 °C',
|
||||
),
|
||||
_buildParamRow(
|
||||
l10n?.duration ?? '持续时间',
|
||||
step.mixTime > 0 ? '${step.mixTime} min' : '--',
|
||||
),
|
||||
_buildParamRow(
|
||||
l10n?.sampleVolume ?? '样品体积',
|
||||
'10.0 mL',
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoBlock(
|
||||
label: l10n?.speed ?? '速度',
|
||||
value: '${step.speed} ${l10n?.speedLevel ?? '档'}',
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
_buildInfoBlock(
|
||||
label: l10n?.duration ?? '持续时间',
|
||||
value: '${step.mixTime} s',
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
_buildInfoBlock(
|
||||
label: l10n?.sampleVolume ?? '样品体积',
|
||||
value: '${step.volume} μL',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 参数行
|
||||
Widget _buildParamRow(String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textTertiary,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ import '../../../core/localization/app_localizations.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../shared/widgets/common_button.dart';
|
||||
import '../../device/providers/run_state_provider.dart';
|
||||
import '../../programs/models/program.dart';
|
||||
import '../../programs/providers/programs_provider.dart';
|
||||
|
||||
/// 运行控制面板 - 暗色工业风格
|
||||
/// 显示当前程序信息、瓷套棒状态和运行控制按钮
|
||||
/// 显示当前程序信息和运行控制按钮
|
||||
class RunningControlPanel extends ConsumerWidget {
|
||||
const RunningControlPanel({super.key});
|
||||
|
||||
@@ -41,6 +42,7 @@ class RunningControlPanel extends ConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 当前选中程序显示
|
||||
@@ -96,40 +98,6 @@ class RunningControlPanel extends ConsumerWidget {
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 瓷套棒确认提示
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
border: Border.all(color: AppTheme.borderSubtle, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppTheme.statusStopped,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n?.ceramicNotInstalled ?? '瓷套棒: 未安装 — 禁止启动',
|
||||
style: const TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 控制按钮
|
||||
Row(
|
||||
children: [
|
||||
@@ -144,26 +112,12 @@ class RunningControlPanel extends ConsumerWidget {
|
||||
type: ButtonType.primary,
|
||||
enabled: selectedProgram != null,
|
||||
onPressed: selectedProgram != null
|
||||
? () => runNotifier.start(selectedProgram)
|
||||
? () => _confirmAndStart(context, runNotifier, selectedProgram, l10n)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 暂停/继续按钮(待机态禁用)
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: CommonButton(
|
||||
text: l10n?.pause ?? '暂停',
|
||||
icon: Icons.pause,
|
||||
type: ButtonType.secondary,
|
||||
enabled: false,
|
||||
onPressed: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 停止按钮(待机态禁用)
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
@@ -196,6 +150,7 @@ class RunningControlPanel extends ConsumerWidget {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 当前程序名称
|
||||
@@ -237,7 +192,7 @@ class RunningControlPanel extends ConsumerWidget {
|
||||
// 控制按钮
|
||||
Row(
|
||||
children: [
|
||||
// 开始/继续按钮
|
||||
// 暂停/继续按钮(运行中切换)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: SizedBox(
|
||||
@@ -245,27 +200,18 @@ class RunningControlPanel extends ConsumerWidget {
|
||||
child: CommonButton(
|
||||
text: runState.status == RunStatus.paused
|
||||
? (l10n?.continue_ ?? '继续')
|
||||
: (l10n?.run ?? '运行'),
|
||||
: (l10n?.pause ?? '暂停'),
|
||||
icon: runState.status == RunStatus.paused
|
||||
? Icons.play_arrow
|
||||
: Icons.play_arrow,
|
||||
: Icons.pause,
|
||||
type: ButtonType.primary,
|
||||
onPressed: () => runNotifier.resume(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 暂停按钮
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: CommonButton(
|
||||
text: l10n?.pause ?? '暂停',
|
||||
icon: Icons.pause,
|
||||
type: ButtonType.warning,
|
||||
onPressed: runState.status == RunStatus.paused
|
||||
? null
|
||||
: () => runNotifier.pause(),
|
||||
onPressed: () {
|
||||
if (runState.status == RunStatus.paused) {
|
||||
runNotifier.resume();
|
||||
} else {
|
||||
runNotifier.pause();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -284,37 +230,48 @@ class RunningControlPanel extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 状态指示
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: runState.status == RunStatus.paused
|
||||
? AppTheme.accentWarning
|
||||
: AppTheme.statusRunning,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
runState.status == RunStatus.paused
|
||||
? (l10n?.paused ?? '已暂停')
|
||||
: (l10n?.running ?? '运行中'),
|
||||
style: TextStyle(
|
||||
color: runState.status == RunStatus.paused
|
||||
? AppTheme.accentWarning
|
||||
: AppTheme.statusRunning,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
/// 显示瓷套棒放置确认对话框,确认后启动程序
|
||||
void _confirmAndStart(
|
||||
BuildContext context,
|
||||
RunStateNotifier runNotifier,
|
||||
Program program,
|
||||
AppLocalizations? l10n,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: AppTheme.cardBg,
|
||||
title: Text(
|
||||
l10n?.ceramicSleeveConfirm ?? '运行前请确认已安装瓷套棒',
|
||||
style: const TextStyle(color: AppTheme.textHeading),
|
||||
),
|
||||
content: Text(
|
||||
l10n?.ceramicSleeveConfirmMessage ?? '请确认已放置瓷套棒后再启动程序。',
|
||||
style: const TextStyle(color: AppTheme.textPrimary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(
|
||||
l10n?.cancel ?? '取消',
|
||||
style: const TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.accentPrimary,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
runNotifier.start(program);
|
||||
},
|
||||
child: Text(l10n?.confirm ?? '确认'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -5,17 +5,35 @@ import '../../../core/localization/app_localizations.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../shared/widgets/status_indicator.dart';
|
||||
import '../../device/providers/device_info_provider.dart';
|
||||
import '../../device/providers/serial_provider.dart';
|
||||
import '../../device/services/serial_port_service.dart';
|
||||
|
||||
/// 状态栏标签项数据
|
||||
class StatusBarTab {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
const StatusBarTab({required this.icon, required this.label});
|
||||
}
|
||||
|
||||
/// 状态栏组件 - 明亮工业风格
|
||||
/// 显示设备名称、实时时钟、系统状态、照明控制
|
||||
/// 显示: 设备名称 | 导航标签 | 灯按钮 | 状态指示器 | 实时时钟
|
||||
///
|
||||
/// [tabs] 不为空时,会在设备名右侧渲染胶囊样式的导航标签按钮组,
|
||||
/// 由父组件通过 [currentTabIndex] 和 [onTabChanged] 控制切换。
|
||||
class StatusBar extends ConsumerStatefulWidget {
|
||||
final bool isRunning;
|
||||
final VoidCallback? onLightToggle;
|
||||
final List<StatusBarTab> tabs;
|
||||
final int currentTabIndex;
|
||||
final ValueChanged<int>? onTabChanged;
|
||||
|
||||
const StatusBar({
|
||||
super.key,
|
||||
this.isRunning = false,
|
||||
this.onLightToggle,
|
||||
this.tabs = const [],
|
||||
this.currentTabIndex = 0,
|
||||
this.onTabChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -39,6 +57,7 @@ class _StatusBarState extends ConsumerState<StatusBar> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 更新当前时间显示
|
||||
void _updateTime() {
|
||||
final now = DateTime.now();
|
||||
_currentTime =
|
||||
@@ -49,6 +68,7 @@ class _StatusBarState extends ConsumerState<StatusBar> {
|
||||
|
||||
String _twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
|
||||
/// 切换照明开关
|
||||
Future<void> _onLightTap() async {
|
||||
widget.onLightToggle?.call();
|
||||
await ref.read(deviceInfoProvider.notifier).toggleLight();
|
||||
@@ -58,6 +78,7 @@ class _StatusBarState extends ConsumerState<StatusBar> {
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final deviceInfo = ref.watch(deviceInfoProvider);
|
||||
final serialState = ref.watch(serialConnectionStateProvider);
|
||||
|
||||
return Container(
|
||||
height: 56,
|
||||
@@ -74,27 +95,19 @@ class _StatusBarState extends ConsumerState<StatusBar> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.precision_manufacturing, color: Colors.white, size: 22),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
l10n?.deviceName ?? '污水毒品前处理一体机',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Icon(Icons.precision_manufacturing, color: Colors.white, size: 22),
|
||||
if (widget.tabs.isNotEmpty) ...[
|
||||
const SizedBox(width: 32),
|
||||
_buildNavTabs(),
|
||||
],
|
||||
const Spacer(),
|
||||
_LightToggleButton(
|
||||
isOn: deviceInfo.lightingOn,
|
||||
onTap: _onLightTap,
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
_SerialConnectionIndicator(state: serialState),
|
||||
const SizedBox(width: 20),
|
||||
StatusIndicator(
|
||||
text: widget.isRunning
|
||||
? (l10n?.running ?? '运行中')
|
||||
@@ -117,6 +130,92 @@ class _StatusBarState extends ConsumerState<StatusBar> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建标题栏内嵌的导航标签按钮组(胶囊样式)
|
||||
Widget _buildNavTabs() {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(widget.tabs.length, (index) {
|
||||
final tab = widget.tabs[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: index == widget.tabs.length - 1 ? 0 : 6),
|
||||
child: _NavTabButton(
|
||||
icon: tab.icon,
|
||||
label: tab.label,
|
||||
selected: index == widget.currentTabIndex,
|
||||
onTap: () => widget.onTabChanged?.call(index),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 标题栏内嵌的导航标签按钮(胶囊样式)
|
||||
/// 选中时使用半透明白色背景突出,未选中时仅显示文字
|
||||
class _NavTabButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _NavTabButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? Colors.white.withValues(alpha: 0.22)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? Colors.white.withValues(alpha: 0.35)
|
||||
: Colors.transparent,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 16,
|
||||
color: selected
|
||||
? Colors.white
|
||||
: Colors.white.withValues(alpha: 0.78),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected
|
||||
? Colors.white
|
||||
: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 14,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LightToggleButton extends StatelessWidget {
|
||||
@@ -154,3 +253,49 @@ class _LightToggleButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 串口连接状态指示器
|
||||
///
|
||||
/// 位于「设备运行状态」之前,反映当前 USB 串口的连接情况。
|
||||
class _SerialConnectionIndicator extends StatelessWidget {
|
||||
final SerialConnectionState state;
|
||||
const _SerialConnectionIndicator({required this.state});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connected = state == SerialConnectionState.connected;
|
||||
final connecting = state == SerialConnectionState.connecting;
|
||||
final text = connected
|
||||
? '已连接'
|
||||
: connecting
|
||||
? '连接中'
|
||||
: '未连接';
|
||||
final color = connected
|
||||
? AppTheme.statusRunning
|
||||
: connecting
|
||||
? AppTheme.statusPaused
|
||||
: AppTheme.statusStopped;
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import '../../../shared/widgets/common_button.dart';
|
||||
import '../providers/steps_provider.dart';
|
||||
import '../widgets/step_list.dart';
|
||||
import '../widgets/step_form.dart';
|
||||
import '../../home/widgets/status_bar.dart';
|
||||
import '../../device/providers/run_state_provider.dart';
|
||||
import '../../programs/providers/programs_provider.dart';
|
||||
|
||||
/// 程序详情页面
|
||||
/// 左侧步骤列表 + 右侧参数表单
|
||||
/// 布局:顶部状态栏(含导航tab) + 子工具栏(返回/程序名/保存) + 左侧步骤列表 + 右侧参数表单
|
||||
class ProgramDetailPage extends ConsumerStatefulWidget {
|
||||
final String programId;
|
||||
|
||||
@@ -35,61 +37,31 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
|
||||
final programsState = ref.watch(programsProvider);
|
||||
final program = programsState.programs.where((p) => p.id == _programIdInt).firstOrNull;
|
||||
final stepsState = ref.watch(stepsProvider(_programIdInt));
|
||||
final runState = ref.watch(runStateProvider);
|
||||
|
||||
// 详情页从程序管理进入,高亮"程序管理"tab(index=1)
|
||||
final tabs = <StatusBarTab>[
|
||||
StatusBarTab(icon: Icons.dashboard, label: l10n?.deviceControl ?? '设备控制'),
|
||||
StatusBarTab(icon: Icons.list_alt, label: l10n?.programs ?? '程序管理'),
|
||||
StatusBarTab(icon: Icons.settings, label: l10n?.settings ?? '系统设置'),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
color: AppTheme.backgroundColor,
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部导航栏
|
||||
Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/programs'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 程序名称
|
||||
Text(
|
||||
program?.name ?? (l10n?.detail ?? '程序详情'),
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 保存按钮
|
||||
CommonButton(
|
||||
text: l10n?.save ?? '保存',
|
||||
icon: Icons.save,
|
||||
type: ButtonType.primary,
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已保存'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
// 顶部状态栏(含导航tab),tab点击跳回首页对应 tab
|
||||
StatusBar(
|
||||
isRunning: runState.status == RunStatus.running,
|
||||
tabs: tabs,
|
||||
currentTabIndex: 1,
|
||||
onTabChanged: (index) => context.go('/?tab=$index'),
|
||||
),
|
||||
|
||||
// 子工具栏:返回按钮 + 程序名 + 保存按钮
|
||||
_buildSubToolbar(context, l10n, program?.name),
|
||||
|
||||
// 主内容区域
|
||||
Expanded(
|
||||
child: stepsState.isLoading
|
||||
@@ -172,6 +144,57 @@ class _ProgramDetailPageState extends ConsumerState<ProgramDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 子工具栏:返回按钮 + 程序名 + 保存按钮
|
||||
/// 位于状态栏下方,提供详情页特有的操作入口
|
||||
Widget _buildSubToolbar(BuildContext context, AppLocalizations? l10n, String? programName) {
|
||||
return Container(
|
||||
height: 56,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: l10n?.backToHome ?? '返回',
|
||||
onPressed: () => context.go('/?tab=1'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
programName ?? (l10n?.detail ?? '程序详情'),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textHeading,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
CommonButton(
|
||||
text: l10n?.save ?? '保存',
|
||||
icon: Icons.save,
|
||||
type: ButtonType.primary,
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已保存'),
|
||||
backgroundColor: AppTheme.successColor,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示添加步骤对话框
|
||||
void _showAddStepDialog(BuildContext context, WidgetRef ref) {
|
||||
showDialog(
|
||||
|
||||
@@ -34,9 +34,7 @@ class _StepFormState extends State<StepForm> {
|
||||
late TextEditingController _blowTimeController;
|
||||
|
||||
String _position = 'A1';
|
||||
String _mixSpeed = '中速';
|
||||
String _blowSpeed = '中速';
|
||||
int _needleSpeed = 5;
|
||||
int _speed = 5;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -48,9 +46,7 @@ class _StepFormState extends State<StepForm> {
|
||||
_blowTimeController = TextEditingController(text: '${widget.step?.blowTime ?? 0}');
|
||||
|
||||
_position = widget.step?.position ?? 'A1';
|
||||
_mixSpeed = widget.step?.mixSpeed ?? '中速';
|
||||
_blowSpeed = widget.step?.blowSpeed ?? '中速';
|
||||
_needleSpeed = widget.step?.needleSpeed ?? 5;
|
||||
_speed = widget.step?.speed ?? 5;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -179,53 +175,22 @@ class _StepFormState extends State<StepForm> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 速度选择
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _mixSpeed,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n?.mixSpeed ?? '混合速度',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: Constants.speedOptions.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _mixSpeed = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: _blowSpeed,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n?.blowSpeed ?? '吹气速度',
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
items: Constants.speedOptions.map((s) => DropdownMenuItem(value: s, child: Text(s))).toList(),
|
||||
onChanged: (value) {
|
||||
if (value != null) setState(() => _blowSpeed = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 下针速度滑块
|
||||
// 速度滑块
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${l10n?.needleSpeed ?? '下针速度'}: $_needleSpeed 档', style: TextStyle(color: AppTheme.textPrimary)),
|
||||
Text(
|
||||
'${l10n?.speed ?? '速度'}: $_speed 档',
|
||||
style: TextStyle(color: AppTheme.textPrimary),
|
||||
),
|
||||
Slider(
|
||||
value: _needleSpeed.toDouble(),
|
||||
value: _speed.toDouble(),
|
||||
min: 1,
|
||||
max: 10,
|
||||
divisions: 9,
|
||||
activeColor: AppTheme.primaryColor,
|
||||
onChanged: (value) {
|
||||
setState(() => _needleSpeed = value.round());
|
||||
setState(() => _speed = value.round());
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -259,10 +224,8 @@ class _StepFormState extends State<StepForm> {
|
||||
mixTime: int.tryParse(_mixTimeController.text) ?? 0,
|
||||
magnetTime: int.tryParse(_magnetTimeController.text) ?? 0,
|
||||
volume: int.tryParse(_volumeController.text) ?? 0,
|
||||
mixSpeed: _mixSpeed,
|
||||
blowSpeed: _blowSpeed,
|
||||
blowTime: int.tryParse(_blowTimeController.text) ?? 0,
|
||||
needleSpeed: _needleSpeed,
|
||||
speed: _speed,
|
||||
);
|
||||
|
||||
widget.onSave(step);
|
||||
|
||||
@@ -5,6 +5,8 @@ class Program {
|
||||
final String name;
|
||||
final String createdAt;
|
||||
final int status; // 1: 启用, 0: 停用
|
||||
final int temperature;
|
||||
final int airflowTime;
|
||||
|
||||
Program({
|
||||
this.id,
|
||||
@@ -12,6 +14,8 @@ class Program {
|
||||
required this.name,
|
||||
required this.createdAt,
|
||||
this.status = 1,
|
||||
this.temperature = 50,
|
||||
this.airflowTime = 60,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
@@ -21,6 +25,8 @@ class Program {
|
||||
'name': name,
|
||||
'created_at': createdAt,
|
||||
'status': status,
|
||||
'temperature': temperature,
|
||||
'airflow_time': airflowTime,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +37,8 @@ class Program {
|
||||
name: map['name'] as String,
|
||||
createdAt: map['created_at'] as String,
|
||||
status: map['status'] as int? ?? 1,
|
||||
temperature: map['temperature'] as int? ?? 50,
|
||||
airflowTime: map['airflow_time'] as int? ?? 60,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +48,8 @@ class Program {
|
||||
String? name,
|
||||
String? createdAt,
|
||||
int? status,
|
||||
int? temperature,
|
||||
int? airflowTime,
|
||||
}) {
|
||||
return Program(
|
||||
id: id ?? this.id,
|
||||
@@ -47,6 +57,8 @@ class Program {
|
||||
name: name ?? this.name,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
status: status ?? this.status,
|
||||
temperature: temperature ?? this.temperature,
|
||||
airflowTime: airflowTime ?? this.airflowTime,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,8 @@ class Step {
|
||||
final int mixTime;
|
||||
final int magnetTime;
|
||||
final int volume;
|
||||
final String mixSpeed;
|
||||
final String blowSpeed;
|
||||
final int blowTime;
|
||||
final int needleSpeed;
|
||||
final int speed;
|
||||
|
||||
Step({
|
||||
this.id,
|
||||
@@ -22,10 +20,8 @@ class Step {
|
||||
this.mixTime = 0,
|
||||
this.magnetTime = 0,
|
||||
this.volume = 0,
|
||||
this.mixSpeed = '中速',
|
||||
this.blowSpeed = '中速',
|
||||
this.blowTime = 0,
|
||||
this.needleSpeed = 5,
|
||||
this.speed = 5,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
@@ -38,10 +34,8 @@ class Step {
|
||||
'mix_time': mixTime,
|
||||
'magnet_time': magnetTime,
|
||||
'volume': volume,
|
||||
'mix_speed': mixSpeed,
|
||||
'blow_speed': blowSpeed,
|
||||
'blow_time': blowTime,
|
||||
'needle_speed': needleSpeed,
|
||||
'speed': speed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,10 +49,8 @@ class Step {
|
||||
mixTime: map['mix_time'] as int? ?? 0,
|
||||
magnetTime: map['magnet_time'] as int? ?? 0,
|
||||
volume: map['volume'] as int? ?? 0,
|
||||
mixSpeed: map['mix_speed'] as String? ?? '中速',
|
||||
blowSpeed: map['blow_speed'] as String? ?? '中速',
|
||||
blowTime: map['blow_time'] as int? ?? 0,
|
||||
needleSpeed: map['needle_speed'] as int? ?? 5,
|
||||
speed: map['speed'] as int? ?? 5,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,10 +63,8 @@ class Step {
|
||||
int? mixTime,
|
||||
int? magnetTime,
|
||||
int? volume,
|
||||
String? mixSpeed,
|
||||
String? blowSpeed,
|
||||
int? blowTime,
|
||||
int? needleSpeed,
|
||||
int? speed,
|
||||
}) {
|
||||
return Step(
|
||||
id: id ?? this.id,
|
||||
@@ -85,10 +75,8 @@ class Step {
|
||||
mixTime: mixTime ?? this.mixTime,
|
||||
magnetTime: magnetTime ?? this.magnetTime,
|
||||
volume: volume ?? this.volume,
|
||||
mixSpeed: mixSpeed ?? this.mixSpeed,
|
||||
blowSpeed: blowSpeed ?? this.blowSpeed,
|
||||
blowTime: blowTime ?? this.blowTime,
|
||||
needleSpeed: needleSpeed ?? this.needleSpeed,
|
||||
speed: speed ?? this.speed,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,13 @@ import 'package:file_picker/file_picker.dart';
|
||||
import 'dart:io';
|
||||
import '../../../core/localization/app_localizations.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../shared/services/toast_service.dart';
|
||||
import '../../../shared/widgets/common_button.dart';
|
||||
import '../models/program.dart';
|
||||
import '../providers/programs_provider.dart';
|
||||
import '../widgets/program_form_dialog.dart';
|
||||
import '../services/program_import_service.dart';
|
||||
import '../services/excel_import_service.dart';
|
||||
import '../services/excel_template_service.dart';
|
||||
|
||||
/// 程序管理页面
|
||||
class ProgramsPage extends ConsumerStatefulWidget {
|
||||
@@ -48,12 +50,6 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// 返回按钮
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => context.go('/'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
l10n?.programs ?? '程序管理',
|
||||
style: const TextStyle(
|
||||
@@ -70,6 +66,14 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
|
||||
onPressed: () => _showAddDialog(context, ref),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 下载模板按钮
|
||||
CommonButton(
|
||||
text: l10n?.downloadTemplate ?? '下载模板',
|
||||
icon: Icons.file_download,
|
||||
type: ButtonType.secondary,
|
||||
onPressed: () => _downloadTemplate(context),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 导入按钮
|
||||
CommonButton(
|
||||
text: l10n?.importProgram ?? '导入',
|
||||
@@ -413,7 +417,7 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
|
||||
// 选择文件
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['json'],
|
||||
allowedExtensions: ['xlsx'],
|
||||
allowMultiple: false,
|
||||
);
|
||||
|
||||
@@ -423,38 +427,39 @@ class _ProgramsPageState extends ConsumerState<ProgramsPage> {
|
||||
|
||||
final file = result.files.first;
|
||||
if (file.path == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('无法读取文件'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ToastService.showError(context, '无法读取文件');
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取文件内容
|
||||
final jsonContent = await File(file.path!).readAsString();
|
||||
|
||||
// 导入程序
|
||||
final importedCount = await ProgramImportService.instance.importFromJson(jsonContent);
|
||||
// 解析并写入数据库
|
||||
final importedCount =
|
||||
await ExcelImportService.instance.importFromExcel(File(file.path!));
|
||||
|
||||
// 刷新程序列表
|
||||
ref.read(programsProvider.notifier).loadPrograms();
|
||||
|
||||
// 显示结果
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('成功导入 $importedCount 个程序'),
|
||||
backgroundColor: importedCount > 0 ? AppTheme.successColor : AppTheme.warningColor,
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (importedCount > 0) {
|
||||
ToastService.showSuccess(context, '成功处理 $importedCount 个程序');
|
||||
} else {
|
||||
ToastService.showWarning(context, 'Excel 中无有效程序数据');
|
||||
}
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('导入失败: ${e.toString()}'),
|
||||
backgroundColor: AppTheme.errorColor,
|
||||
),
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
ToastService.showError(context, '导入失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 下载 Excel 模板
|
||||
Future<void> _downloadTemplate(BuildContext context) async {
|
||||
try {
|
||||
final path = await ExcelTemplateService.instance.generateTemplate();
|
||||
if (!context.mounted) return;
|
||||
ToastService.showSuccess(context, '模板已保存: $path');
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ToastService.showError(context, '生成模板失败: ${e.toString()}');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
237
lib/features/programs/services/excel_import_service.dart
Normal file
237
lib/features/programs/services/excel_import_service.dart
Normal file
@@ -0,0 +1,237 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:excel/excel.dart';
|
||||
|
||||
import '../models/program.dart';
|
||||
import '../models/step.dart';
|
||||
import '../services/program_service.dart';
|
||||
import 'excel_template_service.dart';
|
||||
|
||||
/// Excel 导入服务
|
||||
///
|
||||
/// 解析用户填好的 .xlsx 并通过 [ProgramService] 写入数据库。
|
||||
/// 模板结构与 [ExcelTemplateService] 一致:Programs + Steps 双表,
|
||||
/// 通过 program_code 关联。
|
||||
class ExcelImportService {
|
||||
static final ExcelImportService instance = ExcelImportService._internal();
|
||||
final ProgramService _programService = ProgramService.instance;
|
||||
|
||||
ExcelImportService._internal();
|
||||
|
||||
/// 从 .xlsx 文件导入程序,返回成功处理的程序数量(新建 + 覆盖)
|
||||
///
|
||||
/// 行为:
|
||||
/// - code 不存在:新建程序 + 写入步骤
|
||||
/// - code 已存在:全量覆盖程序字段,并删除旧步骤后写入新步骤
|
||||
Future<int> importFromExcel(File file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
final excel = Excel.decodeBytes(bytes);
|
||||
|
||||
final programsSheet = _findSheet(excel, ExcelTemplateService.sheetPrograms);
|
||||
if (programsSheet == null) {
|
||||
throw const ExcelImportException('缺少 Programs 工作表');
|
||||
}
|
||||
|
||||
final programs = _parsePrograms(programsSheet);
|
||||
if (programs.isEmpty) {
|
||||
throw const ExcelImportException('Programs 表无有效数据');
|
||||
}
|
||||
|
||||
// 已有 code → 完整 Program(用于覆盖时取 id)
|
||||
final existing = await _programService.getAllPrograms();
|
||||
final existingByCode = <String, Program>{
|
||||
for (final p in existing) p.code: p,
|
||||
};
|
||||
|
||||
// 解析步骤
|
||||
final stepsSheet = _findSheet(excel, ExcelTemplateService.sheetSteps);
|
||||
final stepsByCode = stepsSheet == null
|
||||
? <String, List<_RawStep>>{}
|
||||
: _parseSteps(stepsSheet);
|
||||
|
||||
int processedCount = 0;
|
||||
for (final program in programs) {
|
||||
try {
|
||||
final existingProgram = existingByCode[program.code];
|
||||
final int programId;
|
||||
if (existingProgram == null) {
|
||||
programId = await _programService.addProgram(program);
|
||||
} else {
|
||||
// 全量覆盖:保留 id,其余字段取 Excel
|
||||
programId = existingProgram.id!;
|
||||
await _programService.updateProgram(
|
||||
existingProgram.copyWith(
|
||||
name: program.name,
|
||||
temperature: program.temperature,
|
||||
airflowTime: program.airflowTime,
|
||||
status: program.status,
|
||||
createdAt: program.createdAt,
|
||||
),
|
||||
);
|
||||
// 清空旧步骤
|
||||
final oldSteps =
|
||||
await _programService.getStepsByProgramId(programId);
|
||||
if (oldSteps.isNotEmpty) {
|
||||
await _programService.deleteSteps(
|
||||
oldSteps.map((s) => s.id!).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 写入新步骤(按 step_no 排序后重新编号为 1..N)
|
||||
final rawSteps = stepsByCode[program.code] ?? const <_RawStep>[];
|
||||
rawSteps.sort((a, b) => a.stepNo.compareTo(b.stepNo));
|
||||
for (var i = 0; i < rawSteps.length; i++) {
|
||||
final raw = rawSteps[i];
|
||||
final step = Step(
|
||||
programId: programId,
|
||||
stepNo: i + 1,
|
||||
position: raw.position,
|
||||
name: raw.name,
|
||||
mixTime: raw.mixTime,
|
||||
magnetTime: raw.magnetTime,
|
||||
volume: raw.volume,
|
||||
blowTime: raw.blowTime,
|
||||
speed: raw.speed,
|
||||
);
|
||||
await _programService.addStep(step);
|
||||
}
|
||||
|
||||
processedCount++;
|
||||
} catch (_) {
|
||||
// 单条失败不影响其他程序
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return processedCount;
|
||||
}
|
||||
|
||||
Sheet? _findSheet(Excel excel, String name) {
|
||||
for (final entry in excel.tables.entries) {
|
||||
if (entry.key == name) return entry.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 解析 Programs 表
|
||||
/// 返回不含 id 的 Program 列表(createdAt 由调用方填充)
|
||||
List<Program> _parsePrograms(Sheet sheet) {
|
||||
final rows = _dataRows(sheet);
|
||||
final results = <Program>[];
|
||||
|
||||
for (final cells in rows) {
|
||||
final code = _readString(cells, 0);
|
||||
final name = _readString(cells, 1);
|
||||
if (code.isEmpty || name.isEmpty) continue;
|
||||
|
||||
results.add(
|
||||
Program(
|
||||
code: code,
|
||||
name: name,
|
||||
createdAt: DateTime.now().toString().split('.').first,
|
||||
status: _readInt(cells, 4, 1) == 0 ? 0 : 1,
|
||||
temperature: _readInt(cells, 2, 50),
|
||||
airflowTime: _readInt(cells, 3, 60),
|
||||
),
|
||||
);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// 解析 Steps 表
|
||||
/// 以 program_code 为 key 分组
|
||||
Map<String, List<_RawStep>> _parseSteps(Sheet sheet) {
|
||||
final rows = _dataRows(sheet);
|
||||
final map = <String, List<_RawStep>>{};
|
||||
|
||||
for (final cells in rows) {
|
||||
final programCode = _readString(cells, 0);
|
||||
if (programCode.isEmpty) continue;
|
||||
final position = _readString(cells, 2, 'A1');
|
||||
final name = _readString(cells, 3);
|
||||
if (name.isEmpty) continue;
|
||||
|
||||
final raw = _RawStep(
|
||||
stepNo: _readInt(cells, 1, 0),
|
||||
position: position,
|
||||
name: name,
|
||||
mixTime: _readInt(cells, 4, 0),
|
||||
magnetTime: _readInt(cells, 5, 0),
|
||||
volume: _readInt(cells, 6, 0),
|
||||
blowTime: _readInt(cells, 7, 0),
|
||||
speed: _readInt(cells, 8, 5),
|
||||
);
|
||||
map.putIfAbsent(programCode, () => []).add(raw);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// 跳过表头和说明行(首行表头,后续以"说明"开头的视为说明)
|
||||
List<List<Object?>> _dataRows(Sheet sheet) {
|
||||
final result = <List<Object?>>[];
|
||||
for (var i = 1; i < sheet.rows.length; i++) {
|
||||
final row = sheet.rows[i];
|
||||
// 跳过空行
|
||||
if (row.every((c) => c == null || c.value == null)) continue;
|
||||
final firstCell = _asTrimmed(row.first?.value);
|
||||
if (firstCell.startsWith('说明')) continue;
|
||||
result.add(row.map((c) => c?.value).toList(growable: false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
String _readString(List<Object?> row, int index, [String fallback = '']) {
|
||||
if (index >= row.length) return fallback;
|
||||
final v = row[index];
|
||||
if (v == null) return fallback;
|
||||
final s = v.toString().trim();
|
||||
return s.isEmpty ? fallback : s;
|
||||
}
|
||||
|
||||
int _readInt(List<Object?> row, int index, int fallback) {
|
||||
if (index >= row.length) return fallback;
|
||||
final v = row[index];
|
||||
if (v == null) return fallback;
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
final parsed = int.tryParse(v.toString().trim());
|
||||
return parsed ?? fallback;
|
||||
}
|
||||
|
||||
String _asTrimmed(Object? v) {
|
||||
if (v == null) return '';
|
||||
return v.toString().trim();
|
||||
}
|
||||
}
|
||||
|
||||
class _RawStep {
|
||||
final int stepNo;
|
||||
final String position;
|
||||
final String name;
|
||||
final int mixTime;
|
||||
final int magnetTime;
|
||||
final int volume;
|
||||
final int blowTime;
|
||||
final int speed;
|
||||
|
||||
_RawStep({
|
||||
required this.stepNo,
|
||||
required this.position,
|
||||
required this.name,
|
||||
required this.mixTime,
|
||||
required this.magnetTime,
|
||||
required this.volume,
|
||||
required this.blowTime,
|
||||
required this.speed,
|
||||
});
|
||||
}
|
||||
|
||||
/// Excel 导入异常
|
||||
class ExcelImportException implements Exception {
|
||||
final String message;
|
||||
const ExcelImportException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
161
lib/features/programs/services/excel_template_service.dart
Normal file
161
lib/features/programs/services/excel_template_service.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:excel/excel.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
/// Excel 模板服务
|
||||
///
|
||||
/// 生成 .xlsx 模板用于程序/步骤批量导入。
|
||||
/// 模板包含两张表:
|
||||
/// - Programs:程序基础信息
|
||||
/// - Steps:步骤参数(通过 program_code 与 Programs 关联)
|
||||
class ExcelTemplateService {
|
||||
static final ExcelTemplateService instance = ExcelTemplateService._internal();
|
||||
|
||||
ExcelTemplateService._internal();
|
||||
|
||||
/// 工作表名称
|
||||
static const String sheetPrograms = 'Programs';
|
||||
static const String sheetSteps = 'Steps';
|
||||
|
||||
/// 模板文件名
|
||||
static const String templateFilename = 'program_template.xlsx';
|
||||
|
||||
/// Android 端写入公共 Downloads 的 MethodChannel(对应 MainActivity.kt)
|
||||
static const MethodChannel _downloadsChannel =
|
||||
MethodChannel('com.xiarui.kuaishai2/downloads');
|
||||
|
||||
/// 生成模板并保存到下载目录,返回可显示的路径字符串
|
||||
///
|
||||
/// - Android:通过 MediaStore 写入公共 Downloads (`/storage/emulated/0/Download/`)
|
||||
/// - 其它平台:使用 [getDownloadsDirectory],不可用时回退到应用文档目录
|
||||
Future<String> generateTemplate() async {
|
||||
final excel = Excel.createExcel();
|
||||
final bytes = _buildTemplateBytes(excel);
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final saved = await _downloadsChannel.invokeMethod<String>(
|
||||
'saveToDownloads',
|
||||
<String, dynamic>{
|
||||
'filename': templateFilename,
|
||||
'bytes': bytes,
|
||||
},
|
||||
);
|
||||
if (saved == null) {
|
||||
throw StateError('保存到 Downloads 失败:未返回路径');
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
final dir =
|
||||
await getDownloadsDirectory() ?? await getApplicationDocumentsDirectory();
|
||||
final file = File('${dir.path}/$templateFilename');
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
/// 构建模板字节流(可在测试中直接调用)
|
||||
List<int> _buildTemplateBytes(Excel excel) {
|
||||
// 默认创建的第一个 sheet 重命名
|
||||
final defaultSheet = excel.getDefaultSheet();
|
||||
if (defaultSheet != null && defaultSheet != sheetPrograms) {
|
||||
excel.rename(defaultSheet, sheetPrograms);
|
||||
}
|
||||
|
||||
_writeProgramsSheet(excel);
|
||||
_writeStepsSheet(excel);
|
||||
return excel.encode()!;
|
||||
}
|
||||
|
||||
void _writeProgramsSheet(Excel excel) {
|
||||
final sheet = excel[sheetPrograms];
|
||||
final headerStyle = CellStyle(
|
||||
bold: true,
|
||||
backgroundColorHex: ExcelColor.fromHexString('FFD9E1F2'),
|
||||
horizontalAlign: HorizontalAlign.Center,
|
||||
);
|
||||
|
||||
// 表头
|
||||
final headers = ['code', 'name', 'temperature', 'airflowTime', 'status'];
|
||||
sheet.appendRow(headers.map((h) => TextCellValue(h)).toList());
|
||||
for (var i = 0; i < headers.length; i++) {
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0))
|
||||
.cellStyle = headerStyle;
|
||||
}
|
||||
|
||||
// 示例行
|
||||
final sample1 = ['P001', '示例程序-标准流程', 50, 60, 1];
|
||||
final sample2 = ['P002', '示例程序-快速流程', 45, 30, 1];
|
||||
sheet.appendRow(sample1.map((v) => TextCellValue(v.toString())).toList());
|
||||
sheet.appendRow(sample2.map((v) => TextCellValue(v.toString())).toList());
|
||||
|
||||
// 说明行(用前缀标记,避免被解析)
|
||||
final note = '说明:code 必填且唯一;name 必填;temperature/airflowTime 整数;status:1启用 0停用';
|
||||
sheet.appendRow([TextCellValue(note)]);
|
||||
|
||||
// 列宽
|
||||
sheet.setColumnWidth(0, 14);
|
||||
sheet.setColumnWidth(1, 26);
|
||||
sheet.setColumnWidth(2, 14);
|
||||
sheet.setColumnWidth(3, 14);
|
||||
sheet.setColumnWidth(4, 10);
|
||||
}
|
||||
|
||||
void _writeStepsSheet(Excel excel) {
|
||||
final sheet = excel[sheetSteps];
|
||||
final headerStyle = CellStyle(
|
||||
bold: true,
|
||||
backgroundColorHex: ExcelColor.fromHexString('FFD9E1F2'),
|
||||
horizontalAlign: HorizontalAlign.Center,
|
||||
);
|
||||
|
||||
final headers = [
|
||||
'program_code',
|
||||
'step_no',
|
||||
'position',
|
||||
'name',
|
||||
'mixTime',
|
||||
'magnetTime',
|
||||
'volume',
|
||||
'blowTime',
|
||||
'speed',
|
||||
];
|
||||
sheet.appendRow(headers.map((h) => TextCellValue(h)).toList());
|
||||
for (var i = 0; i < headers.length; i++) {
|
||||
sheet
|
||||
.cell(CellIndex.indexByColumnRow(columnIndex: i, rowIndex: 0))
|
||||
.cellStyle = headerStyle;
|
||||
}
|
||||
|
||||
// 示例行:P001 / P002 各两步
|
||||
final samples = <List<Object>>[
|
||||
['P001', 1, 'A1', '加样', 30, 0, 500, 0, 5],
|
||||
['P001', 2, 'A2', '混合', 60, 10, 300, 30, 5],
|
||||
['P002', 1, 'B1', '混合', 20, 5, 400, 0, 4],
|
||||
];
|
||||
for (final row in samples) {
|
||||
sheet.appendRow(row.map((v) {
|
||||
if (v is num) return IntCellValue(v.toInt());
|
||||
return TextCellValue(v.toString());
|
||||
}).toList());
|
||||
}
|
||||
|
||||
// 说明行
|
||||
final note =
|
||||
'说明:program_code 对应 Programs.code;step_no 整数从 1 开始;position 形如 A1/B2;mixTime/magnetTime/volume/blowTime 单位秒或微升;speed 1-10';
|
||||
sheet.appendRow([TextCellValue(note)]);
|
||||
|
||||
// 列宽
|
||||
sheet.setColumnWidth(0, 14);
|
||||
sheet.setColumnWidth(1, 10);
|
||||
sheet.setColumnWidth(2, 12);
|
||||
sheet.setColumnWidth(3, 14);
|
||||
sheet.setColumnWidth(4, 10);
|
||||
sheet.setColumnWidth(5, 12);
|
||||
sheet.setColumnWidth(6, 10);
|
||||
sheet.setColumnWidth(7, 10);
|
||||
sheet.setColumnWidth(8, 8);
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import 'dart:convert';
|
||||
import '../../programs/models/program.dart';
|
||||
import '../../programs/models/step.dart';
|
||||
import '../../programs/services/program_service.dart';
|
||||
|
||||
/// 程序导入服务
|
||||
class ProgramImportService {
|
||||
static final ProgramImportService instance = ProgramImportService._internal();
|
||||
final ProgramService _programService = ProgramService.instance;
|
||||
|
||||
ProgramImportService._internal();
|
||||
|
||||
/// 从 JSON 字符串导入程序
|
||||
/// 返回导入的程序数量
|
||||
Future<int> importFromJson(String jsonContent) async {
|
||||
final data = jsonDecode(jsonContent);
|
||||
|
||||
// 支持单个程序或程序数组
|
||||
final List<dynamic> programsData;
|
||||
if (data is List) {
|
||||
programsData = data;
|
||||
} else if (data is Map && data.containsKey('programs')) {
|
||||
programsData = data['programs'] as List;
|
||||
} else {
|
||||
programsData = [data];
|
||||
}
|
||||
|
||||
int importedCount = 0;
|
||||
|
||||
for (final programData in programsData) {
|
||||
try {
|
||||
// 验证必填字段
|
||||
if (!_validateProgramData(programData)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 检查编号是否已存在
|
||||
final existingPrograms = await _programService.getAllPrograms();
|
||||
final code = programData['code'] as String;
|
||||
if (existingPrograms.any((p) => p.code == code)) {
|
||||
// 编号已存在,跳过或使用新编号
|
||||
continue;
|
||||
}
|
||||
|
||||
// 创建程序
|
||||
final program = Program(
|
||||
code: code,
|
||||
name: programData['name'] as String,
|
||||
createdAt: programData['createdAt'] ?? DateTime.now().toString().split('.')[0],
|
||||
status: programData['status'] ?? 1,
|
||||
);
|
||||
|
||||
final programId = await _programService.addProgram(program);
|
||||
|
||||
// 导入步骤
|
||||
final stepsData = programData['steps'] as List?;
|
||||
if (stepsData != null) {
|
||||
for (int i = 0; i < stepsData.length; i++) {
|
||||
final stepData = stepsData[i];
|
||||
final step = Step(
|
||||
programId: programId,
|
||||
stepNo: i + 1,
|
||||
position: stepData['position'] as String? ?? 'A1',
|
||||
name: stepData['name'] as String? ?? '步骤${i + 1}',
|
||||
mixTime: stepData['mixTime'] as int? ?? 0,
|
||||
magnetTime: stepData['magnetTime'] as int? ?? 0,
|
||||
volume: stepData['volume'] as int? ?? 0,
|
||||
mixSpeed: stepData['mixSpeed'] as String? ?? '中速',
|
||||
blowSpeed: stepData['blowSpeed'] as String? ?? '中速',
|
||||
blowTime: stepData['blowTime'] as int? ?? 0,
|
||||
needleSpeed: stepData['needleSpeed'] as int? ?? 5,
|
||||
);
|
||||
await _programService.addStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
importedCount++;
|
||||
} catch (e) {
|
||||
// 忽略单个程序导入错误
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return importedCount;
|
||||
}
|
||||
|
||||
/// 验证程序数据
|
||||
bool _validateProgramData(Map<String, dynamic> data) {
|
||||
return data.containsKey('code') &&
|
||||
data.containsKey('name') &&
|
||||
data['code'] is String &&
|
||||
data['name'] is String;
|
||||
}
|
||||
|
||||
/// 导出程序为 JSON
|
||||
Future<String> exportToJson(List<int> programIds) async {
|
||||
final programs = [];
|
||||
|
||||
for (final id in programIds) {
|
||||
final program = await _programService.getProgramById(id);
|
||||
if (program == null) continue;
|
||||
|
||||
final steps = await _programService.getStepsByProgramId(id);
|
||||
|
||||
programs.add({
|
||||
'code': program.code,
|
||||
'name': program.name,
|
||||
'createdAt': program.createdAt,
|
||||
'status': program.status,
|
||||
'steps': steps.map((s) => {
|
||||
'position': s.position,
|
||||
'name': s.name,
|
||||
'mixTime': s.mixTime,
|
||||
'magnetTime': s.magnetTime,
|
||||
'volume': s.volume,
|
||||
'mixSpeed': s.mixSpeed,
|
||||
'blowSpeed': s.blowSpeed,
|
||||
'blowTime': s.blowTime,
|
||||
'needleSpeed': s.needleSpeed,
|
||||
}).toList(),
|
||||
});
|
||||
}
|
||||
|
||||
return jsonEncode({'programs': programs});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/localization/app_localizations.dart';
|
||||
import '../../../core/theme/app_theme.dart';
|
||||
import '../../../shared/utils/constants.dart';
|
||||
import '../../../shared/widgets/common_button.dart';
|
||||
import '../models/program.dart';
|
||||
import '../providers/programs_provider.dart';
|
||||
@@ -21,6 +22,8 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late TextEditingController _codeController;
|
||||
late TextEditingController _nameController;
|
||||
late TextEditingController _temperatureController;
|
||||
late TextEditingController _airflowTimeController;
|
||||
bool _isEnabled = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
@@ -29,6 +32,10 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
|
||||
super.initState();
|
||||
_codeController = TextEditingController(text: widget.program?.code ?? '');
|
||||
_nameController = TextEditingController(text: widget.program?.name ?? '');
|
||||
_temperatureController =
|
||||
TextEditingController(text: '${widget.program?.temperature ?? 50}');
|
||||
_airflowTimeController =
|
||||
TextEditingController(text: '${widget.program?.airflowTime ?? 60}');
|
||||
_isEnabled = widget.program?.status == 1;
|
||||
}
|
||||
|
||||
@@ -36,6 +43,8 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
|
||||
void dispose() {
|
||||
_codeController.dispose();
|
||||
_nameController.dispose();
|
||||
_temperatureController.dispose();
|
||||
_airflowTimeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -95,6 +104,38 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 温度和吹气时间
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _temperatureController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '${l10n?.temperature ?? '温度'} (°C)',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _airflowTimeController,
|
||||
decoration: InputDecoration(
|
||||
labelText: '${l10n?.airflowTime ?? '吹气时间'} (${Constants.timeUnitSeconds})',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 状态开关
|
||||
Row(
|
||||
children: [
|
||||
@@ -161,6 +202,8 @@ class _ProgramFormDialogState extends ConsumerState<ProgramFormDialog> {
|
||||
name: _nameController.text.trim(),
|
||||
createdAt: widget.program?.createdAt ?? now,
|
||||
status: _isEnabled ? 1 : 0,
|
||||
temperature: int.tryParse(_temperatureController.text) ?? 50,
|
||||
airflowTime: int.tryParse(_airflowTimeController.text) ?? 60,
|
||||
);
|
||||
|
||||
bool success;
|
||||
|
||||
@@ -5,11 +5,9 @@ import '../../../core/theme/app_theme.dart';
|
||||
import '../../../shared/widgets/common_button.dart';
|
||||
import '../widgets/language_panel.dart';
|
||||
import '../widgets/password_panel.dart';
|
||||
import '../widgets/serial_config_panel.dart';
|
||||
import '../widgets/usb_import_panel.dart';
|
||||
|
||||
/// 设置页菜单
|
||||
enum _SettingsMenu { upgrade, language, password, usbImport, serialConfig }
|
||||
enum _SettingsMenu { upgrade, language, password }
|
||||
|
||||
/// 系统设置页面
|
||||
class SettingsPage extends ConsumerStatefulWidget {
|
||||
@@ -70,14 +68,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
onTap: () => setState(
|
||||
() => _currentMenu = _SettingsMenu.upgrade),
|
||||
),
|
||||
// 串口配置
|
||||
_buildMenuItem(
|
||||
icon: Icons.settings_input_hdmi,
|
||||
title: '串口配置',
|
||||
selected: _currentMenu == _SettingsMenu.serialConfig,
|
||||
onTap: () => setState(
|
||||
() => _currentMenu = _SettingsMenu.serialConfig),
|
||||
),
|
||||
// 语言设置
|
||||
_buildMenuItem(
|
||||
icon: Icons.language,
|
||||
@@ -94,14 +84,6 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
onTap: () => setState(
|
||||
() => _currentMenu = _SettingsMenu.password),
|
||||
),
|
||||
// U盘导入
|
||||
_buildMenuItem(
|
||||
icon: Icons.usb,
|
||||
title: l10n?.usbImport ?? 'U盘导入',
|
||||
selected: _currentMenu == _SettingsMenu.usbImport,
|
||||
onTap: () => setState(
|
||||
() => _currentMenu = _SettingsMenu.usbImport),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -127,10 +109,8 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||
|
||||
Widget _buildContent() {
|
||||
return switch (_currentMenu) {
|
||||
_SettingsMenu.serialConfig => const SerialConfigPanel(),
|
||||
_SettingsMenu.language => const LanguagePanel(),
|
||||
_SettingsMenu.password => const PasswordPanel(),
|
||||
_SettingsMenu.usbImport => const UsbImportPanel(),
|
||||
_SettingsMenu.upgrade => _buildUpgradeContent(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'core/theme/app_theme.dart';
|
||||
import 'core/localization/app_localizations.dart';
|
||||
import 'core/localization/locale_provider.dart';
|
||||
import 'core/database/database_service.dart';
|
||||
import 'features/device/providers/serial_provider.dart';
|
||||
|
||||
/// 应用入口
|
||||
void main() async {
|
||||
@@ -22,7 +23,17 @@ void main() async {
|
||||
final db = DatabaseService.instance;
|
||||
await db.database;
|
||||
await db.initTestData();
|
||||
runApp(const ProviderScope(child: KuaishaiApp()));
|
||||
|
||||
// 使用 ProviderContainer 在 runApp 之前触发启动自动连接
|
||||
final container = ProviderContainer();
|
||||
container.read(autoSerialConnectProvider);
|
||||
|
||||
runApp(
|
||||
UncontrolledProviderScope(
|
||||
container: container,
|
||||
child: const KuaishaiApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 应用主体
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
/// 常量定义
|
||||
class Constants {
|
||||
// 速度选项
|
||||
static const List<String> speedOptions = ['低速', '中速', '高速'];
|
||||
|
||||
// 下针速度档位
|
||||
static const List<int> needleSpeedLevels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
// 速度档位
|
||||
static const int minSpeed = 1;
|
||||
static const int maxSpeed = 10;
|
||||
|
||||
// 孔位列表
|
||||
static const List<String> positions = [
|
||||
|
||||
124
pubspec.lock
124
pubspec.lock
@@ -25,6 +25,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.4"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: cb6a278ef2dbb298455e1a713bda08524a175630ec643a242c399c932a0a1f7d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.6.1"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -89,6 +97,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_assets:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: code_assets
|
||||
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -169,6 +185,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
equatable:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
excel:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: excel
|
||||
sha256: "1a15327dcad260d5db21d1f6e04f04838109b39a2f6a84ea486ceda36e468780"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -277,6 +309,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.1.3"
|
||||
hooks:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: hooks
|
||||
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
hotreloader:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -293,6 +333,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.20.2"
|
||||
jni:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni
|
||||
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
jni_flutter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: jni_flutter
|
||||
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -365,6 +421,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
objective_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: objective_c
|
||||
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.1"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -381,6 +445,30 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
path_provider:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: path_provider
|
||||
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
path_provider_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_android
|
||||
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.1"
|
||||
path_provider_foundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_foundation
|
||||
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.0"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -405,6 +493,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -437,6 +533,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
record_use:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: record_use
|
||||
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.0"
|
||||
riverpod:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -650,8 +754,16 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
usb_serial:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: usb_serial
|
||||
sha256: a605a600e34e7f28d4e80851ca3999ef747e42e406138887b8a88b8c382a8b07
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.5.2"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
|
||||
@@ -706,6 +818,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -716,4 +836,4 @@ packages:
|
||||
version: "3.1.3"
|
||||
sdks:
|
||||
dart: ">=3.11.5 <4.0.0"
|
||||
flutter: ">=3.38.0"
|
||||
flutter: ">=3.38.4"
|
||||
|
||||
12
pubspec.yaml
12
pubspec.yaml
@@ -31,9 +31,21 @@ dependencies:
|
||||
# 文件选择器
|
||||
file_picker: ^8.1.7
|
||||
|
||||
# 路径解析(保存导入/导出文件)
|
||||
path_provider: ^2.1.5
|
||||
|
||||
# Excel 读写(生成导入模板 + 解析用户填好的 .xlsx)
|
||||
excel: ^4.0.2
|
||||
|
||||
# 国际化
|
||||
intl: ^0.20.2
|
||||
|
||||
# USB 串口通信(Android USB Host 模式下的 CH340/FTDI/CP210x/PL2303 等芯片)
|
||||
usb_serial: ^0.5.0
|
||||
|
||||
# 串口消息 ID 生成(UUID v4)
|
||||
uuid: ^4.5.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
Reference in New Issue
Block a user