pQ2)*V*TdwjFOtHvV|;OsiDqHi=6%)o4b!)x$)%9pGTsE z-JL={-Ffv+T87W(Xpooq<`r*VzWQcgBN$$`u}f>-ZQI1BB8ykN*=e4rIsJx9>z}*o zo~|9I;xof literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..4d6372eebdb28e45604e46eeda8dd24651419bc0 GIT binary patch literal 1443 zcmb`G{WsKk6vsdJTdFg%tJav9_E4vzrOaqkWF|A724Nly!y+?N9`Y V6wZ}5(X(D_N(?!*n3`|_r0Hc?=PQw&*vnU?QTFY zB_M sH|!j$PP;I}?dppoE_gA(4uc!jV&0!l7_;&p2^pxNo>PEcNJv za5_RT$o2Mf!<+r?&EbHH6nMoTsDOa;mN(wv8RNsHpG)`^ymG-S5By8 =l9iVXzN_eG%Xg2@Xeq76tTZ*dGh~Lo9vl;Zfs+W#BydUw zCkZ$o1LqWQO$FC9aKlLl*7x9^0q%0}$OMlp@Kk_jHXOjofdePND+j!A{q!8~Jn+s3 z?~~w@4?egS02}8NuulUA=L~QQfm;MzCGd)XhiftT;+zFO&JVyp2mBww?;QByS_1w! zrQlx%{^cMj0|Bo1FjwY@Q8?Hx0cIPF*@-ZRFpPc#bBw{5@tD(5%sClzIfl8WU~V#u zm5Q;_F!wa$BSpqhN>W@2De?TKWR*!ujY;Yylk_X5#~V!L*Gw~;$% 4Q8~Mad z@`-kG?yb$a9cHIApZDVZ^U6Xkp<*4rU82O7%}0jjHlK{id@?-wpN*fCHXyXh(bLt* zPc}H-x0e4E&nQ>y%B-(EL=9}RyC%MyX=upHuFhAk&MLbsF0LP-q`XnH78@fT+pKPW zu72MW`|?8h t^tz$iC}ZwLp4tB;Q49K!QCF3@!iB1qOI=?w z7In!}F~ij(18UYUjnbmC!qKhPo%24?8U1x{7o(+?^Zu0Hx81|FuS?bJ0jgBhEMzf< zCgUq7r2OCB(`XkKcN-TL>u5y#dD6D!)5W?`O5)V^>jb)P)GBdy%t$uUMpf$SNV31$ zb||OojAbvMP?T@$h_ZiFLFVHDmbyMhJF|-_)HX3%m=CDI+ID$0^C>kzxprBW)hw(v zr!Gmda);ICoQyhV_oP5+C%?jcG8v+D@9f?Dk*!BxY}dazmrT@64UrP3hlslANK)bq z$67n83eh}OeW&SV@HG95P|bjfqJ7gw$e+`Hxo!4cx`jdK1bJ>YDSpGKLPZ^1cv$ek zIB?0S<#tX?SJCLWdMd{-ME?$hc7A$zBOdIJ)4!KcAwb=VMov)nK;9z>x~rfT1>dS+ zZ6#`2v@`jgbqq)P22H)Tx2CpmM^o1$B+xT6`(v%5xJ(?j#>Q$+rx_R|7TzDZe{J6q zG1*EcU%tE?!kO%^M;3aM6JN*LAKUVb^xz8-Pxo#jR5(-KBeLJvA@-gxNHx0M-ZJLl z;#JwQoh~9V?`UVo#}{6ka@II>++D@%KqGpMdlQ}?9E*wFcf5(#XQnP$Dk5~%iX^>f z %$y;?M0BLp{O3a(-4A?ewryHrrD%cx#Q^%KY1H zNre$ve+vceSLZcNY4U(RBX&)oZn*Py()h)XkE?PL$!bNb{N5FVI2Y%LKEm%yvpyTP z(1P?z ~7YxD~Rf< (a@_y` literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + ++ + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ ++ + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..2c0b9ad --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,26 @@ +allprojects { + repositories { + maven { url = uri("https://maven.aliyun.com/repository/google") } + maven { url = uri("https://maven.aliyun.com/repository/public") } + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register+ ("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..fbee1d8 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e4ef43f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..a982db7 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.11.1" apply false + id("org.jetbrains.kotlin.android") version "2.2.20" apply false +} + +include(":app") \ No newline at end of file diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/docs/已确认-污水毒品快检一体机_功能需求文档.md b/docs/已确认-污水毒品快检一体机_功能需求文档.md new file mode 100644 index 0000000..06058be --- /dev/null +++ b/docs/已确认-污水毒品快检一体机_功能需求文档.md @@ -0,0 +1,417 @@ +─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +# 1. 功能结构 + +**一、首页模块(设备控制面板)** + +**1. 状态栏** + +- 设备名称显示 + +- 实时时钟 + +- 系统状态显示 + +**2. 任务列表** + +- 程序列表展示 + +- 程序信息显示 + +- 程序选择 + +- 选中状态标识 + +- 查看详情 + +**3. 运行控制** + +- 程序选择确认 + +- 运行按钮 + +- 暂停 / 继续 + +- 停止按钮 + +**4. 运行状态监控** + +- 当前程序显示 + +- 当前孔位 + +- 步骤序号 + +- 步骤名称 + +- 步骤剩余时间 + +- 总进度条 + +- 暂停 / 继续按钮 + +- 停止按钮 + +**5. 运行完成提示** + +**5.1 样本滴入提示** + +- 提示信息 + +- 操作指引 + +------------------------------------------------------------------------ + +**二、程序管理模块** + +**1. 程序列表** + +- 程序列表展示 + +- 编号显示 + +- 程序名称 + +- 创建时间 + +- 状态显示 + +- 多选功能 + +- 全选 / 取消全选 + +**2. 程序 CRUD 操作** + +- 新增程序 + +- 编辑程序 + +- 删除程序 + +- 删除确认 + +- 文件导入 + +- 查看详情 + +------------------------------------------------------------------------ + +**三、程序详情模块** + +**1. 步骤管理** + +- 步骤列表展示 + +- 步骤参数显示 + +- 多选步骤 + +- 全选 / 取消全选 + +- 拖动排序 + +- 添加步骤 + +- 编辑步骤 + +- 删除步骤 + +- 返回按钮 + +**2. 步骤参数配置** + +- 步骤编号 + +- 孔位 + +- 步骤名称 + +- 混合时间 + +- 吸磁时间 + +- 容积 + +- 混合速度 + +- 吹气速度 + +- 吹气时间 + +- 下针速度 + +------------------------------------------------------------------------ + +**四、系统设置模块** + +**1. 软件升级** + +- 版本显示 + +- 检查更新 + +- 更新提示 + +**2. 语言设置** + +- 语言选择 + +- 实时切换 + +**3. 安全设置** + +- 密码修改 + +- 原密码验证 + +- 新密码确认 + +- 密码一致性校验 + +**4. U 盘导入** + +- 自动检测 + +- 程序导入 + +- 导入确认 + +─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +# 2. 首页模块 - 设备控制面板 + +## 2.1 状态栏功能 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 设备名称显示 显示\"污水毒品前处理一体机\"设备标识 + + 实时时钟 显示当前日期时间,格式: YYYY-MM-DD HH:mm:ss + + 系统状态显示 实时显示设备运行状态(运行中/未运行) + + 照明按钮 切换设备照明灯状态(亮/暗) + ----------------------------------------------------------------------- + +## 2.2 程序列表功能 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 程序列表展示 以卡片形式展示所有可用程序 + + 程序信息显示 显示程序编号、名称、创建时间 + + 程序选择 点击卡片可选择要运行的程序 + + 选中状态标识 选中的程序卡片高亮显示并带勾选标记 + + 查看详情 可直接跳转查看程序详细步骤配置 + ----------------------------------------------------------------------- + +## 2.3 运行控制功能 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 程序选择确认 显示当前选中的程序名称 + + 运行按钮 启动选中的程序运行(未选择程序时禁用) + + 瓷套棒确认 运行前确认是否已安装瓷套棒(硬件传感器检测支持) + + 暂停/继续 运行过程中可暂停和继续程序执行 + + 停止按钮 终止当前运行的程序 + + 停止确认 停止前需用户确认操作 + ----------------------------------------------------------------------- + +## 2.4 运行状态监控 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 当前程序显示 显示正在运行的程序名称 + + 当前孔位 显示当前操作的孔位 + + 步骤序号 显示当前执行的步骤编号 + + 步骤名称 显示当前步骤的名称 + + 步骤剩余时间 倒计时显示当前步骤剩余时间(HH:MM:SS) + + 总进度条 显示程序总体完成百分比 + + 暂停/继续按钮 运行过程中的暂停和继续控制 + + 停止按钮 终止当前运行的程序 + + 步骤参数 显示当前步骤所有设置参数 + ----------------------------------------------------------------------- + +## 2.5 运行完成提示 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 提示信息 程序运行完成后自动跳转到提示页面 + + 样本滴入指引 提示用户将样本滴入到检测卡 + + 操作说明 显示详细操作步骤说明 + ----------------------------------------------------------------------- + +─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +# 3. 程序管理模块 + +## 3.1 程序列表管理 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 程序列表展示 以表格形式展示所有程序 + + 编号显示 显示程序唯一编号 + + 程序名称 显示程序名称 + + 创建时间 显示程序创建日期 + + 状态显示 显示程序状态(启用/停用) + + 多选功能 支持勾选多个程序进行批量操作 + + 全选/取消全选 表头复选框控制全选状态 + ----------------------------------------------------------------------- + +## 3.2 程序CRUD操作 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 新增程序 创建新程序,需填写编号和名称 + + 编辑程序 修改程序的编号、名称、状态 + + 删除程序 删除选中的程序,支持批量删除 + + 删除确认 删除操作需用户确认 + + 文件导入 从文件导入程序配置 + + 查看详情 查看程序的详细步骤配置 + ----------------------------------------------------------------------- + +─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +# 4. 程序详情模块 + +## 4.1 步骤管理功能 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 步骤列表展示 以表格形式展示程序的所有步骤 + + 步骤参数显示 显示步骤的完整参数配置 + + 多选步骤 支持勾选多个步骤 + + 全选/取消全选 表头复选框控制全选状态 + + 拖动排序 支持拖动步骤行调整执行顺序 + + 添加步骤 新增步骤并配置参数 + + 编辑步骤 修改已有步骤的参数配置 + + 删除步骤 删除选中的步骤,支持批量删除 + + 返回按钮 返回主页面 + ----------------------------------------------------------------------- + +## 4.2 步骤参数配置 + + ---------------------------------------------------------------------------- + **参数名称** **说明** **取值范围** **示例** + -------------- -------------------------- ----------------- ---------------- + 步骤编号 步骤执行顺序(可拖动调整) 正整数 1, 2, 3\... + + 孔位 操作的孔位位置 如A1、A2、B1等 A1, B2, C3 + + 步骤名称 步骤的描述名称 文本 混合、吸磁\... + + 混合时间 混合操作的持续时间 秒(正整数) 60, 120\... + + 吸磁时间 磁珠吸附时间 秒(正整数) 30, 60\... + + 容积 液体体积 微升μL(正整数) 100, 200\... + + 混合速度 混合操作速度 低速/中速/高速 低速, 中速\... + + 吹气速度 吹气操作速度 低速/中速/高速 中速, 高速\... + + 吹气时间 吹气持续时间 分钟(正整数) 5, 10\... + + 下针速度 针头下移速度,10档可选 1-10档 1档至10档 + ---------------------------------------------------------------------------- + +─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +# 5. 系统设置模块 + +## 5.1 软件升级 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 版本显示 显示当前软件版本号(如V1.0.0) + + 检查更新 检查是否有新版本可用 + + 更新提示 有新版本时提示用户更新 + ----------------------------------------------------------------------- + +## 5.2 语言设置 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 语言选择 支持简体中文、English语言切换 + + 实时切换 切换后立即生效 + ----------------------------------------------------------------------- + +## 5.3 安全设置 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 密码修改 修改用户登录密码或操作密码 + + 原密码验证 需输入原密码进行身份验证 + + 新密码确认 需两次输入新密码进行确认 + + 密码一致性校验 确保两次输入的新密码一致 + ----------------------------------------------------------------------- + +## 5.4 U盘导入 + + ----------------------------------------------------------------------- + **功能项** **描述** + ----------------- ----------------------------------------------------- + 自动检测 自动检测U盘插入事件 + + 程序导入 从U盘自动导入程序配置文件 + + 导入确认 导入前显示确认信息,用户确认后执行导入 + ----------------------------------------------------------------------- + +─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ + +--- 文档结束 --- diff --git a/lib/core/database/database_service.dart b/lib/core/database/database_service.dart new file mode 100644 index 0000000..b27f7b1 --- /dev/null +++ b/lib/core/database/database_service.dart @@ -0,0 +1,164 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:path/path.dart'; + +/// 数据库服务 +class DatabaseService { + static final DatabaseService instance = DatabaseService._internal(); + static Database? _database; + + DatabaseService._internal(); + + Future get database async { + if (_database != null) return _database!; + _database = await _initDatabase(); + return _database!; + } + + Future _initDatabase() async { + final dbPath = await getDatabasesPath(); + final path = join(dbPath, 'kuaishai.db'); + + return await openDatabase( + path, + version: 2, + onCreate: _onCreate, + onUpgrade: _onUpgrade, + ); + } + + Future _onCreate(Database db, int version) async { + // 程序表 + await db.execute(''' + CREATE TABLE programs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + status INTEGER DEFAULT 1 + ) + '''); + + // 步骤表 + await db.execute(''' + CREATE TABLE steps ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + program_id INTEGER NOT NULL, + step_no INTEGER NOT NULL, + position TEXT NOT NULL, + name TEXT NOT NULL, + 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, + FOREIGN KEY (program_id) REFERENCES programs(id) ON DELETE CASCADE + ) + '''); + + // 设置表(密码存储) + await db.execute(''' + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + '''); + + // 初始化默认密码 + await db.insert('settings', {'key': 'password', 'value': '123456'}); + } + + /// 数据库升级 + Future _onUpgrade(Database db, int oldVersion, int newVersion) async { + if (oldVersion < 2) { + // 添加 settings 表 + await db.execute(''' + CREATE TABLE settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + '''); + // 初始化默认密码 + await db.insert('settings', {'key': 'password', 'value': '123456'}); + } + } + + Future close() async { + if (_database != null) { + await _database!.close(); + _database = null; + } + } + + /// 初始化测试数据(仅调试模式使用) + Future initTestData() async { + final db = await database; + + // 检查是否已有数据 + final count = Sqflite.firstIntValue( + await db.rawQuery('SELECT COUNT(*) FROM programs'), + ); + if (count != null && count > 0) return; + + // 插入测试程序并添加步骤 + 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}, + ]; + + for (final program in testPrograms) { + final programId = await db.insert('programs', program); + + // 为每个程序添加测试步骤 + final testSteps = [ + { + 'program_id': programId, + 'step_no': 1, + 'position': 'A1', + 'name': '混合', + 'mix_time': 60, + 'magnet_time': 0, + 'volume': 100, + 'mix_speed': '中速', + 'blow_speed': '中速', + 'blow_time': 0, + 'needle_speed': 5, + }, + { + 'program_id': programId, + 'step_no': 2, + 'position': 'A1', + 'name': '吸磁', + 'mix_time': 0, + 'magnet_time': 30, + 'volume': 0, + 'mix_speed': '中速', + 'blow_speed': '中速', + 'blow_time': 0, + 'needle_speed': 5, + }, + { + 'program_id': programId, + 'step_no': 3, + 'position': 'A2', + 'name': '吹气', + 'mix_time': 0, + 'magnet_time': 0, + 'volume': 0, + 'mix_speed': '中速', + 'blow_speed': '高速', + 'blow_time': 10, + 'needle_speed': 8, + }, + ]; + + for (final step in testSteps) { + await db.insert('steps', step); + } + } + } +} \ No newline at end of file diff --git a/lib/core/localization/app_localizations.dart b/lib/core/localization/app_localizations.dart new file mode 100644 index 0000000..578b0ac --- /dev/null +++ b/lib/core/localization/app_localizations.dart @@ -0,0 +1,366 @@ +import 'package:flutter/material.dart'; + +/// 应用国际化配置 +class AppLocalizations { + final Locale locale; + + AppLocalizations(this.locale); + + static AppLocalizations? of(BuildContext context) { + return Localizations.of (context, AppLocalizations); + } + + static const LocalizationsDelegate delegate = + _AppLocalizationsDelegate(); + + // 状态栏 + String get deviceName => _localizedValues[locale.languageCode]?['deviceName'] ?? '污水毒品前处理一体机'; + String get running => _localizedValues[locale.languageCode]?['running'] ?? '运行中'; + String get idle => _localizedValues[locale.languageCode]?['idle'] ?? '未运行'; + String get lighting => _localizedValues[locale.languageCode]?['lighting'] ?? '照明'; + + // 程序管理 + String get programs => _localizedValues[locale.languageCode]?['programs'] ?? '程序管理'; + String get programList => _localizedValues[locale.languageCode]?['programList'] ?? '程序列表'; + String get programName => _localizedValues[locale.languageCode]?['programName'] ?? '程序名称'; + String get programCode => _localizedValues[locale.languageCode]?['programCode'] ?? '程序编号'; + String get createTime => _localizedValues[locale.languageCode]?['createTime'] ?? '创建时间'; + String get addProgram => _localizedValues[locale.languageCode]?['addProgram'] ?? '新增程序'; + String get editProgram => _localizedValues[locale.languageCode]?['editProgram'] ?? '编辑程序'; + String get deleteProgram => _localizedValues[locale.languageCode]?['deleteProgram'] ?? '删除程序'; + String get importProgram => _localizedValues[locale.languageCode]?['importProgram'] ?? '导入程序'; + 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 temperature => _localizedValues[locale.languageCode]?['temperature'] ?? '温度'; + String get duration => _localizedValues[locale.languageCode]?['duration'] ?? '持续时间'; + String get sampleVolume => _localizedValues[locale.languageCode]?['sampleVolume'] ?? '样品体积'; + String get pleaseSelectProgram => _localizedValues[locale.languageCode]?['pleaseSelectProgram'] ?? '请选择要运行的程序'; + + // 运行控制 + String get run => _localizedValues[locale.languageCode]?['run'] ?? '运行'; + String get pause => _localizedValues[locale.languageCode]?['pause'] ?? '暂停'; + String get continue_ => _localizedValues[locale.languageCode]?['continue'] ?? '继续'; + String get stop => _localizedValues[locale.languageCode]?['stop'] ?? '停止'; + String get startRun => _localizedValues[locale.languageCode]?['startRun'] ?? '开始运行'; + String get currentStep => _localizedValues[locale.languageCode]?['currentStep'] ?? '当前步骤'; + String get remainingTime => _localizedValues[locale.languageCode]?['remainingTime'] ?? '剩余时间'; + String get progress => _localizedValues[locale.languageCode]?['progress'] ?? '进度'; + String get ceramicSleeveConfirm => _localizedValues[locale.languageCode]?['ceramicSleeveConfirm'] ?? '运行前请确认已安装瓷套棒'; + String get paused => _localizedValues[locale.languageCode]?['paused'] ?? '已暂停'; + String get stopConfirm => _localizedValues[locale.languageCode]?['stopConfirm'] ?? '确定要停止当前运行的程序吗?'; + String get currentProgram => _localizedValues[locale.languageCode]?['currentProgram'] ?? '当前程序'; + String get backToHome => _localizedValues[locale.languageCode]?['backToHome'] ?? '返回首页'; + String get runAgain => _localizedValues[locale.languageCode]?['runAgain'] ?? '重新运行'; + String get deleteConfirm => _localizedValues[locale.languageCode]?['deleteConfirm'] ?? '确定要删除此程序吗?'; + + // 步骤参数 + String get stepNo => _localizedValues[locale.languageCode]?['stepNo'] ?? '步骤编号'; + String get position => _localizedValues[locale.languageCode]?['position'] ?? '孔位'; + String get stepName => _localizedValues[locale.languageCode]?['stepName'] ?? '步骤名称'; + 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'] ?? '系统设置'; + String get language => _localizedValues[locale.languageCode]?['language'] ?? '语言设置'; + String get password => _localizedValues[locale.languageCode]?['password'] ?? '密码修改'; + String get upgrade => _localizedValues[locale.languageCode]?['upgrade'] ?? '软件升级'; + String get usbImport => _localizedValues[locale.languageCode]?['usbImport'] ?? 'U盘导入'; + + // 通用 + String get confirm => _localizedValues[locale.languageCode]?['confirm'] ?? '确认'; + String get cancel => _localizedValues[locale.languageCode]?['cancel'] ?? '取消'; + String get save => _localizedValues[locale.languageCode]?['save'] ?? '保存'; + String get delete => _localizedValues[locale.languageCode]?['delete'] ?? '删除'; + String get select => _localizedValues[locale.languageCode]?['select'] ?? '选择'; + String get selected => _localizedValues[locale.languageCode]?['selected'] ?? '已选择'; + String get detail => _localizedValues[locale.languageCode]?['detail'] ?? '详情'; + String get noData => _localizedValues[locale.languageCode]?['noData'] ?? '暂无数据'; + + // 完成提示 + String get runComplete => _localizedValues[locale.languageCode]?['runComplete'] ?? '运行完成'; + String get sampleDropGuide => _localizedValues[locale.languageCode]?['sampleDropGuide'] ?? '请将样本滴入检测卡'; + + // 补充缺失的翻译 + String get lightOn => _localizedValues[locale.languageCode]?['lightOn'] ?? '亮'; + String get lightOff => _localizedValues[locale.languageCode]?['lightOff'] ?? '暗'; + String get enabled => _localizedValues[locale.languageCode]?['enabled'] ?? '启用'; + String get disabled => _localizedValues[locale.languageCode]?['disabled'] ?? '停用'; + String get stepList => _localizedValues[locale.languageCode]?['stepList'] ?? '步骤列表'; + String get operationSteps => _localizedValues[locale.languageCode]?['operationSteps'] ?? '操作步骤'; + String get addStep => _localizedValues[locale.languageCode]?['addStep'] ?? '添加步骤'; + String get editStep => _localizedValues[locale.languageCode]?['editStep'] ?? '编辑步骤'; + String get deleteStep => _localizedValues[locale.languageCode]?['deleteStep'] ?? '删除步骤'; + String get deleteStepConfirm => _localizedValues[locale.languageCode]?['deleteStepConfirm'] ?? '确定要删除此步骤吗?'; + String get stepsCount => _localizedValues[locale.languageCode]?['stepsCount'] ?? '步'; + String get noSteps => _localizedValues[locale.languageCode]?['noSteps'] ?? '暂无步骤'; + String get selectStepFirst => _localizedValues[locale.languageCode]?['selectStepFirst'] ?? '请选择或添加步骤'; + String get oldPassword => _localizedValues[locale.languageCode]?['oldPassword'] ?? '原密码'; + String get newPassword => _localizedValues[locale.languageCode]?['newPassword'] ?? '新密码'; + String get confirmPassword => _localizedValues[locale.languageCode]?['confirmPassword'] ?? '确认新密码'; + String get passwordMinLength => _localizedValues[locale.languageCode]?['passwordMinLength'] ?? '至少6位字符'; + String get passwordChanged => _localizedValues[locale.languageCode]?['passwordChanged'] ?? '密码已修改'; + String get passwordChangeFailed => _localizedValues[locale.languageCode]?['passwordChangeFailed'] ?? '密码修改失败'; + String get oldPasswordError => _localizedValues[locale.languageCode]?['oldPasswordError'] ?? '原密码错误'; + String get passwordMismatch => _localizedValues[locale.languageCode]?['passwordMismatch'] ?? '两次输入的新密码不一致'; + String get fillAllFields => _localizedValues[locale.languageCode]?['fillAllFields'] ?? '请填写所有字段'; + String get importSuccess => _localizedValues[locale.languageCode]?['importSuccess'] ?? '成功导入'; + String get importFailed => _localizedValues[locale.languageCode]?['importFailed'] ?? '导入失败'; + String get programsImported => _localizedValues[locale.languageCode]?['programsImported'] ?? '个程序'; + String get usbDetected => _localizedValues[locale.languageCode]?['usbDetected'] ?? '检测到U盘'; + String get usbNotDetected => _localizedValues[locale.languageCode]?['usbNotDetected'] ?? '未检测到U盘'; + String get insertUsb => _localizedValues[locale.languageCode]?['insertUsb'] ?? '请插入U盘后重试'; + String get detectingUsb => _localizedValues[locale.languageCode]?['detectingUsb'] ?? '正在检测U盘...'; + String get currentVersion => _localizedValues[locale.languageCode]?['currentVersion'] ?? '当前版本'; + String get latestVersion => _localizedValues[locale.languageCode]?['latestVersion'] ?? '已是最新版本'; + String get updateAvailable => _localizedValues[locale.languageCode]?['updateAvailable'] ?? '有新版本可用'; + String get checkUpdate => _localizedValues[locale.languageCode]?['checkUpdate'] ?? '检查更新'; + + static final Map > _localizedValues = { + 'zh': { + 'deviceName': '污水毒品前处理一体机', + 'running': '运行中', + 'idle': '未运行', + 'lighting': '照明', + 'programs': '程序管理', + 'programList': '程序列表', + 'programName': '程序名称', + 'programCode': '程序编号', + 'createTime': '创建时间', + 'addProgram': '新增程序', + 'editProgram': '编辑程序', + 'deleteProgram': '删除程序', + 'importProgram': '导入程序', + 'viewDetails': '查看详情', + 'selectedProgram': '当前选中程序', + 'selectedProgramLabel': '当前选中', + 'availablePrograms': '可用程序', + 'ceramicNotInstalled': '瓷套棒: 未安装 — 禁止启动', + 'ceramicInstalled': '瓷套棒: 已安装', + 'runningMonitor': '运行状态监控', + 'currentHole': '当前孔位', + 'stepParams': '步骤参数', + 'speed': '转速', + 'temperature': '温度', + 'duration': '持续时间', + 'sampleVolume': '样品体积', + 'pleaseSelectProgram': '请选择要运行的程序', + 'run': '运行', + 'pause': '暂停', + 'continue': '继续', + 'stop': '停止', + 'startRun': '开始运行', + 'currentStep': '当前步骤', + 'remainingTime': '剩余时间', + 'progress': '进度', + 'ceramicSleeveConfirm': '运行前请确认已安装瓷套棒', + 'paused': '已暂停', + 'stopConfirm': '确定要停止当前运行的程序吗?', + 'currentProgram': '当前程序', + 'backToHome': '返回首页', + 'runAgain': '重新运行', + 'deleteConfirm': '确定要删除此程序吗?', + 'stepNo': '步骤编号', + 'position': '孔位', + 'stepName': '步骤名称', + 'mixTime': '混合时间', + 'magnetTime': '吸磁时间', + 'volume': '容积', + 'mixSpeed': '混合速度', + 'blowSpeed': '吹气速度', + 'blowTime': '吹气时间', + 'needleSpeed': '下针速度', + 'lowSpeed': '低速', + 'mediumSpeed': '中速', + 'highSpeed': '高速', + 'settings': '系统设置', + 'language': '语言设置', + 'password': '密码修改', + 'upgrade': '软件升级', + 'usbImport': 'U盘导入', + 'confirm': '确认', + 'cancel': '取消', + 'save': '保存', + 'delete': '删除', + 'select': '选择', + 'selected': '已选择', + 'detail': '详情', + 'noData': '暂无数据', + 'runComplete': '运行完成', + 'sampleDropGuide': '请将样本滴入检测卡', + 'lightOn': '亮', + 'lightOff': '暗', + 'enabled': '启用', + 'disabled': '停用', + 'stepList': '步骤列表', + 'operationSteps': '操作步骤', + 'addStep': '添加步骤', + 'editStep': '编辑步骤', + 'deleteStep': '删除步骤', + 'deleteStepConfirm': '确定要删除此步骤吗?', + 'stepsCount': '步', + 'noSteps': '暂无步骤', + 'selectStepFirst': '请选择或添加步骤', + 'oldPassword': '原密码', + 'newPassword': '新密码', + 'confirmPassword': '确认新密码', + 'passwordMinLength': '至少6位字符', + 'passwordChanged': '密码已修改', + 'passwordChangeFailed': '密码修改失败', + 'oldPasswordError': '原密码错误', + 'passwordMismatch': '两次输入的新密码不一致', + 'fillAllFields': '请填写所有字段', + 'importSuccess': '成功导入', + 'importFailed': '导入失败', + 'programsImported': '个程序', + 'usbDetected': '检测到U盘', + 'usbNotDetected': '未检测到U盘', + 'insertUsb': '请插入U盘后重试', + 'detectingUsb': '正在检测U盘...', + 'currentVersion': '当前版本', + 'latestVersion': '已是最新版本', + 'updateAvailable': '有新版本可用', + 'checkUpdate': '检查更新', + }, + 'en': { + 'deviceName': 'Wastewater Drug Pretreatment System', + 'running': 'Running', + 'idle': 'Idle', + 'lighting': 'Lighting', + 'programs': 'Programs', + 'programList': 'Program List', + 'programName': 'Program Name', + 'programCode': 'Program Code', + 'createTime': 'Create Time', + 'addProgram': 'Add Program', + 'editProgram': 'Edit Program', + 'deleteProgram': 'Delete Program', + 'importProgram': 'Import Program', + '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', + 'temperature': 'Temperature', + 'duration': 'Duration', + 'sampleVolume': 'Sample Volume', + 'pleaseSelectProgram': 'Please select a program', + 'run': 'Run', + 'pause': 'Pause', + 'continue': 'Continue', + 'stop': 'Stop', + 'startRun': 'Start Run', + 'currentStep': 'Current Step', + 'remainingTime': 'Remaining', + 'progress': 'Progress', + 'ceramicSleeveConfirm': 'Please confirm ceramic sleeve is installed', + 'paused': 'Paused', + 'stopConfirm': 'Are you sure to stop the running program?', + 'currentProgram': 'Current Program', + 'backToHome': 'Back to Home', + 'runAgain': 'Run Again', + 'deleteConfirm': 'Are you sure to delete this program?', + 'stepNo': 'Step No.', + 'position': 'Position', + 'stepName': 'Step Name', + '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', + 'upgrade': 'Upgrade', + 'usbImport': 'USB Import', + 'confirm': 'Confirm', + 'cancel': 'Cancel', + 'save': 'Save', + 'delete': 'Delete', + 'select': 'Select', + 'selected': 'Selected', + 'detail': 'Detail', + 'noData': 'No Data', + 'runComplete': 'Complete', + 'sampleDropGuide': 'Drop sample to test card', + 'lightOn': 'On', + 'lightOff': 'Off', + 'enabled': 'Enabled', + 'disabled': 'Disabled', + 'stepList': 'Step List', + 'operationSteps': 'Operation Steps', + 'addStep': 'Add Step', + 'editStep': 'Edit Step', + 'deleteStep': 'Delete Step', + 'deleteStepConfirm': 'Are you sure to delete this step?', + 'stepsCount': 'steps', + 'noSteps': 'No steps', + 'selectStepFirst': 'Please select or add a step', + 'oldPassword': 'Old Password', + 'newPassword': 'New Password', + 'confirmPassword': 'Confirm Password', + 'passwordMinLength': 'At least 6 characters', + 'passwordChanged': 'Password changed', + 'passwordChangeFailed': 'Password change failed', + 'oldPasswordError': 'Old password incorrect', + 'passwordMismatch': 'Passwords do not match', + 'fillAllFields': 'Please fill all fields', + 'importSuccess': 'Successfully imported', + 'importFailed': 'Import failed', + 'programsImported': 'programs', + 'usbDetected': 'USB detected', + 'usbNotDetected': 'USB not detected', + 'insertUsb': 'Please insert USB and try again', + 'detectingUsb': 'Detecting USB...', + 'currentVersion': 'Current Version', + 'latestVersion': 'Already latest version', + 'updateAvailable': 'Update available', + 'checkUpdate': 'Check Update', + }, + }; +} + +class _AppLocalizationsDelegate extends LocalizationsDelegate { + const _AppLocalizationsDelegate(); + + @override + bool isSupported(Locale locale) { + return ['zh', 'en'].contains(locale.languageCode); + } + + @override + Future load(Locale locale) async { + return AppLocalizations(locale); + } + + @override + bool shouldReload(_AppLocalizationsDelegate old) => false; +} \ No newline at end of file diff --git a/lib/core/localization/locale_provider.dart b/lib/core/localization/locale_provider.dart new file mode 100644 index 0000000..2d05f05 --- /dev/null +++ b/lib/core/localization/locale_provider.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Locale 状态 Notifier +class LocaleNotifier extends StateNotifier { + static const String _key = 'app_locale'; + + LocaleNotifier() : super(const Locale('zh', 'CN')) { + _loadLocale(); + } + + /// 从本地存储加载语言设置 + Future _loadLocale() async { + final prefs = await SharedPreferences.getInstance(); + final localeCode = prefs.getString(_key); + if (localeCode != null) { + state = Locale(localeCode, localeCode == 'zh' ? 'CN' : 'US'); + } + } + + /// 切换语言 + Future setLocale(Locale locale) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, locale.languageCode); + state = locale; + } + + /// 切换为中文 + Future setChinese() async { + await setLocale(const Locale('zh', 'CN')); + } + + /// 切换为英文 + Future setEnglish() async { + await setLocale(const Locale('en', 'US')); + } +} + +/// Locale Provider +final localeProvider = StateNotifierProvider ((ref) { + return LocaleNotifier(); +}); + +/// 当前语言是否为中文 +final isChineseProvider = Provider ((ref) { + return ref.watch(localeProvider).languageCode == 'zh'; +}); \ No newline at end of file diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart new file mode 100644 index 0000000..9e67e0a --- /dev/null +++ b/lib/core/router/app_router.dart @@ -0,0 +1,45 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../features/home/pages/home_page.dart'; +import '../../features/programs/pages/programs_page.dart'; +import '../../features/program_detail/pages/program_detail_page.dart'; +import '../../features/settings/pages/settings_page.dart'; +import '../../features/home/pages/complete_page.dart'; + +/// 应用路由配置 +final goRouterProvider = Provider ((ref) { + return GoRouter( + initialLocation: '/', + routes: [ + GoRoute( + path: '/', + name: 'home', + builder: (context, state) => const HomePage(), + ), + GoRoute( + path: '/programs', + name: 'programs', + builder: (context, state) => const ProgramsPage(), + ), + GoRoute( + path: '/programs/:id', + name: 'programDetail', + builder: (context, state) { + final id = state.pathParameters['id']; + return ProgramDetailPage(programId: id ?? ''); + }, + ), + GoRoute( + path: '/settings', + name: 'settings', + builder: (context, state) => const SettingsPage(), + ), + GoRoute( + path: '/complete', + name: 'complete', + builder: (context, state) => const CompletePage(), + ), + ], + ); +}); \ No newline at end of file diff --git a/lib/core/theme/app_theme.dart b/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..ee5caa8 --- /dev/null +++ b/lib/core/theme/app_theme.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; + +/// 应用主题配置 - 明亮工业控制风格 +/// 主色 #2196F3,圆角 4px,明亮背景,适配 1920x1080 横屏 +class AppTheme { + // ========== 主色 ========== + static const Color primaryColor = Color(0xFF2196F3); + static const Color primaryDark = Color(0xFF1976D2); + static const Color primaryLight = Color(0xFFBBDEFB); + + // ========== 功能色 ========== + static const Color successColor = Color(0xFF4CAF50); + static const Color warningColor = Color(0xFFFF9800); + static const Color errorColor = Color(0xFFF44336); + static const Color infoColor = Color(0xFF00BCD4); + + // ========== 背景色(明亮) ========== + static const Color bgPage = Color(0xFFF5F7FA); + static const Color bgDeep = Color(0xFFE8ECF0); + static const Color bgSurface = Color(0xFFFFFFFF); + static const Color bgCard = Color(0xFFFFFFFF); + static const Color bgCardHover = Color(0xFFF0F7FF); + static const Color bgSidebar = Color(0xFFF0F2F5); + + // ========== 文本色 ========== + static const Color textHeading = Color(0xFF1A1A2E); + static const Color textPrimary = Color(0xFF333344); + static const Color textSecondary = Color(0xFF6B7280); + static const Color textTertiary = Color(0xFF9CA3AF); + static const Color textOnPrimary = Colors.white; + + // ========== 状态色 ========== + static const Color statusRunning = Color(0xFF4CAF50); + static const Color statusStopped = Color(0xFF9CA3AF); + static const Color statusPaused = Color(0xFFFF9800); + static const Color statusError = Color(0xFFF44336); + + // ========== 卡片背景 ========== + static const Color cardBg = Color(0xFFFFFFFF); + static const Color cardSelectedBg = Color(0xFFE3F2FD); + + // ========== 功能色(accent) ========== + static const Color accentPrimary = primaryColor; + static const Color accentInfo = infoColor; + static const Color accentWarning = warningColor; + static const Color accentCritical = errorColor; + + // ========== 边框色 ========== + static const Color borderLight = Color(0xFFE5E7EB); + static const Color borderMedium = Color(0xFFD1D5DB); + static const Color borderSubtle = borderLight; + static const Color borderFocus = primaryColor; + + // ========== 圆角 ========== + static const double radiusSm = 4.0; + static const double radiusMd = 8.0; + static const double radiusLg = 12.0; + + // ========== 阴影 ========== + static const List shadowCard = [ + BoxShadow( + color: Color(0x0A000000), + blurRadius: 8, + offset: Offset(0, 2), + ), + ]; + + static const List shadowCardHover = [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 12, + offset: Offset(0, 4), + ), + ]; + + // ========== 兼容旧代码的颜色别名 ========== + static const Color runningColor = statusRunning; + static const Color idleColor = statusStopped; + static const Color backgroundColor = bgPage; + static const Color cardColor = bgCard; + + /// 亮色主题 - 明亮工业风格 + static ThemeData lightTheme() { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.light, + ), + scaffoldBackgroundColor: bgPage, + fontFamily: 'Inter', + cardTheme: CardThemeData( + color: bgCard, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + side: const BorderSide(color: borderLight, width: 1), + ), + margin: EdgeInsets.zero, + ), + appBarTheme: const AppBarTheme( + backgroundColor: bgSurface, + foregroundColor: textHeading, + elevation: 0, + centerTitle: false, + scrolledUnderElevation: 1, + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: primaryColor, + foregroundColor: textOnPrimary, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusSm), + ), + ), + ), + inputDecorationTheme: InputDecorationTheme( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: borderMedium), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: borderMedium), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusSm), + borderSide: const BorderSide(color: primaryColor, width: 2), + ), + filled: true, + fillColor: bgSurface, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + ), + dialogTheme: DialogThemeData( + backgroundColor: bgSurface, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: textHeading, + contentTextStyle: const TextStyle(color: Colors.white, fontSize: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusSm), + ), + behavior: SnackBarBehavior.floating, + ), + listTileTheme: const ListTileThemeData( + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(radiusSm)), + ), + ), + dataTableTheme: DataTableThemeData( + headingRowColor: WidgetStateProperty.all(bgSidebar), + dividerThickness: 1, + ), + dividerTheme: const DividerThemeData( + color: borderLight, + thickness: 1, + ), + ); + } + + /// 暗色主题(与亮色主题风格一致的暗色模式) + static ThemeData darkTheme() { + return ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: primaryColor, + brightness: Brightness.dark, + ), + scaffoldBackgroundColor: const Color(0xFF121212), + ); + } +} diff --git a/lib/features/device/models/device_state.dart b/lib/features/device/models/device_state.dart new file mode 100644 index 0000000..7476320 --- /dev/null +++ b/lib/features/device/models/device_state.dart @@ -0,0 +1,73 @@ +/// 设备状态模型 +enum DeviceStatus { idle, running, paused, error } + +/// 设备状态数据 +class DeviceState { + final DeviceStatus status; + final String? currentProgram; + final String? currentPosition; + final int? currentStepNo; + final String? currentStepName; + final int? remainingSeconds; + final double? progress; + final bool lightingOn; + + DeviceState({ + this.status = DeviceStatus.idle, + this.currentProgram, + this.currentPosition, + this.currentStepNo, + this.currentStepName, + this.remainingSeconds, + this.progress, + this.lightingOn = false, + }); + + bool get isRunning => status == DeviceStatus.running; + bool get isPaused => status == DeviceStatus.paused; + bool get isIdle => status == DeviceStatus.idle; + bool get hasError => status == DeviceStatus.error; + + String statusText() { + switch (status) { + case DeviceStatus.running: + return '运行中'; + case DeviceStatus.paused: + return '已暂停'; + case DeviceStatus.error: + return '错误'; + case DeviceStatus.idle: + return '未运行'; + } + } + + String formatRemainingTime() { + if (remainingSeconds == null) return '--:--:--'; + final hours = remainingSeconds! ~/ 3600; + 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')}'; + } + + DeviceState copyWith({ + DeviceStatus? status, + String? currentProgram, + String? currentPosition, + int? currentStepNo, + String? currentStepName, + int? remainingSeconds, + double? progress, + bool? lightingOn, + }) { + return DeviceState( + status: status ?? this.status, + currentProgram: currentProgram ?? this.currentProgram, + currentPosition: currentPosition ?? this.currentPosition, + currentStepNo: currentStepNo ?? this.currentStepNo, + currentStepName: currentStepName ?? this.currentStepName, + remainingSeconds: remainingSeconds ?? this.remainingSeconds, + progress: progress ?? this.progress, + lightingOn: lightingOn ?? this.lightingOn, + ); + } +} \ No newline at end of file diff --git a/lib/features/device/providers/run_state_provider.dart b/lib/features/device/providers/run_state_provider.dart new file mode 100644 index 0000000..f003e6f --- /dev/null +++ b/lib/features/device/providers/run_state_provider.dart @@ -0,0 +1,188 @@ +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'; + +/// 运行状态枚举 +enum RunStatus { + idle, // 待机 + running, // 运行中 + paused, // 已暂停 + completed,// 已完成 + error, // 错误 +} + +/// 运行状态 +class RunState { + final RunStatus status; + final Program? currentProgram; + final List steps; + final int currentStepIndex; + final int remainingSeconds; + final double progress; + final String? currentWell; + + const RunState({ + this.status = RunStatus.idle, + this.currentProgram, + this.steps = const [], + this.currentStepIndex = 0, + this.remainingSeconds = 0, + this.progress = 0, + this.currentWell, + }); + + RunState copyWith({ + RunStatus? status, + Program? currentProgram, + List ? steps, + int? currentStepIndex, + int? remainingSeconds, + double? progress, + String? currentWell, + bool clearProgram = false, + bool clearWell = false, + }) { + return RunState( + status: status ?? this.status, + 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), + ); + } + + /// 获取当前步骤 + Step? get currentStep { + if (steps.isEmpty || currentStepIndex >= steps.length) return null; + return steps[currentStepIndex]; + } + + /// 格式化剩余时间 (HH:MM:SS) + String get formattedRemainingTime { + final hours = remainingSeconds ~/ 3600; + 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')}'; + } + + /// 格式化进度百分比 + String get formattedProgress { + return '${(progress * 100).toStringAsFixed(0)}%'; + } +} + +/// 运行状态 Notifier +class RunStateNotifier extends StateNotifier { + final MockRunner _runner; + final ProgramService _programService; + + RunStateNotifier(this._runner, this._programService) : super(const RunState()); + + /// 开始运行程序 + Future start(Program program) async { + // 获取程序步骤(这里使用模拟数据,实际应从数据库读取) + final steps = await _loadSteps(program.id!); + + if (steps.isEmpty) { + state = state.copyWith(status: RunStatus.error); + return; + } + + state = state.copyWith( + status: RunStatus.running, + currentProgram: program, + steps: steps, + currentStepIndex: 0, + progress: 0, + ); + + _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, + ); + }, + ); + } + + /// 暂停运行 + void pause() { + if (state.status == RunStatus.running) { + _runner.pause(); + state = state.copyWith(status: RunStatus.paused); + } + } + + /// 继续运行 + void resume() { + if (state.status == RunStatus.paused) { + _runner.resume(); + state = state.copyWith(status: RunStatus.running); + } + } + + /// 停止运行 + void stop() { + _runner.stop(); + state = const RunState(status: RunStatus.idle); + } + + /// 重置状态 + void reset() { + stop(); + } + + /// 加载程序步骤(从数据库读取) + Future > _loadSteps(int programId) async { + return await _programService.getStepsByProgramId(programId); + } +} + +/// MockRunner Provider +final mockRunnerProvider = Provider
((ref) { + return MockRunner(); +}); + +/// ProgramService Provider +final programServiceProvider = Provider ((ref) { + return ProgramService.instance; +}); + +/// 运行状态 Provider +final runStateProvider = + StateNotifierProvider ((ref) { + final runner = ref.watch(mockRunnerProvider); + final programService = ref.watch(programServiceProvider); + return RunStateNotifier(runner, programService); +}); + +/// 是否正在运行 Provider +final isRunningProvider = Provider ((ref) { + final status = ref.watch(runStateProvider).status; + return status == RunStatus.running; +}); + +/// 是否已暂停 Provider +final isPausedProvider = Provider ((ref) { + final status = ref.watch(runStateProvider).status; + return status == RunStatus.paused; +}); \ No newline at end of file diff --git a/lib/features/device/services/mock_runner.dart b/lib/features/device/services/mock_runner.dart new file mode 100644 index 0000000..27134a8 --- /dev/null +++ b/lib/features/device/services/mock_runner.dart @@ -0,0 +1,190 @@ +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 _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 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; + } +} \ No newline at end of file diff --git a/lib/features/device/services/mock_runner_impl.dart b/lib/features/device/services/mock_runner_impl.dart new file mode 100644 index 0000000..c807b07 --- /dev/null +++ b/lib/features/device/services/mock_runner_impl.dart @@ -0,0 +1,114 @@ +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 _steps = []; + + @override + void start(Program program, List 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(); + } +} \ No newline at end of file diff --git a/lib/features/device/services/runner_interface.dart b/lib/features/device/services/runner_interface.dart new file mode 100644 index 0000000..1bf1309 --- /dev/null +++ b/lib/features/device/services/runner_interface.dart @@ -0,0 +1,54 @@ +import '../../programs/models/program.dart'; +import '../../programs/models/step.dart'; + +/// 运行器状态 +enum RunnerStatus { + idle, + running, + paused, + completed, + error, +} + +/// 运行器回调 +class RunnerCallbacks { + /// 步骤进度回调: (stepIndex, remainingSeconds, progress, currentWell) + final void Function(int stepIndex, int remainingSeconds, double progress, String well)? onProgress; + + /// 运行完成回调 + final void Function()? onComplete; + + /// 错误回调 + final void Function(String error)? onError; + + const RunnerCallbacks({ + this.onProgress, + this.onComplete, + this.onError, + }); +} + +/// 运行器抽象接口 +/// 定义硬件运行控制的标准接口 +abstract class Runner { + /// 当前状态 + RunnerStatus status = RunnerStatus.idle; + + /// 启动程序运行 + void start(Program program, List steps, RunnerCallbacks callbacks); + + /// 暂停运行 + void pause(); + + /// 继续运行 + void resume(); + + /// 停止运行 + void stop(); + + /// 获取当前状态 + RunnerStatus getStatus(); + + /// 释放资源 + void dispose(); +} \ No newline at end of file diff --git a/lib/features/device/services/serial_runner.dart b/lib/features/device/services/serial_runner.dart new file mode 100644 index 0000000..5d8239d --- /dev/null +++ b/lib/features/device/services/serial_runner.dart @@ -0,0 +1,91 @@ +import '../../programs/models/program.dart'; +import '../../programs/models/step.dart'; +import 'runner_interface.dart'; + +/// 串口运行器(真实硬件实现) +/// 实现与设备的串口通信 +class SerialRunner implements Runner { + @override + RunnerStatus status = RunnerStatus.idle; + + /// 串口配置 + final String portName; + final int baudRate; + final int dataBits; + final int stopBits; + + SerialRunner({ + this.portName = '/dev/ttyUSB0', + this.baudRate = 9600, + this.dataBits = 8, + this.stopBits = 1, + }); + + @override + void start(Program program, List steps, RunnerCallbacks callbacks) { + // TODO: 实现串口通信启动逻辑 + // 1. 打开串口连接 + // 2. 发送程序配置 + // 3. 按步骤发送控制指令 + // 4. 接收设备反馈并更新状态 + + status = RunnerStatus.running; + + // 示例:发送启动指令 + // _sendCommand('START', program.code); + + // 示例:监听设备状态 + // _listenToDevice(callbacks); + } + + @override + void pause() { + if (status == RunnerStatus.running) { + // _sendCommand('PAUSE'); + status = RunnerStatus.paused; + } + } + + @override + void resume() { + if (status == RunnerStatus.paused) { + // _sendCommand('RESUME'); + status = RunnerStatus.running; + } + } + + @override + void stop() { + // _sendCommand('STOP'); + // _closeConnection(); + status = RunnerStatus.idle; + } + + @override + RunnerStatus getStatus() => status; + + @override + void dispose() { + stop(); + } + + /// 发送控制指令(待硬件协议确定后实现) + Future _sendCommand(String command, [String? data]) async { + // TODO: 根据硬件通信协议实现 + // 示例协议格式: [CMD:data] 或 二进制协议 + } + + /// 监听设备反馈(待硬件协议确定后实现) + void _listenToDevice(RunnerCallbacks callbacks) { + // TODO: 解析设备返回的状态数据 + // 状态格式示例: [STEP:1,TIME:60,POS:A1] + } + + /// 执行单个步骤 + Future _executeStep(Step step) async { + // TODO: 根据步骤参数生成控制指令 + // 混合: MIX(position, time, speed) + // 吸磁: MAGNET(position, time) + // 吹气: BLOW(position, speed, time) + } +} \ No newline at end of file diff --git a/lib/features/home/pages/complete_page.dart b/lib/features/home/pages/complete_page.dart new file mode 100644 index 0000000..145f31c --- /dev/null +++ b/lib/features/home/pages/complete_page.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +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'; + +/// 运行完成提示页面 +class CompletePage extends ConsumerWidget { + const CompletePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final runState = ref.watch(runStateProvider); + final runNotifier = ref.read(runStateProvider.notifier); + + return Scaffold( + body: Container( + color: AppTheme.backgroundColor, + 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('/'); + } + }, + ), + ], + ), + ], + ), + ), + ), + ), + ); + } + + /// 操作指引示意图 + Widget _buildOperationGuide() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppTheme.backgroundColor, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.idleColor.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Text( + '操作步骤', + style: TextStyle( + color: AppTheme.textPrimary, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildStepItem(1, '取出样本', Icons.science), + _buildStepItem(2, '滴入检测卡', Icons.water_drop), + _buildStepItem(3, '等待反应', Icons.timer), + _buildStepItem(4, '查看结果', Icons.visibility), + ], + ), + ], + ), + ); + } + + /// 步骤项 + Widget _buildStepItem(int number, String text, IconData icon) { + return Column( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: AppTheme.primaryColor, size: 24), + ), + const SizedBox(height: 8), + Text( + '$number', + style: TextStyle( + color: AppTheme.primaryColor, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + text, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/features/home/pages/home_page.dart b/lib/features/home/pages/home_page.dart new file mode 100644 index 0000000..08da9f2 --- /dev/null +++ b/lib/features/home/pages/home_page.dart @@ -0,0 +1,176 @@ +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/theme/app_theme.dart'; +import '../../device/providers/run_state_provider.dart'; +import '../../programs/pages/programs_page.dart'; +import '../../settings/pages/settings_page.dart'; +import '../widgets/status_bar.dart'; +import '../widgets/program_list.dart'; +import '../widgets/running_control_panel.dart'; +import '../widgets/run_status_monitor.dart'; + +/// 首页 - 设备控制面板 (暗色工业风格) +/// 布局:状态栏 + 导航标签栏 + 内容区(设备控制/程序管理/系统设置) +class HomePage extends ConsumerStatefulWidget { + const HomePage({super.key}); + + @override + ConsumerState createState() => _HomePageState(); +} + +class _HomePageState extends ConsumerState + with SingleTickerProviderStateMixin { + bool _lightOn = false; + final bool _ceramicSleeveInstalled = false; // TODO: 后续对接硬件传感器后改为可变状态 + int _currentIndex = 0; + + @override + void initState() { + super.initState(); + DatabaseService.instance.initTestData(); + } + + @override + Widget build(BuildContext context) { + final runState = ref.watch(runStateProvider); + + // 监听运行完成状态,自动跳转 + ref.listen (runStateProvider, (prev, next) { + if (prev?.status != RunStatus.completed && next.status == RunStatus.completed) { + // 仅首页才自动跳转 + if (_currentIndex == 0) { + context.push('/complete'); + } + } + }); + + return Scaffold( + body: Container( + color: AppTheme.bgDeep, + child: Column( + children: [ + // 状态栏 + StatusBar( + isRunning: runState.status == RunStatus.running, + lightOn: _lightOn, + onLightToggle: () { + setState(() { + _lightOn = !_lightOn; + }); + }, + ceramicSleeveInstalled: _ceramicSleeveInstalled, + ), + + // 导航标签栏 + _buildTabBar(), + + // 内容区 + Expanded( + child: IndexedStack( + index: _currentIndex, + children: [ + _buildDeviceControlPage(runState), + const ProgramsPage(), + const SettingsPage(), + ], + ), + ), + ], + ), + ), + ); + } + + /// 导航标签栏 + 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), + child: Row( + children: [ + // 左侧:程序列表(运行时锁定) + Opacity( + opacity: runState.status == RunStatus.idle ? 1.0 : 0.6, + child: IgnorePointer( + ignoring: runState.status != RunStatus.idle, + child: const ProgramList(), + ), + ), + const SizedBox(width: 20), + // 右侧:运行控制区域 + Expanded( + child: Column( + children: [ + const Expanded(child: RunningControlPanel()), + if (runState.status != RunStatus.idle) ...[ + const SizedBox(height: 16), + const Expanded(child: RunStatusMonitor()), + ], + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/widgets/program_list.dart b/lib/features/home/widgets/program_list.dart new file mode 100644 index 0000000..c758444 --- /dev/null +++ b/lib/features/home/widgets/program_list.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../../core/localization/app_localizations.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../programs/models/program.dart'; +import '../../programs/providers/programs_provider.dart'; + +/// 程序列表组件 - 暗色工业风格 +/// 显示程序卡片列表,支持选择操作 +class ProgramList extends ConsumerWidget { + const ProgramList({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final programsState = ref.watch(programsProvider); + final programsNotifier = ref.read(programsProvider.notifier); + + return Container( + width: 380, + decoration: BoxDecoration( + color: AppTheme.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.borderSubtle, width: 1), + ), + child: Column( + children: [ + // 标题 + Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Icon(Icons.list_alt, color: AppTheme.textHeading, size: 18), + const SizedBox(width: 10), + Text( + l10n?.availablePrograms ?? '可用程序', + style: const TextStyle( + color: AppTheme.textHeading, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + + // 程序列表 + Expanded( + child: programsState.isLoading + ? const Center(child: CircularProgressIndicator()) + : programsState.programs.isEmpty + ? Center( + child: Text( + l10n?.noData ?? '暂无数据', + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 14), + itemCount: programsState.programs.length, + itemBuilder: (context, index) { + final program = programsState.programs[index]; + final isSelected = + programsState.selectedProgramId == program.id; + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: _ProgramCard( + program: program, + isSelected: isSelected, + onTap: () { + programsNotifier.selectProgram(program.id); + }, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +/// 单个程序卡片 - 暗色工业风格 +class _ProgramCard extends StatelessWidget { + final Program program; + final bool isSelected; + final VoidCallback? onTap; + + const _ProgramCard({ + required this.program, + this.isSelected = false, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + final createdAt = _parseDate(program.createdAt); + + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSelected ? AppTheme.cardSelectedBg : AppTheme.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? AppTheme.accentPrimary : AppTheme.borderSubtle, + width: isSelected ? 2 : 1, + ), + ), + child: Row( + children: [ + // 选择指示器 + Container( + width: 20, + height: 20, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? AppTheme.accentPrimary + : Colors.transparent, + border: Border.all( + color: isSelected + ? AppTheme.accentPrimary + : AppTheme.statusStopped, + width: 2, + ), + ), + child: isSelected + ? const Icon(Icons.check, color: Colors.white, size: 12) + : null, + ), + const SizedBox(width: 12), + + // 程序信息 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + program.code, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: program.status == 1 + ? AppTheme.statusRunning.withValues(alpha: 0.15) + : AppTheme.statusStopped.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + program.status == 1 ? '启用' : '停用', + style: TextStyle( + color: program.status == 1 + ? AppTheme.statusRunning + : AppTheme.statusStopped, + fontSize: 10, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + program.name, + style: const TextStyle( + color: AppTheme.textHeading, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + createdAt != null + ? dateFormat.format(createdAt) + : program.createdAt, + style: const TextStyle( + color: AppTheme.textTertiary, + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + DateTime? _parseDate(String dateStr) { + try { + return DateTime.parse(dateStr); + } catch (e) { + return null; + } + } +} diff --git a/lib/features/home/widgets/run_status_monitor.dart b/lib/features/home/widgets/run_status_monitor.dart new file mode 100644 index 0000000..09b82c7 --- /dev/null +++ b/lib/features/home/widgets/run_status_monitor.dart @@ -0,0 +1,242 @@ +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 '../../device/providers/run_state_provider.dart'; + +/// 运行状态监控面板 - 暗色工业风格 +/// 显示当前孔位、步骤、倒计时、进度条、参数详情 +class RunStatusMonitor extends ConsumerWidget { + const RunStatusMonitor({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final runState = ref.watch(runStateProvider); + + if (runState.status == RunStatus.idle) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.cardBg, + 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, + ), + ), + const Spacer(), + Text( + runState.currentProgram?.name ?? '', + style: const TextStyle( + color: AppTheme.accentPrimary, + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + + 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, + ), + ], + ), + + const SizedBox(height: 14), + + // 总进度条 + _buildProgressBar(l10n, runState), + + const SizedBox(height: 14), + + // 步骤参数 + if (runState.currentStep != null) + _buildStepParams(l10n, runState.currentStep!), + ], + ), + ); + } + + /// 信息块 + Widget _buildInfoBlock({ + required String label, + required String value, + String? subValue, + Color valueColor = AppTheme.textHeading, + double valueSize = 16, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + color: AppTheme.textTertiary, + fontSize: 11, + ), + ), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + color: valueColor, + fontSize: valueSize, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + if (subValue != null) ...[ + const SizedBox(height: 2), + Text( + subValue, + style: const TextStyle( + color: AppTheme.textSecondary, + fontSize: 11, + ), + ), + ], + ], + ); + } + + /// 进度条 + Widget _buildProgressBar(AppLocalizations? l10n, RunState runState) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + l10n?.progress ?? '总进度', + style: const TextStyle( + color: AppTheme.textTertiary, + fontSize: 11, + ), + ), + const Spacer(), + Text( + runState.formattedProgress, + style: const TextStyle( + color: AppTheme.accentPrimary, + fontSize: 12, + fontWeight: FontWeight.w600, + fontFamily: 'monospace', + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: runState.progress, + minHeight: 8, + backgroundColor: const Color(0xFF1E293B), + valueColor: AlwaysStoppedAnimation (AppTheme.accentPrimary), + ), + ), + ], + ); + } + + /// 步骤参数详情 + Widget _buildStepParams(AppLocalizations? l10n, dynamic step) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n?.stepParams ?? '步骤参数', + style: const TextStyle( + color: AppTheme.textTertiary, + 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', + ), + ], + ); + } + + /// 参数行 + 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', + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/widgets/running_control_panel.dart b/lib/features/home/widgets/running_control_panel.dart new file mode 100644 index 0000000..03b479d --- /dev/null +++ b/lib/features/home/widgets/running_control_panel.dart @@ -0,0 +1,365 @@ +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/widgets/common_button.dart'; +import '../../device/providers/run_state_provider.dart'; +import '../../programs/providers/programs_provider.dart'; + +/// 运行控制面板 - 暗色工业风格 +/// 显示当前程序信息、瓷套棒状态和运行控制按钮 +class RunningControlPanel extends ConsumerWidget { + const RunningControlPanel({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = AppLocalizations.of(context); + final runState = ref.watch(runStateProvider); + final programsState = ref.watch(programsProvider); + + return Container( + decoration: BoxDecoration( + color: AppTheme.cardBg, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppTheme.borderSubtle, width: 1), + ), + child: runState.status == RunStatus.idle + ? _buildIdleState(context, ref, l10n, programsState.selectedProgram) + : _buildRunningState(context, ref, l10n, runState), + ); + } + + /// 待机状态布局 + Widget _buildIdleState( + BuildContext context, + WidgetRef ref, + AppLocalizations? l10n, + dynamic selectedProgram, + ) { + final runNotifier = ref.read(runStateProvider.notifier); + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 当前选中程序显示 + if (selectedProgram != null) + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.cardSelectedBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppTheme.accentPrimary, width: 1), + ), + child: Row( + children: [ + Text( + '${l10n?.selectedProgramLabel ?? '当前选中'}:', + style: const TextStyle( + color: AppTheme.textTertiary, + fontSize: 12, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '${selectedProgram.code} ${selectedProgram.name}', + style: const TextStyle( + color: AppTheme.accentPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.cardBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppTheme.borderSubtle, width: 1), + ), + child: Text( + l10n?.pleaseSelectProgram ?? '请选择要运行的程序', + style: const TextStyle( + color: AppTheme.textTertiary, + fontSize: 12, + ), + ), + ), + + 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: [ + // 开始运行按钮 + Expanded( + flex: 2, + child: SizedBox( + height: 48, + child: CommonButton( + text: l10n?.startRun ?? '开始运行', + icon: Icons.play_arrow, + type: ButtonType.primary, + enabled: selectedProgram != null, + onPressed: selectedProgram != null + ? () => runNotifier.start(selectedProgram) + : 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( + height: 48, + child: CommonButton( + text: l10n?.stop ?? '停止', + icon: Icons.stop, + type: ButtonType.danger, + enabled: false, + onPressed: null, + ), + ), + ), + ], + ), + ], + ), + ); + } + + /// 运行状态布局 + Widget _buildRunningState( + BuildContext context, + WidgetRef ref, + AppLocalizations? l10n, + RunState runState, + ) { + final runNotifier = ref.read(runStateProvider.notifier); + + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 当前程序名称 + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: AppTheme.cardSelectedBg, + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppTheme.accentPrimary, width: 1), + ), + child: Row( + children: [ + Text( + '${l10n?.selectedProgramLabel ?? '当前选中'}:', + style: const TextStyle( + color: AppTheme.textTertiary, + fontSize: 12, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + runState.currentProgram?.name ?? '', + style: const TextStyle( + color: AppTheme.accentPrimary, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + const SizedBox(height: 12), + + // 控制按钮 + Row( + children: [ + // 开始/继续按钮 + Expanded( + flex: 2, + child: SizedBox( + height: 48, + child: CommonButton( + text: runState.status == RunStatus.paused + ? (l10n?.continue_ ?? '继续') + : (l10n?.run ?? '运行'), + icon: runState.status == RunStatus.paused + ? Icons.play_arrow + : Icons.play_arrow, + 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(), + ), + ), + ), + const SizedBox(width: 12), + // 停止按钮 + Expanded( + child: SizedBox( + height: 48, + child: CommonButton( + text: l10n?.stop ?? '停止', + icon: Icons.stop, + type: ButtonType.danger, + onPressed: () => _showStopConfirm(context, runNotifier, l10n), + ), + ), + ), + ], + ), + + 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 _showStopConfirm( + BuildContext context, + RunStateNotifier runNotifier, + AppLocalizations? l10n, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: AppTheme.cardBg, + title: Text( + l10n?.confirm ?? '确认', + style: const TextStyle(color: AppTheme.textHeading), + ), + content: Text( + l10n?.stopConfirm ?? '确定要停止当前运行的程序吗?', + 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.accentCritical, + foregroundColor: Colors.white, + ), + onPressed: () { + runNotifier.stop(); + Navigator.of(context).pop(); + }, + child: Text(l10n?.confirm ?? '确认'), + ), + ], + ), + ); + } +} diff --git a/lib/features/home/widgets/status_bar.dart b/lib/features/home/widgets/status_bar.dart new file mode 100644 index 0000000..c8d64bb --- /dev/null +++ b/lib/features/home/widgets/status_bar.dart @@ -0,0 +1,181 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import '../../../core/localization/app_localizations.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../shared/widgets/status_indicator.dart'; + +/// 状态栏组件 - 明亮工业风格 +/// 显示设备名称、实时时钟、系统状态、照明控制、瓷套棒状态 +class StatusBar extends StatefulWidget { + final bool isRunning; + final bool lightOn; + final VoidCallback? onLightToggle; + final bool ceramicSleeveInstalled; + + const StatusBar({ + super.key, + this.isRunning = false, + this.lightOn = false, + this.onLightToggle, + this.ceramicSleeveInstalled = false, + }); + + @override + State createState() => _StatusBarState(); +} + +class _StatusBarState extends State { + String _currentTime = ''; + Timer? _timer; + + @override + void initState() { + super.initState(); + _updateTime(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) => _updateTime()); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + void _updateTime() { + final now = DateTime.now(); + _currentTime = + '${now.year}-${_twoDigits(now.month)}-${_twoDigits(now.day)} ' + '${_twoDigits(now.hour)}:${_twoDigits(now.minute)}:${_twoDigits(now.second)}'; + if (mounted) setState(() {}); + } + + String _twoDigits(int n) => n.toString().padLeft(2, '0'); + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.primaryColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + 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 Spacer(), + _LightToggleButton(isOn: widget.lightOn, onTap: widget.onLightToggle), + const SizedBox(width: 16), + _CeramicSleeveStatus(installed: widget.ceramicSleeveInstalled), + const SizedBox(width: 20), + StatusIndicator( + text: widget.isRunning + ? (l10n?.running ?? '运行中') + : (l10n?.idle ?? '未运行'), + status: widget.isRunning + ? DeviceStatusType.running + : DeviceStatusType.idle, + ), + const SizedBox(width: 20), + Text( + _currentTime, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontFamily: 'monospace', + fontWeight: FontWeight.normal, + ), + ), + ], + ), + ); + } +} + +class _CeramicSleeveStatus extends StatelessWidget { + final bool installed; + const _CeramicSleeveStatus({required this.installed}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: installed ? Colors.greenAccent : Colors.redAccent, + ), + ), + const SizedBox(width: 6), + Text( + installed ? '瓷套棒: 已安装' : '瓷套棒: 未安装', + style: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 12, + ), + ), + ], + ); + } +} + +class _LightToggleButton extends StatelessWidget { + final bool isOn; + final VoidCallback? onTap; + const _LightToggleButton({this.isOn = false, this.onTap}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(20), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isOn + ? Colors.white.withValues(alpha: 0.25) + : Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Icon( + isOn ? Icons.lightbulb : Icons.lightbulb_outline_rounded, + color: isOn ? Colors.yellowAccent : Colors.white.withValues(alpha: 0.8), + size: 20, + ), + ), + ), + ); + } +} diff --git a/lib/features/program_detail/pages/program_detail_page.dart b/lib/features/program_detail/pages/program_detail_page.dart new file mode 100644 index 0000000..d0ade1c --- /dev/null +++ b/lib/features/program_detail/pages/program_detail_page.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/localization/app_localizations.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../shared/widgets/common_button.dart'; +import '../providers/steps_provider.dart'; +import '../widgets/step_list.dart'; +import '../widgets/step_form.dart'; +import '../../programs/providers/programs_provider.dart'; + +/// 程序详情页面 +/// 左侧步骤列表 + 右侧参数表单 +class ProgramDetailPage extends ConsumerStatefulWidget { + final String programId; + + const ProgramDetailPage({super.key, required this.programId}); + + @override + ConsumerState createState() => _ProgramDetailPageState(); +} + +class _ProgramDetailPageState extends ConsumerState { + late int _programIdInt; + + @override + void initState() { + super.initState(); + _programIdInt = int.tryParse(widget.programId) ?? 0; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final programsState = ref.watch(programsProvider); + final program = programsState.programs.where((p) => p.id == _programIdInt).firstOrNull; + final stepsState = ref.watch(stepsProvider(_programIdInt)); + + 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, + ), + ); + }, + ), + ], + ), + ), + + // 主内容区域 + Expanded( + child: stepsState.isLoading + ? const Center(child: CircularProgressIndicator()) + : Row( + children: [ + // 左侧:步骤列表 + SizedBox( + width: 400, + child: StepList( + programId: _programIdInt, + steps: stepsState.steps, + selectedStepId: stepsState.selectedStepId, + onStepSelected: (stepId) { + ref.read(stepsProvider(_programIdInt).notifier).selectStep(stepId); + }, + onAddStep: () => _showAddStepDialog(context, ref), + onReorder: (oldIndex, newIndex) { + ref.read(stepsProvider(_programIdInt).notifier).reorderSteps(oldIndex, newIndex); + }, + onDeleteSteps: (stepIds) { + ref.read(stepsProvider(_programIdInt).notifier).deleteSteps(stepIds); + }, + ), + ), + + // 分隔线 + Container( + width: 1, + color: AppTheme.idleColor.withValues(alpha: 0.3), + ), + + // 右侧:步骤参数表单 + Expanded( + child: stepsState.selectedStep != null + ? StepForm( + programId: _programIdInt, + step: stepsState.selectedStep!, + onSave: (step) async { + final success = await ref + .read(stepsProvider(_programIdInt).notifier) + .updateStep(step); + if (success) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('步骤已更新'), + backgroundColor: AppTheme.successColor, + ), + ); + } + }, + ) + : Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.edit_note, + size: 64, + color: AppTheme.idleColor, + ), + const SizedBox(height: 16), + Text( + '请选择或添加步骤', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// 显示添加步骤对话框 + void _showAddStepDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (context) => Dialog( + child: Container( + width: 600, + padding: const EdgeInsets.all(24), + child: StepForm( + programId: _programIdInt, + isNew: true, + onSave: (step) async { + final success = await ref + .read(stepsProvider(_programIdInt).notifier) + .addStep(step); + if (success) { + Navigator.of(context).pop(); + } + }, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/program_detail/providers/steps_provider.dart b/lib/features/program_detail/providers/steps_provider.dart new file mode 100644 index 0000000..85d5358 --- /dev/null +++ b/lib/features/program_detail/providers/steps_provider.dart @@ -0,0 +1,161 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../programs/models/step.dart'; +import '../../programs/services/program_service.dart'; + +/// 步骤状态 +class StepsState { + final List steps; + final int? selectedStepId; + final bool isLoading; + final String? error; + + const StepsState({ + this.steps = const [], + this.selectedStepId, + this.isLoading = false, + this.error, + }); + + StepsState copyWith({ + List ? steps, + int? selectedStepId, + bool? isLoading, + String? error, + bool clearSelection = false, + bool clearError = false, + }) { + return StepsState( + steps: steps ?? this.steps, + selectedStepId: clearSelection ? null : (selectedStepId ?? this.selectedStepId), + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } + + /// 获取选中的步骤 + Step? get selectedStep { + if (selectedStepId == null) return null; + return steps.where((s) => s.id == selectedStepId).firstOrNull; + } +} + +/// 步骤 Notifier +class StepsNotifier extends StateNotifier { + final ProgramService _service; + final int programId; + + StepsNotifier(this._service, this.programId) : super(const StepsState()) { + loadSteps(); + } + + /// 加载步骤 + Future loadSteps() async { + state = state.copyWith(isLoading: true, clearError: true); + try { + final steps = await _service.getStepsByProgramId(programId); + state = state.copyWith(steps: steps, isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// 选择步骤 + void selectStep(int? stepId) { + state = state.copyWith(selectedStepId: stepId); + } + + /// 清除选择 + void clearSelection() { + state = state.copyWith(clearSelection: true); + } + + /// 添加步骤 + Future addStep(Step step) async { + try { + await _service.addStep(step); + await loadSteps(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 更新步骤 + Future updateStep(Step step) async { + if (step.id == null) return false; + try { + await _service.updateStep(step); + await loadSteps(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 删除步骤 + Future deleteStep(int stepId) async { + try { + await _service.deleteStep(stepId); + if (state.selectedStepId == stepId) { + state = state.copyWith(clearSelection: true); + } + await loadSteps(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 批量删除步骤 + Future deleteSteps(List stepIds) async { + try { + await _service.deleteSteps(stepIds); + if (stepIds.contains(state.selectedStepId)) { + state = state.copyWith(clearSelection: true); + } + await loadSteps(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 重新排序步骤 + Future reorderSteps(int oldIndex, int newIndex) async { + final steps = List .from(state.steps); + final step = steps.removeAt(oldIndex); + steps.insert(newIndex, step); + + // 更新 step_no + for (int i = 0; i < steps.length; i++) { + steps[i] = steps[i].copyWith(stepNo: i + 1); + } + + state = state.copyWith(steps: steps); + + // 持久化排序 + await _service.reorderSteps(programId, steps.map((s) => s.id!).toList()); + } +} + +/// 程序服务 Provider +final programServiceProvider = Provider ((ref) { + return ProgramService.instance; +}); + +/// 步骤 Provider(按程序ID) +final stepsProvider = StateNotifierProvider.family ( + (ref, programId) { + final service = ref.watch(programServiceProvider); + return StepsNotifier(service, programId); + }, +); + +/// 选中的步骤 Provider +final selectedStepProvider = Provider.family ((ref, programId) { + return ref.watch(stepsProvider(programId)).selectedStep; +}); \ No newline at end of file diff --git a/lib/features/program_detail/widgets/step_form.dart b/lib/features/program_detail/widgets/step_form.dart new file mode 100644 index 0000000..a66388f --- /dev/null +++ b/lib/features/program_detail/widgets/step_form.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.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 '../../programs/models/step.dart' as models; + +/// 步骤参数表单 +class StepForm extends StatefulWidget { + final int programId; + final models.Step? step; + final bool isNew; + final void Function(models.Step) onSave; + + const StepForm({ + super.key, + required this.programId, + this.step, + this.isNew = false, + required this.onSave, + }); + + @override + State createState() => _StepFormState(); +} + +class _StepFormState extends State { + final _formKey = GlobalKey (); + + late TextEditingController _nameController; + late TextEditingController _mixTimeController; + late TextEditingController _magnetTimeController; + late TextEditingController _volumeController; + late TextEditingController _blowTimeController; + + String _position = 'A1'; + String _mixSpeed = '中速'; + String _blowSpeed = '中速'; + int _needleSpeed = 5; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.step?.name ?? ''); + _mixTimeController = TextEditingController(text: '${widget.step?.mixTime ?? 0}'); + _magnetTimeController = TextEditingController(text: '${widget.step?.magnetTime ?? 0}'); + _volumeController = TextEditingController(text: '${widget.step?.volume ?? 0}'); + _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; + } + + @override + void dispose() { + _nameController.dispose(); + _mixTimeController.dispose(); + _magnetTimeController.dispose(); + _volumeController.dispose(); + _blowTimeController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + Text( + widget.isNew ? '添加步骤' : '编辑步骤', + style: TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + + // 步骤名称 + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n?.stepName ?? '步骤名称', + hintText: '例如: 混合、吸磁、吹气', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入步骤名称'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // 孔位选择 + Row( + children: [ + Text(l10n?.position ?? '孔位', style: TextStyle(color: AppTheme.textPrimary)), + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField ( + value: _position, + decoration: InputDecoration( + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + items: Constants.positions.map((p) => DropdownMenuItem(value: p, child: Text(p))).toList(), + onChanged: (value) { + if (value != null) setState(() => _position = value); + }, + ), + ), + ], + ), + const SizedBox(height: 16), + + // 时间参数行 + Row( + children: [ + Expanded( + child: TextFormField( + controller: _mixTimeController, + decoration: InputDecoration( + labelText: '${l10n?.mixTime ?? '混合时间'} (${Constants.timeUnitSeconds})', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _magnetTimeController, + decoration: InputDecoration( + labelText: '${l10n?.magnetTime ?? '吸磁时间'} (${Constants.timeUnitSeconds})', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 16), + + // 容积和吹气时间 + Row( + children: [ + Expanded( + child: TextFormField( + controller: _volumeController, + decoration: InputDecoration( + labelText: '${l10n?.volume ?? '容积'} (${Constants.volumeUnit})', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _blowTimeController, + decoration: InputDecoration( + labelText: '${l10n?.blowTime ?? '吹气时间'} (${Constants.timeUnitMinutes})', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + ), + keyboardType: TextInputType.number, + ), + ), + ], + ), + const SizedBox(height: 16), + + // 速度选择 + Row( + children: [ + Expanded( + child: DropdownButtonFormField ( + 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 ( + 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)), + Slider( + value: _needleSpeed.toDouble(), + min: 1, + max: 10, + divisions: 9, + activeColor: AppTheme.primaryColor, + onChanged: (value) { + setState(() => _needleSpeed = value.round()); + }, + ), + ], + ), + + const SizedBox(height: 24), + + // 保存按钮 + CommonButton( + text: l10n?.save ?? '保存', + icon: Icons.save, + type: ButtonType.primary, + onPressed: _saveStep, + ), + ], + ), + ), + ); + } + + /// 保存步骤 + void _saveStep() { + if (!_formKey.currentState!.validate()) return; + + final step = models.Step( + id: widget.step?.id, + programId: widget.programId, + stepNo: widget.step?.stepNo ?? 1, + position: _position, + name: _nameController.text.trim(), + 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, + ); + + widget.onSave(step); + } +} \ No newline at end of file diff --git a/lib/features/program_detail/widgets/step_list.dart b/lib/features/program_detail/widgets/step_list.dart new file mode 100644 index 0000000..3286dbc --- /dev/null +++ b/lib/features/program_detail/widgets/step_list.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import '../../../core/localization/app_localizations.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../shared/widgets/common_button.dart'; +import '../../programs/models/step.dart' as models; + +/// 步骤列表组件 +class StepList extends StatefulWidget { + final int programId; + final List steps; + final int? selectedStepId; + final void Function(int?) onStepSelected; + final void Function() onAddStep; + final void Function(int oldIndex, int newIndex)? onReorder; + final void Function(List stepIds)? onDeleteSteps; + + const StepList({ + super.key, + required this.programId, + required this.steps, + this.selectedStepId, + required this.onStepSelected, + required this.onAddStep, + this.onReorder, + this.onDeleteSteps, + }); + + @override + State createState() => _StepListState(); +} + +class _StepListState extends State { + final Set _selectedIds = {}; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final allSelected = _selectedIds.length == widget.steps.length && widget.steps.isNotEmpty; + + return Container( + color: Colors.white, + child: Column( + children: [ + // 标题 + Container( + height: 50, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + ), + child: Row( + children: [ + Icon(Icons.list, color: AppTheme.primaryColor, size: 20), + const SizedBox(width: 12), + Text( + '步骤列表', + style: TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + Text( + '${widget.steps.length} 步', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 12, + ), + ), + ], + ), + ), + + // 表头 + Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: AppTheme.idleColor.withValues(alpha: 0.2)), + ), + ), + child: Row( + children: [ + SizedBox( + width: 40, + child: Checkbox( + value: allSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedIds.clear(); + _selectedIds.addAll(widget.steps.map((s) => s.id!)); + } else { + _selectedIds.clear(); + } + }); + }, + ), + ), + SizedBox(width: 40, child: Text('#', style: TextStyle(fontSize: 12))), + Expanded(child: Text(l10n?.stepName ?? '名称', style: TextStyle(fontSize: 12))), + SizedBox(width: 60, child: Text(l10n?.position ?? '孔位', style: TextStyle(fontSize: 12))), + ], + ), + ), + + // 步骤列表(可拖拽排序) + Expanded( + child: widget.steps.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.add_circle_outline, size: 48, color: AppTheme.idleColor), + const SizedBox(height: 12), + Text('暂无步骤', style: TextStyle(color: AppTheme.textSecondary)), + ], + ), + ) + : ReorderableListView.builder( + padding: const EdgeInsets.all(8), + itemCount: widget.steps.length, + onReorder: (oldIndex, newIndex) { + if (widget.onReorder != null) { + // 调整 newIndex(ReorderableListView 的特殊行为) + if (newIndex > oldIndex) newIndex -= 1; + widget.onReorder!(oldIndex, newIndex); + } + }, + itemBuilder: (context, index) { + final step = widget.steps[index]; + final isSelected = widget.selectedStepId == step.id || _selectedIds.contains(step.id); + return _buildStepItem(step, isSelected, index); + }, + ), + ), + + // 底部操作栏 + Container( + height: 60, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: AppTheme.idleColor.withValues(alpha: 0.2)), + ), + ), + child: Row( + children: [ + // 添加按钮 + CommonButton( + text: '添加', + icon: Icons.add, + type: ButtonType.primary, + onPressed: widget.onAddStep, + ), + const SizedBox(width: 12), + // 删除按钮 + if (_selectedIds.isNotEmpty) + CommonButton( + text: '删除', + icon: Icons.delete, + type: ButtonType.danger, + onPressed: () => _showDeleteConfirmDialog(context), + ), + ], + ), + ), + ], + ), + ); + } + + /// 步骤项 + Widget _buildStepItem(models.Step step, bool isSelected, int index) { + return Container( + key: ValueKey(step.id), + margin: const EdgeInsets.symmetric(vertical: 2), + decoration: BoxDecoration( + color: isSelected ? AppTheme.primaryLight.withValues(alpha: 0.3) : Colors.white, + borderRadius: BorderRadius.circular(4), + border: isSelected ? Border.all(color: AppTheme.primaryColor, width: 2) : null, + ), + child: ListTile( + dense: true, + leading: Checkbox( + value: _selectedIds.contains(step.id), + onChanged: (value) { + setState(() { + if (value == true) { + _selectedIds.add(step.id!); + } else { + _selectedIds.remove(step.id!); + } + }); + }, + ), + title: Row( + children: [ + Container( + width: 30, + alignment: Alignment.center, + child: Text( + '${step.stepNo}', + style: TextStyle( + color: AppTheme.primaryColor, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Expanded(child: Text(step.name)), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + step.position, + style: TextStyle( + color: AppTheme.primaryColor, + fontSize: 12, + ), + ), + ), + ], + ), + trailing: Icon(Icons.drag_handle, color: AppTheme.idleColor), + onTap: () => widget.onStepSelected(step.id), + ), + ); + } + + /// 显示删除确认对话框 + void _showDeleteConfirmDialog(BuildContext context) { + final l10n = AppLocalizations.of(context); + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(l10n?.confirm ?? '确认'), + content: Text( + _selectedIds.length == 1 + ? '确定要删除此步骤吗?' + : '确定要删除选中的 ${_selectedIds.length} 个步骤吗?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text(l10n?.cancel ?? '取消'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.errorColor, + foregroundColor: Colors.white, + ), + onPressed: () { + Navigator.of(ctx).pop(); + if (widget.onDeleteSteps != null) { + widget.onDeleteSteps!(_selectedIds.toList()); + } + setState(() { + _selectedIds.clear(); + }); + }, + child: Text(l10n?.confirm ?? '确认'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/programs/models/program.dart b/lib/features/programs/models/program.dart new file mode 100644 index 0000000..aaa967f --- /dev/null +++ b/lib/features/programs/models/program.dart @@ -0,0 +1,52 @@ +/// 程序模型 +class Program { + final int? id; + final String code; + final String name; + final String createdAt; + final int status; // 1: 启用, 0: 停用 + + Program({ + this.id, + required this.code, + required this.name, + required this.createdAt, + this.status = 1, + }); + + Map toMap() { + return { + 'id': id, + 'code': code, + 'name': name, + 'created_at': createdAt, + 'status': status, + }; + } + + factory Program.fromMap(Map map) { + return Program( + id: map['id'] as int?, + code: map['code'] as String, + name: map['name'] as String, + createdAt: map['created_at'] as String, + status: map['status'] as int? ?? 1, + ); + } + + Program copyWith({ + int? id, + String? code, + String? name, + String? createdAt, + int? status, + }) { + return Program( + id: id ?? this.id, + code: code ?? this.code, + name: name ?? this.name, + createdAt: createdAt ?? this.createdAt, + status: status ?? this.status, + ); + } +} \ No newline at end of file diff --git a/lib/features/programs/models/step.dart b/lib/features/programs/models/step.dart new file mode 100644 index 0000000..ce8635b --- /dev/null +++ b/lib/features/programs/models/step.dart @@ -0,0 +1,94 @@ +/// 步骤模型 +class Step { + final int? id; + final int programId; + final int stepNo; + final String position; + final String name; + final int mixTime; + final int magnetTime; + final int volume; + final String mixSpeed; + final String blowSpeed; + final int blowTime; + final int needleSpeed; + + Step({ + this.id, + required this.programId, + required this.stepNo, + required this.position, + required this.name, + this.mixTime = 0, + this.magnetTime = 0, + this.volume = 0, + this.mixSpeed = '中速', + this.blowSpeed = '中速', + this.blowTime = 0, + this.needleSpeed = 5, + }); + + Map toMap() { + return { + 'id': id, + 'program_id': programId, + 'step_no': stepNo, + 'position': position, + 'name': name, + 'mix_time': mixTime, + 'magnet_time': magnetTime, + 'volume': volume, + 'mix_speed': mixSpeed, + 'blow_speed': blowSpeed, + 'blow_time': blowTime, + 'needle_speed': needleSpeed, + }; + } + + factory Step.fromMap(Map map) { + return Step( + id: map['id'] as int?, + programId: map['program_id'] as int, + stepNo: map['step_no'] as int, + position: map['position'] as String, + name: map['name'] as String, + 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, + ); + } + + Step copyWith({ + int? id, + int? programId, + int? stepNo, + String? position, + String? name, + int? mixTime, + int? magnetTime, + int? volume, + String? mixSpeed, + String? blowSpeed, + int? blowTime, + int? needleSpeed, + }) { + return Step( + id: id ?? this.id, + programId: programId ?? this.programId, + stepNo: stepNo ?? this.stepNo, + position: position ?? this.position, + name: name ?? this.name, + 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, + ); + } +} \ No newline at end of file diff --git a/lib/features/programs/pages/programs_page.dart b/lib/features/programs/pages/programs_page.dart new file mode 100644 index 0000000..75934c1 --- /dev/null +++ b/lib/features/programs/pages/programs_page.dart @@ -0,0 +1,509 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:file_picker/file_picker.dart'; +import 'dart:io'; +import '../../../core/localization/app_localizations.dart'; +import '../../../core/theme/app_theme.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'; + +/// 程序管理页面 +class ProgramsPage extends ConsumerStatefulWidget { + const ProgramsPage({super.key}); + + @override + ConsumerState createState() => _ProgramsPageState(); +} + +class _ProgramsPageState extends ConsumerState { + final Set _selectedIds = {}; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final programsState = ref.watch(programsProvider); + + 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('/'), + ), + const SizedBox(width: 16), + Text( + l10n?.programs ?? '程序管理', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w600, + ), + ), + const Spacer(), + // 新增按钮 + CommonButton( + text: l10n?.addProgram ?? '新增', + icon: Icons.add, + type: ButtonType.primary, + onPressed: () => _showAddDialog(context, ref), + ), + const SizedBox(width: 12), + // 导入按钮 + CommonButton( + text: l10n?.importProgram ?? '导入', + icon: Icons.file_upload, + type: ButtonType.secondary, + onPressed: () => _importPrograms(context, ref), + ), + ], + ), + ), + + // 程序列表表格 + Expanded( + child: programsState.isLoading + ? const Center(child: CircularProgressIndicator()) + : programsState.programs.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.folder_open, + size: 64, + color: AppTheme.idleColor, + ), + const SizedBox(height: 16), + Text( + l10n?.noData ?? '暂无数据', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + ), + ), + ], + ), + ) + : Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(2, 2), + ), + ], + ), + child: Column( + children: [ + // 表头 + _buildTableHeader(l10n, programsState.programs), + // 表格内容 + Expanded( + child: ListView.builder( + itemCount: programsState.programs.length, + itemBuilder: (context, index) { + final program = programsState.programs[index]; + final isSelected = _selectedIds.contains(program.id); + return _buildTableRow( + context, + ref, + l10n, + program, + isSelected, + index == programsState.programs.length - 1, + ); + }, + ), + ), + ], + ), + ), + ), + + // 底部操作栏 + if (programsState.programs.isNotEmpty) + 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: [ + Text( + '${l10n?.selected ?? '已选择'}: ${_selectedIds.length}', + style: TextStyle(color: AppTheme.textSecondary), + ), + const Spacer(), + if (_selectedIds.isNotEmpty) + CommonButton( + text: l10n?.deleteProgram ?? '删除', + icon: Icons.delete, + type: ButtonType.danger, + onPressed: () => _showDeleteConfirmDialog( + context, + ref, + l10n, + _selectedIds.toList(), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + /// 表头 + Widget _buildTableHeader(AppLocalizations? l10n, List programs) { + final allSelected = _selectedIds.length == programs.length && programs.isNotEmpty; + + return Container( + height: 50, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + borderRadius: const BorderRadius.vertical(top: Radius.circular(12)), + ), + child: Row( + children: [ + // 复选框 + SizedBox( + width: 50, + child: Checkbox( + value: allSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedIds.clear(); + _selectedIds.addAll(programs.map((p) => p.id!)); + } else { + _selectedIds.clear(); + } + }); + }, + ), + ), + // 编号 + SizedBox( + width: 100, + child: Text( + l10n?.programCode ?? '编号', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + // 名称 + Expanded( + flex: 2, + child: Text( + l10n?.programName ?? '名称', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + // 创建时间 + Expanded( + child: Text( + l10n?.createTime ?? '创建时间', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + // 状态 + SizedBox( + width: 80, + child: Text( + '状态', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + ), + ), + // 操作 + SizedBox( + width: 150, + child: Text( + l10n?.detail ?? '操作', + style: TextStyle( + fontWeight: FontWeight.w600, + color: AppTheme.textPrimary, + ), + textAlign: TextAlign.center, + ), + ), + ], + ), + ); + } + + /// 表格行 + Widget _buildTableRow( + BuildContext context, + WidgetRef ref, + AppLocalizations? l10n, + Program program, + bool isSelected, + bool isLast, + ) { + return Container( + height: 50, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: isSelected ? AppTheme.primaryLight.withValues(alpha: 0.2) : null, + border: isLast + ? null + : Border( + bottom: BorderSide( + color: AppTheme.idleColor.withValues(alpha: 0.2), + ), + ), + ), + child: Row( + children: [ + // 复选框 + SizedBox( + width: 50, + child: Checkbox( + value: isSelected, + onChanged: (value) { + setState(() { + if (value == true) { + _selectedIds.add(program.id!); + } else { + _selectedIds.remove(program.id!); + } + }); + }, + ), + ), + // 编号 + SizedBox( + width: 100, + child: Text( + program.code, + style: TextStyle(color: AppTheme.textPrimary), + ), + ), + // 名称 + Expanded( + flex: 2, + child: Text( + program.name, + style: TextStyle(color: AppTheme.textPrimary), + ), + ), + // 创建时间 + Expanded( + child: Text( + program.createdAt, + style: TextStyle(color: AppTheme.textSecondary), + ), + ), + // 状态 + SizedBox( + width: 80, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: program.status == 1 + ? AppTheme.successColor.withValues(alpha: 0.1) + : AppTheme.idleColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + program.status == 1 ? '启用' : '停用', + style: TextStyle( + color: program.status == 1 + ? AppTheme.successColor + : AppTheme.idleColor, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ), + ), + // 操作按钮 + SizedBox( + width: 150, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + color: AppTheme.primaryColor, + onPressed: () => _showEditDialog(context, ref, program), + ), + IconButton( + icon: const Icon(Icons.delete, size: 20), + color: AppTheme.errorColor, + onPressed: () => _showDeleteConfirmDialog( + context, + ref, + l10n, + [program.id!], + ), + ), + IconButton( + icon: const Icon(Icons.visibility, size: 20), + color: AppTheme.textSecondary, + onPressed: () => context.go('/programs/${program.id}'), + ), + ], + ), + ), + ], + ), + ); + } + + /// 显示新增对话框 + void _showAddDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (context) => const ProgramFormDialog(), + ); + } + + /// 导入程序 + Future _importPrograms(BuildContext context, WidgetRef ref) async { + try { + // 选择文件 + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['json'], + allowMultiple: false, + ); + + if (result == null || result.files.isEmpty) { + return; + } + + final file = result.files.first; + if (file.path == null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('无法读取文件'), + backgroundColor: AppTheme.errorColor, + ), + ); + return; + } + + // 读取文件内容 + final jsonContent = await File(file.path!).readAsString(); + + // 导入程序 + final importedCount = await ProgramImportService.instance.importFromJson(jsonContent); + + // 刷新程序列表 + ref.read(programsProvider.notifier).loadPrograms(); + + // 显示结果 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('成功导入 $importedCount 个程序'), + backgroundColor: importedCount > 0 ? AppTheme.successColor : AppTheme.warningColor, + ), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('导入失败: ${e.toString()}'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } + + /// 显示编辑对话框 + void _showEditDialog(BuildContext context, WidgetRef ref, Program program) { + showDialog( + context: context, + builder: (context) => ProgramFormDialog(program: program), + ); + } + + /// 显示删除确认对话框 + void _showDeleteConfirmDialog( + BuildContext context, + WidgetRef ref, + AppLocalizations? l10n, + List ids, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n?.confirm ?? '确认'), + content: Text( + ids.length == 1 + ? '确定要删除此程序吗?' + : '确定要删除选中的 ${ids.length} 个程序吗?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n?.cancel ?? '取消'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.errorColor, + foregroundColor: Colors.white, + ), + onPressed: () async { + final notifier = ref.read(programsProvider.notifier); + await notifier.deletePrograms(ids); + setState(() { + _selectedIds.removeAll(ids); + }); + Navigator.of(context).pop(); + }, + child: Text(l10n?.confirm ?? '确认'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/programs/providers/programs_provider.dart b/lib/features/programs/providers/programs_provider.dart new file mode 100644 index 0000000..290b411 --- /dev/null +++ b/lib/features/programs/providers/programs_provider.dart @@ -0,0 +1,192 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/database/database_service.dart'; +import '../models/program.dart'; + +/// 程序列表状态 +class ProgramsState { + final List programs; + final int? selectedProgramId; + final bool isLoading; + final String? error; + + const ProgramsState({ + this.programs = const [], + this.selectedProgramId, + this.isLoading = false, + this.error, + }); + + ProgramsState copyWith({ + List ? programs, + int? selectedProgramId, + bool? isLoading, + String? error, + bool clearSelection = false, + bool clearError = false, + }) { + return ProgramsState( + programs: programs ?? this.programs, + selectedProgramId: clearSelection ? null : (selectedProgramId ?? this.selectedProgramId), + isLoading: isLoading ?? this.isLoading, + error: clearError ? null : (error ?? this.error), + ); + } + + /// 获取选中的程序 + Program? get selectedProgram { + if (selectedProgramId == null) return null; + return programs.where((p) => p.id == selectedProgramId).firstOrNull; + } +} + +/// 程序列表 Notifier +class ProgramsNotifier extends StateNotifier { + final DatabaseService _db; + + ProgramsNotifier(this._db) : super(const ProgramsState()) { + loadPrograms(); + } + + /// 加载所有程序 + Future loadPrograms() async { + state = state.copyWith(isLoading: true, clearError: true); + + try { + final db = await _db.database; + final maps = await db.query('programs', orderBy: 'created_at DESC'); + final programs = maps.map((m) => Program.fromMap(m)).toList(); + + state = state.copyWith(programs: programs, isLoading: false); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + /// 选择程序 + void selectProgram(int? programId) { + state = state.copyWith(selectedProgramId: programId); + } + + /// 清除选择 + void clearSelection() { + state = state.copyWith(clearSelection: true); + } + + /// 新增程序 + Future addProgram(Program program) async { + try { + final db = await _db.database; + await db.insert('programs', program.toMap()); + await loadPrograms(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 更新程序 + Future updateProgram(Program program) async { + if (program.id == null) return false; + + try { + final db = await _db.database; + await db.update( + 'programs', + program.toMap(), + where: 'id = ?', + whereArgs: [program.id], + ); + await loadPrograms(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 删除程序 + Future deleteProgram(int programId) async { + try { + final db = await _db.database; + await db.delete('programs', where: 'id = ?', whereArgs: [programId]); + + // 如果删除的是选中的程序,清除选择 + if (state.selectedProgramId == programId) { + state = state.copyWith(clearSelection: true); + } + + await loadPrograms(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 批量删除程序 + Future deletePrograms(List programIds) async { + try { + final db = await _db.database; + await db.delete( + 'programs', + where: 'id IN (${programIds.map((_) => '?').join(',')})', + whereArgs: programIds, + ); + + // 如果删除的是选中的程序,清除选择 + if (programIds.contains(state.selectedProgramId)) { + state = state.copyWith(clearSelection: true); + } + + await loadPrograms(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } + + /// 切换程序状态 + Future toggleStatus(int programId) async { + try { + final db = await _db.database; + final program = state.programs.where((p) => p.id == programId).firstOrNull; + if (program == null) return false; + + await db.update( + 'programs', + {'status': program.status == 1 ? 0 : 1}, + where: 'id = ?', + whereArgs: [programId], + ); + await loadPrograms(); + return true; + } catch (e) { + state = state.copyWith(error: e.toString()); + return false; + } + } +} + +/// 数据库服务 Provider +final databaseServiceProvider = Provider ((ref) { + return DatabaseService.instance; +}); + +/// 程序列表 Provider +final programsProvider = + StateNotifierProvider ((ref) { + final db = ref.watch(databaseServiceProvider); + return ProgramsNotifier(db); +}); + +/// 选中的程序 Provider +final selectedProgramProvider = Provider ((ref) { + return ref.watch(programsProvider).selectedProgram; +}); + +/// 启用的程序列表 Provider +final enabledProgramsProvider = Provider >((ref) { + return ref.watch(programsProvider).programs.where((p) => p.status == 1).toList(); +}); \ No newline at end of file diff --git a/lib/features/programs/services/program_import_service.dart b/lib/features/programs/services/program_import_service.dart new file mode 100644 index 0000000..0dceecf --- /dev/null +++ b/lib/features/programs/services/program_import_service.dart @@ -0,0 +1,126 @@ +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
importFromJson(String jsonContent) async { + final data = jsonDecode(jsonContent); + + // 支持单个程序或程序数组 + final List 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 data) { + return data.containsKey('code') && + data.containsKey('name') && + data['code'] is String && + data['name'] is String; + } + + /// 导出程序为 JSON + Future exportToJson(List 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}); + } +} \ No newline at end of file diff --git a/lib/features/programs/services/program_service.dart b/lib/features/programs/services/program_service.dart new file mode 100644 index 0000000..c4d0624 --- /dev/null +++ b/lib/features/programs/services/program_service.dart @@ -0,0 +1,156 @@ +import '../../../core/database/database_service.dart'; +import '../models/program.dart'; +import '../models/step.dart'; + +/// 程序服务 +/// 封装程序和步骤的数据库操作 +class ProgramService { + static final ProgramService instance = ProgramService._internal(); + final DatabaseService _db = DatabaseService.instance; + + ProgramService._internal(); + + /// 获取所有程序 + Future > getAllPrograms() async { + final database = await _db.database; + final maps = await database.query('programs', orderBy: 'created_at DESC'); + return maps.map((m) => Program.fromMap(m)).toList(); + } + + /// 根据ID获取程序 + Future
getProgramById(int id) async { + final database = await _db.database; + final maps = await database.query( + 'programs', + where: 'id = ?', + whereArgs: [id], + ); + if (maps.isEmpty) return null; + return Program.fromMap(maps.first); + } + + /// 新增程序 + Future addProgram(Program program) async { + final database = await _db.database; + return await database.insert('programs', program.toMap()); + } + + /// 更新程序 + Future updateProgram(Program program) async { + if (program.id == null) return false; + final database = await _db.database; + final count = await database.update( + 'programs', + program.toMap(), + where: 'id = ?', + whereArgs: [program.id], + ); + return count > 0; + } + + /// 删除程序(含步骤) + Future deleteProgram(int id) async { + final database = await _db.database; + // 先删除关联的步骤 + await database.delete('steps', where: 'program_id = ?', whereArgs: [id]); + // 再删除程序 + final count = await database.delete('programs', where: 'id = ?', whereArgs: [id]); + return count > 0; + } + + /// 批量删除程序 + Future deletePrograms(List ids) async { + if (ids.isEmpty) return true; + final database = await _db.database; + // 先删除关联的步骤 + await database.delete( + 'steps', + where: 'program_id IN (${ids.map((_) => '?').join(',')})', + whereArgs: ids, + ); + // 再删除程序 + final count = await database.delete( + 'programs', + where: 'id IN (${ids.map((_) => '?').join(',')})', + whereArgs: ids, + ); + return count > 0; + } + + /// 切换程序状态 + Future toggleProgramStatus(int id) async { + final database = await _db.database; + final program = await getProgramById(id); + if (program == null) return false; + final count = await database.update( + 'programs', + {'status': program.status == 1 ? 0 : 1}, + where: 'id = ?', + whereArgs: [id], + ); + return count > 0; + } + + /// 获取程序的步骤列表 + Future > getStepsByProgramId(int programId) async { + final database = await _db.database; + final maps = await database.query( + 'steps', + where: 'program_id = ?', + whereArgs: [programId], + orderBy: 'step_no ASC', + ); + return maps.map((m) => Step.fromMap(m)).toList(); + } + + /// 新增步骤 + Future
addStep(Step step) async { + final database = await _db.database; + return await database.insert('steps', step.toMap()); + } + + /// 更新步骤 + Future updateStep(Step step) async { + if (step.id == null) return false; + final database = await _db.database; + final count = await database.update( + 'steps', + step.toMap(), + where: 'id = ?', + whereArgs: [step.id], + ); + return count > 0; + } + + /// 删除步骤 + Future deleteStep(int id) async { + final database = await _db.database; + final count = await database.delete('steps', where: 'id = ?', whereArgs: [id]); + return count > 0; + } + + /// 批量删除步骤 + Future deleteSteps(List ids) async { + if (ids.isEmpty) return true; + final database = await _db.database; + final count = await database.delete( + 'steps', + where: 'id IN (${ids.map((_) => '?').join(',')})', + whereArgs: ids, + ); + return count > 0; + } + + /// 更新步骤排序 + Future reorderSteps(int programId, List stepIds) async { + final database = await _db.database; + for (int i = 0; i < stepIds.length; i++) { + await database.update( + 'steps', + {'step_no': i + 1}, + where: 'id = ? AND program_id = ?', + whereArgs: [stepIds[i], programId], + ); + } + } +} \ No newline at end of file diff --git a/lib/features/programs/widgets/program_form_dialog.dart b/lib/features/programs/widgets/program_form_dialog.dart new file mode 100644 index 0000000..8d21402 --- /dev/null +++ b/lib/features/programs/widgets/program_form_dialog.dart @@ -0,0 +1,188 @@ +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/widgets/common_button.dart'; +import '../models/program.dart'; +import '../providers/programs_provider.dart'; + +/// 程序表单弹窗 +/// 用于新增和编辑程序 +class ProgramFormDialog extends ConsumerStatefulWidget { + final Program? program; + + const ProgramFormDialog({super.key, this.program}); + + @override + ConsumerState createState() => _ProgramFormDialogState(); +} + +class _ProgramFormDialogState extends ConsumerState { + final _formKey = GlobalKey (); + late TextEditingController _codeController; + late TextEditingController _nameController; + bool _isEnabled = true; + bool _isSaving = false; + + @override + void initState() { + super.initState(); + _codeController = TextEditingController(text: widget.program?.code ?? ''); + _nameController = TextEditingController(text: widget.program?.name ?? ''); + _isEnabled = widget.program?.status == 1; + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final isEditing = widget.program != null; + + return AlertDialog( + title: Text( + isEditing + ? (l10n?.editProgram ?? '编辑程序') + : (l10n?.addProgram ?? '新增程序'), + ), + content: SizedBox( + width: 400, + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 编号输入 + TextFormField( + controller: _codeController, + decoration: InputDecoration( + labelText: l10n?.programCode ?? '编号', + hintText: '例如: P001', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入编号'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // 名称输入 + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n?.programName ?? '名称', + hintText: '请输入程序名称', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + validator: (value) { + if (value == null || value.trim().isEmpty) { + return '请输入名称'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // 状态开关 + Row( + children: [ + Text( + '状态', + style: TextStyle(color: AppTheme.textPrimary), + ), + const Spacer(), + Switch( + value: _isEnabled, + onChanged: (value) { + setState(() { + _isEnabled = value; + }); + }, + activeColor: AppTheme.successColor, + ), + Text( + _isEnabled ? '启用' : '停用', + style: TextStyle( + color: _isEnabled ? AppTheme.successColor : AppTheme.idleColor, + ), + ), + ], + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: _isSaving ? null : () => Navigator.of(context).pop(), + child: Text(l10n?.cancel ?? '取消'), + ), + CommonButton( + text: l10n?.save ?? '保存', + icon: Icons.save, + type: ButtonType.primary, + isLoading: _isSaving, + onPressed: _isSaving ? null : () => _saveProgram(context, ref, l10n), + ), + ], + ); + } + + /// 保存程序 + Future _saveProgram( + BuildContext context, + WidgetRef ref, + AppLocalizations? l10n, + ) async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isSaving = true; + }); + + final notifier = ref.read(programsProvider.notifier); + final now = DateTime.now().toString().substring(0, 10); + + final program = Program( + id: widget.program?.id, + code: _codeController.text.trim(), + name: _nameController.text.trim(), + createdAt: widget.program?.createdAt ?? now, + status: _isEnabled ? 1 : 0, + ); + + bool success; + if (widget.program != null) { + success = await notifier.updateProgram(program); + } else { + success = await notifier.addProgram(program); + } + + setState(() { + _isSaving = false; + }); + + if (success) { + Navigator.of(context).pop(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('保存失败,请检查编号是否重复'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + } +} \ No newline at end of file diff --git a/lib/features/settings/pages/settings_page.dart b/lib/features/settings/pages/settings_page.dart new file mode 100644 index 0000000..e1e9c01 --- /dev/null +++ b/lib/features/settings/pages/settings_page.dart @@ -0,0 +1,382 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../core/localization/app_localizations.dart'; +import '../../../core/localization/locale_provider.dart'; +import '../../../core/theme/app_theme.dart'; +import '../../../shared/widgets/common_button.dart'; +import '../services/settings_service.dart'; + +/// 系统设置页面 +class SettingsPage extends ConsumerStatefulWidget { + const SettingsPage({super.key}); + + @override + ConsumerState createState() => _SettingsPageState(); +} + +class _SettingsPageState extends ConsumerState { + String _currentVersion = 'V1.0.0'; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + // locale 用于语言切换,通过 ref.watch 保持监听 + + return Scaffold( + body: Container( + color: AppTheme.backgroundColor, + child: Row( + children: [ + // 左侧导航菜单 + SizedBox( + width: 280, + child: Container( + color: Colors.white, + child: Column( + children: [ + // 返回按钮 + Container( + height: 50, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back), + color: AppTheme.textPrimary, + onPressed: () => context.go('/'), + ), + Text( + '返回首页', + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 14, + ), + ), + ], + ), + ), + // 设置标题 + Container( + height: 60, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.primaryColor.withValues(alpha: 0.1), + ), + child: Row( + children: [ + Icon(Icons.settings, color: AppTheme.primaryColor, size: 24), + const SizedBox(width: 12), + Text( + l10n?.settings ?? '系统设置', + style: TextStyle( + color: AppTheme.primaryColor, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + // 软件升级 + _buildMenuItem( + icon: Icons.system_update, + title: l10n?.upgrade ?? '软件升级', + onTap: () {}, + ), + // 语言设置 + _buildMenuItem( + icon: Icons.language, + title: l10n?.language ?? '语言设置', + onTap: () => _showLanguageDialog(), + ), + // 安全设置 + _buildMenuItem( + icon: Icons.lock, + title: l10n?.password ?? '密码修改', + onTap: () => _showPasswordDialog(), + ), + // U盘导入 + _buildMenuItem( + icon: Icons.usb, + title: l10n?.usbImport ?? 'U盘导入', + onTap: () => _showUsbImportDialog(), + ), + ], + ), + ), + ), + + // 右侧内容区域 + Expanded( + child: Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n?.upgrade ?? '软件升级', + style: TextStyle( + color: AppTheme.textPrimary, + fontSize: 18, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 24), + + // 版本信息 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppTheme.backgroundColor, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: AppTheme.primaryColor), + const SizedBox(width: 12), + Text( + '当前版本: $_currentVersion', + style: TextStyle(color: AppTheme.textPrimary), + ), + ], + ), + ), + const SizedBox(height: 24), + + // 检查更新按钮 + CommonButton( + text: '检查更新', + icon: Icons.refresh, + type: ButtonType.primary, + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('已是最新版本'), + backgroundColor: AppTheme.successColor, + ), + ); + }, + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + /// 导航菜单项 + Widget _buildMenuItem({ + required IconData icon, + required String title, + required VoidCallback onTap, + }) { + return ListTile( + leading: Icon(icon, color: AppTheme.textSecondary), + title: Text(title, style: TextStyle(color: AppTheme.textPrimary)), + trailing: Icon(Icons.chevron_right, color: AppTheme.idleColor), + onTap: onTap, + ); + } + + /// 显示语言选择对话框 + void _showLanguageDialog() { + final locale = ref.read(localeProvider); + final currentLang = locale.languageCode; + + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text('语言设置'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + RadioListTile ( + title: Text('简体中文'), + value: 'zh', + groupValue: currentLang, + onChanged: (value) { + ref.read(localeProvider.notifier).setChinese(); + Navigator.of(ctx).pop(); + }, + ), + RadioListTile ( + title: Text('English'), + value: 'en', + groupValue: currentLang, + onChanged: (value) { + ref.read(localeProvider.notifier).setEnglish(); + Navigator.of(ctx).pop(); + }, + ), + ], + ), + ), + ); + } + + /// 显示密码修改对话框 + void _showPasswordDialog() { + final oldPasswordController = TextEditingController(); + final newPasswordController = TextEditingController(); + final confirmPasswordController = TextEditingController(); + String? errorMessage; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setState) => AlertDialog( + title: Text('密码修改'), + content: SizedBox( + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: oldPasswordController, + decoration: InputDecoration( + labelText: '原密码', + errorText: null, + ), + obscureText: true, + ), + const SizedBox(height: 12), + TextField( + controller: newPasswordController, + decoration: InputDecoration( + labelText: '新密码', + helperText: '至少6位字符', + ), + obscureText: true, + ), + const SizedBox(height: 12), + TextField( + controller: confirmPasswordController, + decoration: InputDecoration(labelText: '确认新密码'), + obscureText: true, + ), + if (errorMessage != null) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + errorMessage!, + style: TextStyle(color: AppTheme.errorColor, fontSize: 12), + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: Text('取消'), + ), + ElevatedButton( + onPressed: () async { + // 验证逻辑 + final oldPassword = oldPasswordController.text.trim(); + final newPassword = newPasswordController.text.trim(); + final confirmPassword = confirmPasswordController.text.trim(); + + // 检查空值 + if (oldPassword.isEmpty || newPassword.isEmpty || confirmPassword.isEmpty) { + setState(() => errorMessage = '请填写所有字段'); + return; + } + + // 检查新密码长度 + if (newPassword.length < 6) { + setState(() => errorMessage = '新密码至少6位字符'); + return; + } + + // 检查新密码一致性 + if (newPassword != confirmPassword) { + setState(() => errorMessage = '两次输入的新密码不一致'); + return; + } + + // 验证原密码 + final isValid = await SettingsService.instance.verifyPassword(oldPassword); + if (!isValid) { + setState(() => errorMessage = '原密码错误'); + return; + } + + // 保存新密码 + final success = await SettingsService.instance.setPassword(newPassword); + Navigator.of(ctx).pop(); + + if (success) { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text('密码已修改'), + backgroundColor: AppTheme.successColor, + ), + ); + } else { + ScaffoldMessenger.of(this.context).showSnackBar( + SnackBar( + content: Text('密码修改失败'), + backgroundColor: AppTheme.errorColor, + ), + ); + } + }, + child: Text('确认'), + ), + ], + ), + ), + ); + } + + /// 显示U盘导入对话框 + void _showUsbImportDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('U盘导入'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.usb, size: 48, color: AppTheme.warningColor), + const SizedBox(height: 16), + Text('未检测到U盘'), + const SizedBox(height: 8), + Text( + '请插入U盘后重试', + style: TextStyle(color: AppTheme.textSecondary, fontSize: 12), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text('关闭'), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('正在检测U盘...'), + backgroundColor: AppTheme.primaryColor, + ), + ); + }, + child: Text('重新检测'), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/features/settings/services/settings_service.dart b/lib/features/settings/services/settings_service.dart new file mode 100644 index 0000000..d754606 --- /dev/null +++ b/lib/features/settings/services/settings_service.dart @@ -0,0 +1,63 @@ +import '../../../core/database/database_service.dart'; + +/// 设置服务 +/// 管理系统设置(密码、语言偏好等) +class SettingsService { + static final SettingsService instance = SettingsService._internal(); + final DatabaseService _db = DatabaseService.instance; + + SettingsService._internal(); + + /// 获取密码 + Future getPassword() async { + final database = await _db.database; + final results = await database.query( + 'settings', + where: 'key = ?', + whereArgs: ['password'], + ); + if (results.isEmpty) return '123456'; // 默认密码 + return results.first['value'] as String; + } + + /// 设置密码 + Future setPassword(String newPassword) async { + final database = await _db.database; + final count = await database.update( + 'settings', + {'value': newPassword}, + where: 'key = ?', + whereArgs: ['password'], + ); + return count > 0; + } + + /// 验证密码 + Future verifyPassword(String password) async { + final storedPassword = await getPassword(); + return password == storedPassword; + } + + /// 获取设置值 + Future getSetting(String key) async { + final database = await _db.database; + final results = await database.query( + 'settings', + where: 'key = ?', + whereArgs: [key], + ); + if (results.isEmpty) return null; + return results.first['value'] as String; + } + + /// 设置值 + Future setSetting(String key, String value) async { + final database = await _db.database; + // 使用 insert 或 replace + await database.execute( + 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', + [key, value], + ); + return true; + } +} \ No newline at end of file diff --git a/lib/features/settings/services/usb_detection_service.dart b/lib/features/settings/services/usb_detection_service.dart new file mode 100644 index 0000000..fa5bd1f --- /dev/null +++ b/lib/features/settings/services/usb_detection_service.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +/// USB 检测服务 +/// 监听 U盘插入/拔出事件 +class UsbDetectionService { + static final UsbDetectionService instance = UsbDetectionService._internal(); + + UsbDetectionService._internal(); + + /// USB 状态 + bool _isUsbConnected = false; + String? _usbPath; + + /// 状态流 + final StreamController _stateController = StreamController .broadcast(); + + /// 监听 USB 状态变化 + Stream get stateStream => _stateController.stream; + + /// 当前 USB 是否连接 + bool get isConnected => _isUsbConnected; + + /// USB 路径 + String? get usbPath => _usbPath; + + /// 开始监听 USB 事件 + void startMonitoring() { + // TODO: 实现平台特定的 USB 监听 + // Android: 使用 BroadcastReceiver 监听 ACTION_MEDIA_MOUNTED + // Linux: 监听 /dev/disk/by-path/ 或使用 udev + // Windows: 监听 WM_DEVICECHANGE + + // 模拟实现:定时检测 + _startPolling(); + } + + /// 停止监听 + void stopMonitoring() { + // _stopPolling(); + } + + /// 模拟轮询检测(待平台实现) + void _startPolling() { + // TODO: 根据平台实现真实的 USB 检测 + // 定时检测 /mnt/usb 或 /media/*/ 目录 + } + + /// 手动检测 USB + Future detectUsb() async { + // TODO: 实现平台特定的 USB 检测 + // Android: 检查 getExternalFilesDir 或 mount points + // Linux: 检查 /mnt, /media 目录 + // Windows: 检查 D:, E: 等驱动器 + + // 返回检测结果 + return _isUsbConnected; + } + + /// 获取 USB 上的程序文件列表 + Future > listProgramFiles() async { + if (!_isUsbConnected || _usbPath == null) { + return []; + } + + // TODO: 扫描 USB 目录中的 .json 程序文件 + // 示例路径: $_usbPath/programs/*.json + + return []; + } + + /// 模拟 USB 连接(用于测试) + void simulateConnection(String path) { + _isUsbConnected = true; + _usbPath = path; + _stateController.add(UsbState.connected(path)); + } + + /// 模拟 USB 断开(用于测试) + void simulateDisconnection() { + _isUsbConnected = false; + _usbPath = null; + _stateController.add(UsbState.disconnected()); + } + + /// 释放资源 + void dispose() { + stopMonitoring(); + _stateController.close(); + } +} + +/// USB 状态 +class UsbState { + final bool isConnected; + final String? path; + + const UsbState.connected(String path) : isConnected = true, path = path; + const UsbState.disconnected() : isConnected = false, path = null; +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..9378274 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; + +import 'core/router/app_router.dart'; +import 'core/theme/app_theme.dart'; +import 'core/localization/app_localizations.dart'; +import 'core/localization/locale_provider.dart'; +import 'core/database/database_service.dart'; + +/// 应用入口 +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + // Kiosk 模式:隐藏系统状态栏和导航栏 + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + + // 固定横屏 + SystemChrome.setPreferredOrientations([DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]); + + final db = DatabaseService.instance; + await db.database; + await db.initTestData(); + runApp(const ProviderScope(child: KuaishaiApp())); +} + +/// 应用主体 +class KuaishaiApp extends ConsumerWidget { + const KuaishaiApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(goRouterProvider); + final locale = ref.watch(localeProvider); + + return MaterialApp.router( + title: '污水毒品快检一体机', + debugShowCheckedModeBanner: false, + theme: AppTheme.lightTheme(), + darkTheme: AppTheme.darkTheme(), + themeMode: ThemeMode.light, + + // 国际化配置 + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('zh', 'CN'), + Locale('en', 'US'), + ], + locale: locale, + + // 路由配置 + routerConfig: router, + ); + } +} \ No newline at end of file diff --git a/lib/shared/services/toast_service.dart b/lib/shared/services/toast_service.dart new file mode 100644 index 0000000..24bf80e --- /dev/null +++ b/lib/shared/services/toast_service.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; + +/// Toast 服务 +/// 统一的消息提示管理 +class ToastService { + /// 显示成功提示 + static void showSuccess(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: AppTheme.successColor, + duration: const Duration(seconds: 3), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + ), + ); + } + + /// 显示错误提示 + static void showError(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error, color: Colors.white, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: AppTheme.errorColor, + duration: const Duration(seconds: 4), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + ), + ); + } + + /// 显示警告提示 + static void showWarning(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.warning, color: Colors.white, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: AppTheme.warningColor, + duration: const Duration(seconds: 3), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + ), + ); + } + + /// 显示信息提示 + static void showInfo(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.info, color: Colors.white, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(message)), + ], + ), + backgroundColor: AppTheme.primaryColor, + duration: const Duration(seconds: 3), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(16), + ), + ); + } +} \ No newline at end of file diff --git a/lib/shared/utils/constants.dart b/lib/shared/utils/constants.dart new file mode 100644 index 0000000..2d21963 --- /dev/null +++ b/lib/shared/utils/constants.dart @@ -0,0 +1,24 @@ +/// 常量定义 +class Constants { + // 速度选项 + static const List
speedOptions = ['低速', '中速', '高速']; + + // 下针速度档位 + static const List needleSpeedLevels = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + // 孔位列表 + static const List positions = [ + 'A1', 'A2', 'A3', 'A4', 'A5', 'A6', + 'B1', 'B2', 'B3', 'B4', 'B5', 'B6', + 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', + 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', + ]; + + // 默认步骤名称 + static const List defaultStepNames = ['混合', '吸磁', '吹气', '下针']; + + // 时间单位 + static const String timeUnitSeconds = '秒'; + static const String timeUnitMinutes = '分钟'; + static const String volumeUnit = 'μL'; +} \ No newline at end of file diff --git a/lib/shared/utils/responsive_layout.dart b/lib/shared/utils/responsive_layout.dart new file mode 100644 index 0000000..a2298fe --- /dev/null +++ b/lib/shared/utils/responsive_layout.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// 响应式布局工具类 +/// 目标屏幕: 1920x1080 +class ResponsiveLayout { + static const double targetWidth = 1920; + static const double targetHeight = 1080; + + /// 获取屏幕宽度比例 + static double widthPercent(BuildContext context, double percent) { + return MediaQuery.of(context).size.width * percent; + } + + /// 获取屏幕高度比例 + static double heightPercent(BuildContext context, double percent) { + return MediaQuery.of(context).size.height * percent; + } + + /// 基于目标屏幕缩放宽度 + static double scaleWidth(BuildContext context, double targetValue) { + final screenWidth = MediaQuery.of(context).size.width; + return targetValue * (screenWidth / targetWidth); + } + + /// 基于目标屏幕缩放高度 + static double scaleHeight(BuildContext context, double targetValue) { + final screenHeight = MediaQuery.of(context).size.height; + return targetValue * (screenHeight / targetHeight); + } + + /// 基于目标屏幕缩放字体 + static double scaleFont(BuildContext context, double targetFontSize) { + return scaleWidth(context, targetFontSize); + } + + /// 预设布局尺寸 + static double sidebarWidth(BuildContext context) => widthPercent(context, 0.25); // 480px on 1920 + static double detailWidth(BuildContext context) => widthPercent(context, 0.21); // 400px on 1920 + static double navWidth(BuildContext context) => widthPercent(context, 0.15); // 280px on 1920 + static double cardWidth(BuildContext context) => widthPercent(context, 0.30); // ~576px +} + +/// 响应式间距 +class ResponsiveSpacing { + static double small(BuildContext context) => ResponsiveLayout.scaleWidth(context, 8); + static double medium(BuildContext context) => ResponsiveLayout.scaleWidth(context, 16); + static double large(BuildContext context) => ResponsiveLayout.scaleWidth(context, 24); + static double xlarge(BuildContext context) => ResponsiveLayout.scaleWidth(context, 32); +} \ No newline at end of file diff --git a/lib/shared/widgets/common_button.dart b/lib/shared/widgets/common_button.dart new file mode 100644 index 0000000..2aa6ea6 --- /dev/null +++ b/lib/shared/widgets/common_button.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; + +/// 通用按钮组件 - 明亮工业风格 +class CommonButton extends StatelessWidget { + final String text; + final VoidCallback? onPressed; + final bool enabled; + final Color? backgroundColor; + final Color? textColor; + final IconData? icon; + final bool isLoading; + final ButtonType type; + + const CommonButton({ + super.key, + required this.text, + this.onPressed, + this.enabled = true, + this.backgroundColor, + this.textColor, + this.icon, + this.isLoading = false, + this.type = ButtonType.primary, + }); + + @override + Widget build(BuildContext context) { + final bgColor = backgroundColor ?? _getDefaultBackgroundColor(); + final fgColor = textColor ?? _getDefaultTextColor(); + + Widget content; + if (isLoading) { + content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: fgColor, + ), + ), + const SizedBox(width: 8), + Text(text, style: TextStyle(fontWeight: FontWeight.w500)), + ], + ); + } else if (icon != null) { + content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Text(text, style: TextStyle(fontWeight: FontWeight.w500)), + ], + ); + } else { + content = Text(text, style: TextStyle(fontWeight: FontWeight.w500)); + } + + return ElevatedButton( + onPressed: enabled && !isLoading ? onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: bgColor, + foregroundColor: fgColor, + disabledBackgroundColor: AppTheme.statusStopped.withValues(alpha: 0.3), + disabledForegroundColor: AppTheme.textTertiary, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppTheme.radiusSm), + ), + ), + child: content, + ); + } + + Color _getDefaultBackgroundColor() { + switch (type) { + case ButtonType.primary: + return AppTheme.primaryColor; + case ButtonType.success: + return AppTheme.successColor; + case ButtonType.warning: + return AppTheme.warningColor; + case ButtonType.danger: + return AppTheme.errorColor; + case ButtonType.secondary: + return AppTheme.bgSurface; + } + } + + Color _getDefaultTextColor() { + switch (type) { + case ButtonType.secondary: + return AppTheme.textPrimary; + default: + return AppTheme.textOnPrimary; + } + } +} + +enum ButtonType { primary, success, warning, danger, secondary } diff --git a/lib/shared/widgets/common_card.dart b/lib/shared/widgets/common_card.dart new file mode 100644 index 0000000..bd426a7 --- /dev/null +++ b/lib/shared/widgets/common_card.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; + +/// 通用卡片组件 - 明亮工业风格 +class CommonCard extends StatelessWidget { + final Widget child; + final VoidCallback? onTap; + final bool selected; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + + const CommonCard({ + super.key, + required this.child, + this.onTap, + this.selected = false, + this.padding, + this.margin, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: margin ?? const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Material( + color: selected ? AppTheme.bgCardHover : AppTheme.bgCard, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + elevation: 0, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + child: Container( + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.radiusMd), + border: selected + ? Border.all(color: AppTheme.primaryColor, width: 2) + : Border.all(color: AppTheme.borderLight, width: 1), + boxShadow: AppTheme.shadowCard, + ), + child: child, + ), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/common_dialog.dart b/lib/shared/widgets/common_dialog.dart new file mode 100644 index 0000000..8ac738f --- /dev/null +++ b/lib/shared/widgets/common_dialog.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +/// 确认对话框组件 +class CommonDialog { + /// 显示确认对话框 + static Future showConfirm({ + required BuildContext context, + required String title, + required String content, + String confirmText = '确认', + String cancelText = '取消', + bool isDestructive = false, + }) { + return showDialog ( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: Text(cancelText), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: isDestructive ? Colors.red : null, + ), + child: Text(confirmText), + ), + ], + ), + ); + } + + /// 显示信息对话框 + static Future showInfo({ + required BuildContext context, + required String title, + required String content, + String confirmText = '确认', + }) { + return showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(content), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: Text(confirmText), + ), + ], + ), + ); + } + + /// 显示输入对话框 + static Future showInput({ + required BuildContext context, + required String title, + String? hintText, + String? initialValue, + String confirmText = '确认', + String cancelText = '取消', + }) { + final controller = TextEditingController(text: initialValue); + + return showDialog ( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: TextField( + decoration: InputDecoration( + hintText: hintText, + border: const OutlineInputBorder(), + ), + controller: controller, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: Text(cancelText), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text(confirmText), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/shared/widgets/empty_state_widget.dart b/lib/shared/widgets/empty_state_widget.dart new file mode 100644 index 0000000..4539800 --- /dev/null +++ b/lib/shared/widgets/empty_state_widget.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import '../../../core/theme/app_theme.dart'; + +/// 空状态组件 +/// 统一的空数据展示样式 +class EmptyStateWidget extends StatelessWidget { + final IconData icon; + final String message; + final String? actionText; + final VoidCallback? onAction; + + const EmptyStateWidget({ + super.key, + required this.icon, + required this.message, + this.actionText, + this.onAction, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 64, + color: AppTheme.idleColor, + ), + const SizedBox(height: 16), + Text( + message, + style: TextStyle( + color: AppTheme.textSecondary, + fontSize: 16, + ), + ), + if (actionText != null && onAction != null) ...[ + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: onAction, + icon: const Icon(Icons.add, size: 20), + label: Text(actionText!), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryColor, + foregroundColor: Colors.white, + ), + ), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/shared/widgets/status_indicator.dart b/lib/shared/widgets/status_indicator.dart new file mode 100644 index 0000000..8b46cde --- /dev/null +++ b/lib/shared/widgets/status_indicator.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import '../../core/theme/app_theme.dart'; + +/// 状态指示器组件 - 明亮工业风格 +class StatusIndicator extends StatelessWidget { + final String text; + final DeviceStatusType status; + final double size; + + const StatusIndicator({ + super.key, + required this.text, + required this.status, + this.size = 10, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getStatusColor(), + ), + ), + const SizedBox(width: 6), + Text( + text, + style: TextStyle( + color: _getStatusColor(), + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ); + } + + Color _getStatusColor() { + switch (status) { + case DeviceStatusType.running: + return AppTheme.statusRunning; + case DeviceStatusType.idle: + return AppTheme.statusStopped; + case DeviceStatusType.paused: + return AppTheme.statusPaused; + case DeviceStatusType.error: + return AppTheme.statusError; + case DeviceStatusType.success: + return AppTheme.statusRunning; + } + } +} + +enum DeviceStatusType { running, idle, paused, error, success } diff --git a/pencil/images/Untitled.fig b/pencil/images/Untitled.fig new file mode 100644 index 0000000000000000000000000000000000000000..b125c6a6b7e84a244fe02b7dde910a3321fe2fb8 GIT binary patch literal 630731 zcmZ6SV{|3Y*S2GGV%xUuWMbR4ZQHhOCzFY7TPL=SdGh;zde?hCbYHdm-n;JZ)9b7{ zUDXOQpkSy#P*6ZX8J^8LK>yDN28a&G*wD_+(3#%M(p*&q90=GE9?l8|i12?RU?5B& zL4fhG-pLm5|G)py(^*@(TblkTX{~{PT6>@~OqMkneP(*v)2{P!eZxb*gxNEQOqe!n zYrD;?itSWq;qtZA+*Rc 1rqe>TL#Mlr}F|3mQ@Ch-_d;~`Ar z7-Zug{@gvvah|QMBENh4=bvZZb?rIZdBgYN`SQ@BejR4`q0#PW)l4(Q1z9~TO=I!; z6c*s0YDK?0LI0LI>;ojV4tMWLbER Lc3q7Rdb>>t97_5fAD4TyfL*}jghZ>@yVw!4>1jhmB8d7 z)d#k(wREL*+UJWB2*bvJPoIX!L84BBvhQZbrZS_RRV$O*hwlPXqg_{R8$m7G)j|T_ zx{b$T>A`RPaTGuG82yG$4g0Mt-xf8!FtN1gjCyQSeb&4>Rhl0*Hdk?Q(aij!E3fXO zRkg `MtWD5d{cLcB-`sttzr$Xy2 )kvsiqD OUAI=-Fo+T#V7dB!KFsk!1`fJo|Aq(r{)#kCmc(I;E!_?!v2Ei z!n&e#lhGD{fx%%vBveq$Idp?McBY%8Gw3{qqh*DjF~Wh3C9Lu2$z`47zg|@7`XO+V zJt4|Z=Z2DhP*?0~wV AzmLhJt~XJGn?9lN1_r+Jy*``l8=^G zJzt7I6~)uVqcbeE2+F~eU#Ujf8NB}Z;H2B&uAptPmEgHH^DvA{fZZ!juok~H`C2x1 zgU+S Xz0n1@V?dtsd~%CUstz=1m;d!(>)|+YG6D8Ufo4aKZhsgjkU2_uwIA_o>5b zGvS?Akbky_;5V(4(Rs;L>ZcZ+TT_bXH3RJGj_RuyYPNv!awahhdJB-JkAnlyKb4sD z2R?PZ%&ox1R7bZVRP55Qhp~ZQ`j)KT*VfszIK0#EC46?Yo=wy5CtbaGYr{4 CcaMT3}=&R)vbo+<3ss`0<%5x!4UF zjwCype@mjjM{57IjuiKH2P!Y%LVli8cR-bvJ*~1*u-T({Uf$>l%<6#^Y86EhB;*pJ zAXi7ju@`)m`P#=sno{BAZs35U&3eSI(YDeAi5m 6Z&D4(w5=M1NBO zJVi`1*Z~2f*R*};;^QEm#fYbcl`513Bb%`-m-aVrOxM@Cs;hb*M>|X`1^V4PxAaHp z^k_vV{c7v@B(CLL!^_g}r2x&A#XY?DlX%>mcjDSD$Yle*qFrb>w<<@M;jc|@sr-j) z2d7yClcbtaB~}J1)~r@6Mj^92_z2W$@9+egVI>&*+8V%bq12#sp}Dd3D}r@Z7cXw( zV8;RI#=;BOJ+6!Ui0t|}{C=L*4Nwn$pez~DVK(HvEH0u*{mtAqN9DRH*~lv117D_b zN}`atqNF!1q`AJKaTb>qyu&$^cVC%qJxucUD{**kiZ)+c&W#glwAiAqbYjUD uYR?w8EJQBR+h*` $I^cdIRknn!B~l7@v U3F$&C!4d+pc_b~4ot&YT73pEcJF$cP9oyln%aI1tq`uUg zkcMRFuwv(-r&mYU7F*iG?%U$6=>BY=lL0Gr%J_Dn+m|bpxN!xu`<#!4v*iO%RzTj8 zi4!OB$BxSTPnSR_LittwJ3u)qEZz}i+3Ll<0>A?6#BLHzF%-~>M~tx`UeeiI!sA?9 zvJTNX`e9!-oYXJkBsGzmS6SQ%ysLYY4(w{^k&G@*R}Q)DzacdUtc+XgNNubCz$LR0 zzYX >kLAl(4oV&c;|27#*HMyu7hj+6>164suczeNGjiuglGH+ zQ?|;-wu{A(=s_U4D{~93=t5v{W-sUz zr-EK8Z6b6mufkmv{R=Dh>6GK@ )Oygc;ti!1F}OZBn?@bbw)TjW8`t@Yr7%Opa(LWy537oeH!RFKa#*N} zwS8oKP6aF^+}9S$<@%g+7d9qZo8%ti!ZDyWh}W8;9TyV#z-SwEsq%9&*-e8R6B*Sa z4PKKX+8NII8sLKjd*trzDh`@h;yWWLjowb;q(s(MrDtmg+6Kw0EUt*HWWxmwTCrE+ z5qM<4At5k46OovW &ys#sX zS_45DD0G^jVFF>aXhN)YE?`C~&d7ZRSrV0MaEroV^BHcUgd;61Aeg_qJBk=1Iwnw? zwwVe>+9@DRQ@o8CgIx)Hsp1}4ampO?>_D-Ou-L~8cv0B*joLyz$D%-V^;Vs8L&Ewt zRRfjc2~Q3;iGIzp0thjzO|vtN(hhwCv?xe#4b>Kyw$fnj_ng=?@zbJ(etp`(rKuFb z<;sDxku6Fw3_ObNr$JEC(|}ApB%{~eo@+#a;)FZWY%l@qfc!-tb+4RQ2sChIy?k-* zLB5gOU2+8Hn 4NM_wriqs2JS{;9ib3axn5826 z5FxxdMShLrPmo=}iR2=R? DL&`}P D+;ToexUvM>7Gc1 <^Jl4UyW8@2jjn@3=rD- zHCqa5)ex~hZ^5~3AeElw!R?*KC&wCvF!dBFC9t^i_AdB5+JvxvAgw?AxYeeh-e5Tl zRa$6BB((iuWfGT2xr{IyAc|}(nx#ru2pTCLbXCO*iyDPIqm1CF#)=kc9)yMrhxQI= zVcNyZc8(3~sB*tvTa0^zdb(FaEn(*nW;h{=28tt^SI=G`Mh#1= zSibgQ2ti4qAr3#b;dKxrI3o4y>vZgg1e^)A*zfHEp-I}yNz|$D>gWN#r86RJ>XWPn zqoE@sGilf`G(k-G7lUNk^-pruQ`G^LF6b3|`{JM(`UDxXY#GyQ2&39;4%gX~@H$o% zX^+P%(!CgU?mX)rmhR$VUa|K*3kuF~$eUviyGhPev1y>RT#A&TcHX~Xziv$ECQYo| z{A0^8>mew#VKsc Ls#HREWYSkP3FwoyZx+-QqKZiRZ#+&J>QhaPiqbH(W zb@&(%K#~geyduOh7^rZ`%PvY~KE2m}`M=yll uUR>L zQGJ5=`r9>fqp!)M((l(*1$Kg8rMri5;aRXl`Bq|SnZo#mEugF~o*R{~Ir3<|k8$zU zWjgZkene)qio;ww2)>Y DU6*M%sRT8L^l`qxndK$;Mb-(>XeE5 zqKz(R`Z Gr#~4=oq>Rtfw>bm6ll!Ry8|`=&M5o z)pA)5Ze9J0;GXfbBg(TzT+#C v~G3v_(WQXL(179c6u1JL3hYg1kx7%on)HitZ&V2Hc*15gPKv4AN F8)SH1MF0GY? Mbf$ojCLH#8Di?fe$_htVo=Ccd_6Z!XZ=)>-(^$)`jc~_Ph>X~HVCUM=gYJd7Q zfU*~Z0O@4&7C6z5fS+(h`xbcKPk^6rNBa&K`5zEJ;qm5PVAXr@Ye0tmDCE$m$Pe7~ z{lMT4?8YHRyh+>wGxdTJAU(tbK#lw;colN};eJ}(KjD9zKlwa&KLGXv|BI0R2meJ- zf53ZhAIRw2(noOW p$540nPv5;0L&V0CDdJJpY4%!R3fIjGwv!AwT_M{8Z`1 z`RR$_C&dr(r=0%Ck1xF+WpB{WaYGLec%yICpXSZrPu%*)FG2qgT>NJ|{zt&^Q^_Cb zr-1!`ofGov1ZVie{xrk?bI$%#PvSqH2tP3TUxfZY_%DM00~CAvtr2f8KTUzipHhhb zmBReVCHU_e!2AdQod?v9Qs6&RiXI+#M&BquUI_m4QvPE>;J<4(`;#XS`yc#N@(20J z)BibJKL~t_=y#bPb^g$gTKzwD=#QxWKN0qiX!k!6`hW1B^OzjeUXVxuaqbgrBDTsK zLKYP}dpqJry4==z2hjE4JEU=1(!7uqn(~otg^r}szP|FB;2O+D&_lR@jnkG fezsv#J$dZ3Y5ZSqZ*ET zQUgzJ<{lyuu6mG7?eQfHWm|IYTh~vCcW1)hBBY`LA6&r1jr35yiQw4o9Fm@Wnw2O$ zXh;n;0Ou^1%uh|z9 Hz4Qe9b*cQrcN;f#jX1mA~sPf@#2?uR11$w8jx!C0jiL5 zI^IBndl}JPbNFFEOi)`WaJJHySHn-0A49w%E8*H-2=FSMe0{5>oX)Jc?P$eaNC2AK z24h<*$(v-O6SJkOhS^Bn?b!V%zLF8^0)!xbx$$iZ3*v2(Vg9o3K)z81f@{~23x(!I zqdBtU5G8=ivTBs4U*)t^zL2VVWOT(KBU6HTY@>df!HAjQ6U@g<5a(uq!LR(mn&5qb z8F(~zZi<;rlj)#i_0?e+)Dqyu?2f4L63%}iN`#3e=x>N?sl(tG;#6qC`jWKo!&l#! zs&ERKG7y7$V`KO!0I^(pX-G%*KN-xD)IId{rg8zx>k6~=l8)==4#v)$K!1L|_eDrg zNPjX^dzrCG8VDLF^f}(bLQ20mIkn~HDYmC#aOu)QOEtXhO*L2c7Rbfrb=;i z7D5xQEghEBr>224ts|D2E S>H!Amc<0)6`e(f{mY^jtmO`U&N)YXP=GvU%3 z$z6{iL+Hf(`%=2T2uL{=ZC5UO_a`EfpJXCDKf!BUnb5G#t!TX#weE>rAt%?)&atz# z%3die*XDVVZE^E!YK2X%-EK5l1|GtvDHuNs0eB_!+&NZ)xn7?`Oij!;UTbV6qDB%q zYz+&QdV~I^T5~E+19Hy*;tPADtx1G3#bkp%jC8O#HE)OHF(BXvs}@mxL#nAsku*^; zho!I;4P}Jjn7ihY_?2<3sDtk7{pOi*Ke$Yvn@^>&HmJDT1`~K-U&{eATDE$ttw6++ zt20!HHiK)UM>upI64jERCg$Or_$Lj6YX H#?^plG|g@z *lP$YmM2hd&`ldt z DMZ$+T+2hv7 zNJj(kW)WzX>qRKF&P|vgekfzt7UCu<&zA0b2%XUfNUSRowY@(4mb3I2aEzR7JSZ%Y z%;NxAQ&xd@pwU%-q+3OV>gclR 0wAvbb&uKKC-PO0v)n$2Z8>z&<9K*`=qbe9r^h zDW8`SI^`l=5f_M)VnIt^Xmm|N_e6*`1lI|Guo;~74NCt>MeH0~-AKH>?__`N%CXq! z%E84{M;Yq|M~%FYIv6JY%uiBwg%Yn?u@>=zu8}CNeFya0$fc?aOX=U>2M3qZNa30} z2hW0{!sTK=lh{a=UBDHVq}i+18s_`lesG*eHj6cHmv1S8+b>Xbq_I}L_TBz{c&f!J z_EUfH?M4Cmm@vvyq#aBJi+k*i%!n0kMD|M)X$r_}nz@Z|G|R!=zFNFU{~Z&UtRbM0 z`DMZAJD>*hK1vWwOFty1sTJ6)wdY1fZ|Cia-_%&rRdeAaDw`3in(}2mC?@^0$JRD= zmjU;7jYYf?IxY;BW^1hF@hZS}qGK2R8 s+j7*z* of?Jv6wEV+CN0H|OC8_uRnxjSuss~9HRnHhkpF1v9 z%J2$UOgQYeNM<9EhWBYvDrywXZgMRM#2OZ8!4HqM?aA#i!JSO&OWVS)ypP=$4X4L4 zZ^LGDN54D7Rok+^GfzyjmU0HFQoH88Gpe;S*|==tlva4XBAefnH0%zNBW@QWYH@xQ zt|QNfR#aK{!`p5lyBe099*Uv?4wr2HOn&rIO_>BEYmhJpdV!W^uCe$iRAB+3F+-lD zlVw$nM2D`>cHx*Qmi4kROUO@k1hlM*wUt-7a|l&dN@J1W81iC~YBf~b828Yhzx|?$ zAc0fWp{MMvSV8=7HD&FHVV@-3BGiQcK UEEz`O<4)E%6ISut)8C7G3ZHehp>(1(_WbD%)n$fd2pKFd(OG$8fV zAR@Ekwy=+2?YT;MUiE?7h!aWy5R$lDU*L1;C3#+GBf@tW`kPwu@5Xh=Qq)?-wOB5E zBYWsAMSXRecQ8UkWN#7zFJo5|oNF^JI2X&U+e2KZVdKVj03e!AV{xIGxOSP7!F#nO z+2yrU(Oq^aK4s1T$I)G_{q;OXfJ>5iEQTGB7J+> z40dK>K{MK|4}gxZb~pn(y vqR*I@fHv}%%mp-8d*i7^AF}BF z+>G&SKJf&Nf&+Whloy(|?KBg9#d^F8+zHBY;-`m|Zr0(n*7FkOu6Ujfs`FbNt|54| zXPG`7ODt(pS_DRs(Fe8)=E5ZSVaV|uBC0y}#C4XbX)7M+{3X^6*YTWM$}fJSS^B&q z=fyJ{Q+7W2#a?lYK}g9*#RNbbyepgZzNbCMA_O6bg_vHoJsY~`t_EcLqNXT9=vr5^ ziC+hp(|G$PFoGMaWWP}k%*d)ss*&SC>^M~iu1~@K0(;1+sz8ql<7x^uTVlG8x5KQj zGiz6Y;>>wOV0B(c?o=(vU|uL1r ( z80ju3ODS^}0*xG`e_4( +P7oNera!%{6%{k7^eaY3j=VBW0aQq zikC-|)UTS+Rfs504cs$99(`b!#JiR+K;&BSoY#vE>(kRW^|VCf=$SfX)Xd<1vgLu) zF*lkqpwz-bPBG?;ffP{ap)v@8Jq^Qk>p`*!ZdT!0(CN#TSsxW2g7ssPXA^Yp&7mtj zTui`9bS`p!V&Cc*6Tjtd6E*brBun Ccl?L-<&J~ZFajor)Pg7Wbx z+2#j504pumGQn&{vOyxq+`SS>3lrrA{R$;IrClFK@Q64dTJR ;Pyye%@xOl1l={-QJ=%?{x)?eDB}Q=6>wRz|CLj)5xZeK7;nZs|bEqGUh% zh5d%T?Vf&^(?oLd2{rq*4Pe0dGCFeZKACB|PCY(=C3=viV`N}yl0XR5(v7`rJ-wyJ zS}Aj0tpkCF?l)@B`t-3(MS<2%8mMam*{-z~S-GGCaB@(YI&0^4tKth!d%*0-IOs-^ zvCjtA{)hwA7T0=1Fic>V17LZGNhNokA ?o*eg-m$BaoWahu`LL%1uiaEZxv%=ne=9Hih;t zqlzuKT}yn>o2W%6VK3Ag>w%UJ6|GKgK_m}^7K2l=FBOs|pM_&3q=2NgYe45pU97Vc za4UU@*$4M;6~Fo_ih~)w3{WBXNlQ1&GcM>JNj!+~tRoAcU#6u)&H-O)@pfx`Wj5u8 z+f-*TZS%kCZfW^Z7mX<+ql|lyQLNVLLmJeVXm>*-)rxWX0{lZo0zb7p{Sk>lUT&4} zXKG80pe?qAqnqciM8lyHaShARdQM-9Rh41)Bn&nzVBn8-)E3U*1r1C)gwu~u2|;-g zZtP;cG~VRmJ*aKD3z8-x25X<%1BMj!H%q{3=D}q>`eAFn6~QmI{cy(GT>bi+_16^A zq+va*=E#HU*Fi5++uslH{d#z)3wV_$cW@=3j}-asVK4BGa3h@fSdE3Vt!;s3zC)6> zb8)ZK&2(~kWo=2}@REkywU|zFRrH4I8gg(?rX(JMXUej|be|-#V|1o8!QJNJ>$Yp@ zZ6N1FNQ>i|_<|E~gTj2-+VQj_WpSRmmW|Tbt)(k;rjDc!2Q8-*iIK94B_F737*KaB zOwF*S&DZW^% la hkrK?;ekXOK|^^0P;igV*egkYc#T poZNn{wCck?eBZJ4iW}~l==Xv z8t8yAsW}z1Pw%#EQ5|N% BB2Y3=6;JIjd#My1-~Z^2rD_;XO`<;0f4 ze_sIm-a+JmlmqrFLQZ*S``ijgA`1@_2%h{5N$EO<1+y6@_DXs|(A;ggpSwH-&r|_} zQmVgUcOK>!DJx7^ghNe}OidA3>KViWog<3;_}OJh6G^y|-lba=>$-FQ+YS6{E4T`N zmVBXR8Zr2s^f~9Ikhh={-bLJ*jxcl~Zif6ZPY)AEAI{5O3<2n YSMDvNi2aLmC+|oSsd-ze5lMa}b1lO-Y!(!uhBF n^KD`wlIqdLolYF@l48CGS*at^bMij}jOe>i_7yZSaFshJr zsF1Q577DX80-PB!Qi4#G9M`#vI8JKqoEOz1ZBs9aU84n0LN@g@Ul!EcGU5RC$hEbd zTfwAYq;peYH+Cc;soQ*hYNh RMRz-VyS$6RNU|BSN +8N=&J~-18an(ONh4c5#cc0{%NrmwWiDhOk&1 zyU;J?PkhBPxlNT1uv>~bcq_c96c >T1J6D2VlF&Pvw~yi-$}*yag=;}h#)cGStRttUZl}2w3<{ MPVmg3oB_&14no^M3u0*yyK zn`j*Cg4^Xcjz{v8ZGiU3bfssLLJzH|e{G@&>Xj08LhTsD%@ZnRr~+i(g+{e#hvq;< zs()Use^V3QRL7*Hy_n ~?SjX1&zcO5@-a0U#p{3M*6*U~Xo2m08wFxg16kArnuNw4yi;KJ2WU-RfLRpFk z-&KZu5@RdGdSCH(5@?1jHACNd>v1j$ALhn&ApaGedlmlNeTHpA8+BjDnkW6fcF@+2 zbLRrcnfm?G$F@)3%#7NKhv=!^K4xFfpI;%IXgs$R8>CDMuS_CX!rx=n{PtPMIzX8l zUjLd~=uH?==Czt(nLzdrqJHm+ISY2)ZdKSyN@(X|a=`27AUi~CQbhv=RX_>_ut2}H zPXJ}UA4^gFWs$y5V^u K1ZRnKM&JEBTVWxdEd=rMb z+{7!X5%FJ|wyC8Q?KQGdTvIrflkihTps>-ob~;=wVaE}scCA3!3X%O1hOU%MDEx|R zMphIia2S5*P=5-iocm{-PT;CSLG``P?jj%}z;X;IW2YM&3=45Jmka3F>z|gwY2k8Q zA&rT~$DWPIbr0?EfXL9dh$$a9>Jipq20t8_ZbWeLEb~b8JFYo$ QdhDMmH-y2^&ra}nv4<#SzZj%*Ac8dBBGLC<_V&xBx1e3ZO;-%~FD3k&YC F8qi~QpN@~?+S z2>27K>aFX}8dB4~u6fr$OELU1J+|}!5O K?9TpUT n>@UYhq)j=n@99R@83 z6Bv(UhrU}Cbg(yn!HU|%ePhuA!cct4cN?IB0EpDRYBQ&c3iq(?cRz*ya^5? Cy*iBdt zBt}=OSMQDh%E@;uYF^ahlXqV NiDx&yTDLDLF9vbD+1?2{#WL?9%tIc}t#?1JuPYV4dbf?Ls>NrCk z(W|bB8Lid>b4eWr^_kPcQ&0zSLtvzk2L|;0Cp7F9a{C+GWd=jVktWMV_cK96@Ceu? z7v_t?FQXq-sg1~QwebQscIyCPZ2VL*0PWtpz>cpHMV@otjmbQHIG=||b36DJ8qcpG z?{<5K`j_FoE`1M|6sGSO55$%N9*K?56iV;85sce=Z7eYG&Pmm!7&F`(8%;h@{3!^3 z#rXc%X@q|J1m@m{E0YDO-!n&zFw7(H$EMX#{__yayDaQ;x(+H%hYh4F64~82r8O1) zIxzFUDCSzELA1N*t?z3se-DwN=D#>48T~+RkzxiK%p%XbUm+~)D+%HZ-@zRRjfTW; z-&rx6#W7P$jqf5(xEwV;1IPk~{n$39p6TEdVi4&9RNaah3-%c9+ND+#P03fR1q;0p zHg2?gx5#%9-~*V0uz=O#F6xVka@;$=_3Vgy@7!0}?l{8c@Jg_!w*uX!nC{e_NB_y0 zMy*>S)Uvoh=OP5C)J)<(w-ryo&*iWNopG98p_@+x5~{5>6e4%2AwFsdR51l)=OCvd zc0K_K*+jMf=%nrloLHJ5JJ(a?D_y284{Yl`WwwnevqT_oHg)bwq0%77F$KTbf<%vj zZ(N4cDR#`XZg-7tST1Rhr`U6$0(}$zp&glaaXfb|l6`OJ=34IDBB+15KQhN2cI({P z#{r*h6B7ANS>v4HB5BrrVwjUGu!D@BPGoOA)7GAS)xGj@ 30;MJnBmM_vw0RfoK+#KKn5#Wh}#eT z(60yB`7^$3SiXZJp$LWV$yH(J#Ty`z165NeEg9)fS%4j5U~LL_uABN5LE)!zWWHHV zxE>JO6Fq=zKrN&)$u-=6yt0-N#+QjCP|BPjhx@`_D1W1vK$bFa2jZDe;fF42SCa^D zz#vd}_(uTpD;x*tF|kTq0}nS&FPu1E`INB%YNYoUfn0<(Dy@Hz*(D0}+ZRkx>Eh9~ zL9|qG{LheKEp^n=+Ro*}LijTRmL<#=t8nwXzjPf~B9Z@G z~3;JD^#j2f%@v3|zH55B!) zdB;p!%rih(-)$vE|KbY42C^sN(L2{}6mwa`+%L@(fe=2-* p|jnCHU7hni i_!={E^P9K84GRJ#dDi!licNjKLWgE zNL_IKj9eyZhlAuR1GImBN>yABQ!!~Ck}N4P1U6!|5B(Ps>tljDbi8qUF%Dw^sy`zh z?K7P2CnVAE`(?E(nVO^?UTI9h=W1_;91thNwKu4jhB*;A+evAsKopwfmwg?2`rHu5 zc@jdJtGAx5PpFngo?P?qe2oFZWKE7HQ(L9t~3pg`L2hyW?I#qZsBkg zAQS%Kua@J&Z2`3Np7h(*6Ik(!kfi>Jb|KKgM`C2;U#6-K%oCq57D#F&n6Nbds!0;) zcfbuHPhC1ds=}BYhTq%~c84i?G&AI61r!jD92w)Vy66`up z`eIPRp@ukM`AMVQ<$_!emzXZcwo-Ep`IsQxg66iiz9Cg5;CJm!wH2T_QknEf6uJwa z#-Kd+1gRrv(`|xSXA}O!<9z48EGGQeiM} (Qzbk zZ)ypJgNfo$JDI5NoCOq`4DN>A #L?LAOca;>>!3mKwf!}%klXz1 zFLvd!?PEnbzLkpf`|qCo-~a*C&sD?L= }!1gtY FXe%+VUY zLQ?liL`Fn$lOe_q{hkm6e(L;Jo7cLF^}Ys3Z7#9*%FWDc%BLSWzH9$?d *4d-B2H&u|pW*>vOm zV*YcvKf!w~uh_p?;0^||?Y>EK9`VE!{o{FpeAO|`sV~gb*L_@gkn @$Hc zc&_yAJ8Y+-|J&;`021#hC3 ?yU7Gw1o{t{cKoX zJx#xR5+&ebG~>`-Hk{A(hu{=13zU-Yss`|^XP#>6GP9M3KFdiG#(dUB_`Prjr{10r z8S6=~lZNgO-cf!fB~*MeVV ?&qwa0cQ&w_u)T1n`Ww1^=ii^sp?Z@eTF?HD&VU#RW6}U?0FgppA=K;O{v5XBuZ+ zm1*+Gxz3}zyGU+Zfj13eKBDdBYA?(wJ~byw!~`7KE4|(71OhouTW}sE{}eIN2X_S2 z&lei(+RmPucAPKnRaqU9Ark!Ry$Y2;LJk@ *MTwG h*al-fQFAtX>h8tX0j&>uTWzT-Zemwb`aLxCOr-_bUG`iPIK z&Yy6>vN%WQ>ip%+T@#nqddWK(dNri0+#1@7^WBF|
AY^xT^~T1!>-w#*9ou1S5oE8JrD8vB0n8x?3Up zAuB)6Is5^{B9O)oypxXl-*6%x=3U!?JK!1XgR*;;br9Mqqed)WZ 9y%hz`po;A`~f%XDe(?byh>p-5*YGfIV$mV%W903tHjA)0C$$MdEZ8NU4EPZS9 zIksc?lco80Q~1psjyl;r49R<5;d?`hicgu4PcFd5c)uIdV-$*rqC~oPq5fvzu&Sq8 zEN^*rgoT(?(ajrN$ZhFB9Em_=IBbP}x2wnuvTB6szsW=KKdhT-((;F6H@7$SKDejQ zxg*(mUxAsylsyA1_?ANo1K?r!clH^bY3m;MV#l~~nf}m_@rO;u9z)^wGMWKqQeKP5 zjf${7>qT# Ab82 z24_5OcND(>=_Cnip&&%JdO>F8q_405bz0Q5a>dIB@H>Z;QP#}1Utg*d4PUr^*@VC0 z$%cwkv=#!V{mlV5j>xyhD4oeod%MHPdRKLbAG9)`Y&`JI&=&mhwHMWw*0nqI!^%5Gh WNDWT*8w)6|&0w8{AuURct z#*v`KqDS+ETD|wk>SM+^B91NI#()1!!kfWWA2f)uIIs%a*%G%e@AQQj@5Ykns8k@n znb-sV$l78z6Vi++)4FtaOKZnVlW~`v)C49}%(hWdJ! ~ ze)y$Vs#HvmjGiMMEQ}0Yp6J$*&l12n%z?=HULL>zquLJ+8iBC|tde~XJtyX_Rp7C& z+YpU=Lp)y