79 Commits

Author SHA1 Message Date
208147cdf2 启动动画和登录页面的动画 2026-09-23 16:52:18 +08:00
b3a700491a 启动动画和登录页面的动画 2026-09-23 16:50:48 +08:00
d230279f4f 启动动画和登录页面的动画 2026-09-23 16:48:48 +08:00
32397f8899 AIR版的功能添加 2026-09-20 16:31:15 +08:00
c8b597f683 蓝牙和上位机和非上位机的功能的适配 2026-09-16 15:38:41 +08:00
a1e01da241 多角色的配置适配 2026-09-14 12:35:20 +08:00
3547f80383 修改UI 2026-09-11 16:12:44 +08:00
4b88a291a5 ios蓝牙修复写配置 2026-09-11 15:37:37 +08:00
877ff932d4 修复iOS蓝牙扫描问题和权限问题
- iOS 跳过 Android 专属权限检查(bluetoothConnect/bluetoothScan)
- iOS 跳过定位服务检查(iOS 蓝牙扫描不需要定位)
- Info.plist 添加 NSBluetoothAlwaysUsageDescription 蓝牙权限
- Podfile 修复被污染内容并添加 CDN 源
- project.pbxproj 临时修改 Bundle ID 和 Team(适配当前开发环境)
2026-09-11 15:22:53 +08:00
43c0b68e1a 遗漏的文件 2026-09-11 09:07:18 +08:00
356c2a1925 蓝牙增强+个更换绑定逻辑接口 2026-09-11 09:06:20 +08:00
3ba480f63f config文件 2026-09-10 09:43:27 +08:00
85e9632326 退出登陆的重新设计到我的页面;优化高清地图的加载和显示(冷启动稍慢,再次秒开);声网通用组件遗漏文件; 2026-09-10 09:02:23 +08:00
b4bedd9737 修复路径规划任务的取消接口的入参的结构去掉deviceid,测试通过。 2026-09-10 08:58:04 +08:00
d76bcdcdf2 声网通用组件开发测试通过 2026-09-10 08:55:56 +08:00
b468e577a6 远程遥控视频的显示bug修复;恢复远程遥控中的前置推送检查和动态实时信息的展示。 2026-09-04 15:02:43 +08:00
db81816b78 修复高清地图(贴图)不支持手动滑动地理位置显示问题;修复地图显示上次设备位置的异常情况;修复打点保存少携带mode +orgid参数问题。 2026-09-04 08:43:43 +08:00
5141d6e83f 蓝牙绑定设备+场站切换支持高清图层的一对一显示+设备实时状态的部分显示 2026-09-02 16:58:45 +08:00
61bce2c1cf 绑定设备+覆盖模式+控制显示bug+在线更新 2026-09-01 08:59:20 +08:00
0fa67a127b Merge remote-tracking branch 'origin/feature/next-arch' into feature/next-arch 2026-08-26 14:25:53 +08:00
7c01229b9e 1:修复远程遥控控制状态显示不正确的问题
2:更换端口映射为测试服的8081-59003,9001-59004
2026-08-26 14:23:20 +08:00
a48778bc8a 删除 _fix_impl.py 2026-08-25 03:44:22 +00:00
e8070b5183 删除 _fix_json_data.py 2026-08-25 03:44:13 +00:00
fc3cf4de0f 删除 _fix_overflow.py 2026-08-25 03:44:01 +00:00
db323a26ce 删除 _fix_pause_stop.py 2026-08-25 03:43:51 +00:00
11509d1aa6 删除 _fix_startwork.py 2026-08-25 03:43:41 +00:00
97a2126fcf 删除 _fix_taskid2.py 2026-08-25 03:43:33 +00:00
0978f387fd 删除 _fix_taskid3.py 2026-08-25 03:43:23 +00:00
e9532ddafa 删除 _fix_taskid.py 2026-08-25 03:43:14 +00:00
43fbd0af19 删除 _patch_save.py 2026-08-25 03:43:06 +00:00
4b03da8142 删除 flutter_01.log 2026-08-25 03:42:53 +00:00
d0fcc067ca 删除 fix_dup.py 2026-08-25 03:42:42 +00:00
ca805ee957 删除 flutter_02.log 2026-08-25 03:42:33 +00:00
d119407866 删除 flutter_04.log 2026-08-25 03:42:21 +00:00
5a02522bb4 删除 flutter_03.log 2026-08-25 03:42:12 +00:00
54dac30915 删除 flutter_05.log 2026-08-25 03:42:03 +00:00
875766cc28 删除 flutter_06.log 2026-08-25 03:41:54 +00:00
e8a79168e8 删除 flutter_07.log 2026-08-25 03:41:45 +00:00
7773fe7a60 删除 flutter_08.log 2026-08-25 03:41:37 +00:00
b6e2a40aa7 删除 _fix_flow.py 2026-08-25 03:41:25 +00:00
bb2becdf7b 删除 _fix_dup.py 2026-08-25 03:41:16 +00:00
ca616faf11 迁移提交 2026-08-25 11:34:32 +08:00
83220869da 高清地图+无人机实时信息显示异常问题修复 2026-08-19 14:57:24 +08:00
ab75e8c5b0 and so on 2026-08-18 08:52:33 +08:00
87609c7dc4 优化ios 2026-08-10 16:26:15 +08:00
9dc445eeb5 优化+摄像头+扫码绑定 2026-08-10 10:00:52 +08:00
db70665ced 一次大的提交 2026-08-07 08:49:29 +08:00
bf2fc49c19 手误 2026-07-11 16:49:55 +08:00
1a4d0aedfd 优化和路径规划新适配 2026-07-11 16:46:53 +08:00
2124f3bc02 1.优化了无人的轨迹的绘制的过程中的 飞机不动路线自己动的不和谐现象,实现了飞机实时根据推送的数据绘制珍格格飞机任务的过程,同时也优化了地图加载慢 白底色的问题。
2.完善了遗留一些页面的多语言支持的。
2026-07-03 13:39:18 +08:00
0db64a96e7 1.优化补足了首页下拉刷新中对获取场站接口的加入,确保能够实时获取数据并渲染。
2.优化修改了tcp重连机制,具体操作:断开重连时候不再建立新链接 而是将旧链接重置,并重新发送数据。
3.大面积的给项目中的各个页面 手动接入了多语言的支持。同时也调整了相应带来的问题英文长度较大影响原有的样式的问题,已解决了对应的样式不和谐问题。保证了外观的统一性。
4.给项目中的主要操作加了几个核心的操作比如远程遥控和路径规划添加了双层的保护,设置里安全模式,即在此期间对服务器推送的重复登录操作后的自动退出登录做一个拦截 保证操作的安全性。
2026-07-02 16:04:30 +08:00
d5a83c1221 1.测试和修改了无人机在线实时信息的展示数据集的展示。使其能够全面展示。
2.添加了在无人机设备摄像头的为空时候,通过新增的接口获取无人机的摄像头的列表。
3.替换原先依照tcp推送消息绘制机器运动作业的轨迹过程,先用订阅mqtt特定话题推送的信息做动画过程的绘制。
2026-06-24 14:44:16 +08:00
b7ea788298 1优化改动设备栏中全部标签栏的设备列表中数据的整合,又数据合并解耦成使用已有组件各自维护,使其样式和逻辑一致。
2接入了无人机机场的实时信息和无人机的实时信息,并创建了对应的组件,并测试。
3.实现了机器人的详情页的视频显示的逻辑为当机器人在线,就在下方同步出来机器人的各个方位视频,并且支持切换。
4.实现了全部设备中对类型的区分和实现了点击进入对应的详情页。
5.修复了部分bug,优化了代码结构,提高了代码的可读性和可维护性。
6更改了机器人列表项中的信息展示为机器序列号和别名和id(优先展示别名)
7.更改了无人机场的信息展示上位 无人机及序列号下位机场序列号。
8优化了无人机机场信息只能是的为明文展示。便于观看理解。
2026-06-23 16:20:51 +08:00
06718ed957 集成双 MQTT 客户端实现设备实时数据监控(未测试)
1.集成两个独立的 MQTT 客户端,支持 WebSocket 和 TCP 协议
2.实现无人机/机场 OSD 实时监控(ws://1.95.137.212:8083/mqtt)
3.实现任务状态消息订阅(tcp://1.95.137.212:59020)
4.采用清洁架构设计,分层清晰(Domain → Data → Presentation)
2026-06-18 16:41:02 +08:00
a6143f2ba7 遗漏文件的提交 2026-06-17 13:47:24 +08:00
443ce601a0 1更新无人机的摄像头视频显示的条件为在离线情况下的自动刷新+下拉刷新的机制。
2更新优化去除原有路径规划的初始化阶段的获取设备的接口请求,改为使用新的 targetDevice。
3修改无人机机场的在线状态简化为在线和离线。去掉机场二字。
4.更新同一账号同时段登录,被强制下线的友好提示!
2026-06-17 13:46:30 +08:00
71aea48d9f 集成接口 开始执行任务的领域层和数据层和UI层的开发啊(待测试)
集成功能 暂停 取消功能 恢复功能的接口的领域层和数据层的开发 下一步待集成到页面上
优化功能,优化了接口异常和未知异常对页面渲染的影响对用户的体验的不好情况。具体通过弹窗友好提示!
优化更新了tcp指示灯点击出现机器状态中添加选中设备的编号 方便用户使用的明白明了!。
优化更新关闭了tcp重连操作内链条中的获取用户设备和切换设备操作项。
调整了获取无人机状态信息的更新频率 为14秒一次
2026-06-16 17:34:00 +08:00
52fe17c0d5 构建初步的设备运行悬浮状态栏,已实现初步的的悬浮和开关功能。
优化了登录链条适配多种用户的登录操作
2026-06-12 15:00:44 +08:00
b08e1e0b57 补充 2026-06-11 17:31:10 +08:00
8eb878dd81 无人机视频对接+支持无人机未在线时候5S执行一下查询操作。便于视频正常展示。
无人机视频的摄像头切换功能和参数切换功能(广角和红外 和等)
无人机航线任务创建页面的开发和接口对接
无人机航线任务搜索接口(支持时间过滤)
无人机执行航线任务功能开发和接口对接
无人机暂停执行任务功能开发和接口对接
无人机返航任务和取消返航功能开发和接口对接
无人机机场详情页中的展示信息的更正。
2026-06-11 17:30:15 +08:00
058a5d9588 对接路径规划的列表(根据场站的下的)
优化无人机机场的监控视频的显示视频流畅度提升80%
添加首页tcp指示灯的机器状态信息展示功能。(暂不支持在此页面上设备的切换显示)
2026-06-09 14:39:55 +08:00
ff4a3836c7 优化了tcp实时更新对UI线程的压力和对用弹窗的重复弹窗的影响。
更换了路径规划的的经纬度的字段名和取操作的名字更换。
2026-06-08 13:35:56 +08:00
354f7d6290 优化了tcp实时更新对UI线程的压力和对用弹窗的重复弹窗的影响。
更换了路径规划的的经纬度的字段名和取操作的名字更换。
2026-06-08 13:35:36 +08:00
7c9734d918 对接远程遥控 2026-06-05 19:06:21 +08:00
d8879dad62 告警中心的二级详情页面+信息中心的页面+对接机器人接口 2026-06-02 08:40:50 +08:00
9eab402551 遗漏 2026-06-02 08:31:13 +08:00
b6480778ed 遗漏 2026-06-01 17:33:40 +08:00
93e69a5e4b 机器人的列表接口 2026-06-01 17:33:16 +08:00
9154174614 遗漏提交 2026-05-29 14:14:19 +08:00
335c468aac 修复版本管理整包更新无法弹出的问题
完成集成新的登录接口的更换使用
完成集成用户的场站列表接口
完成开发设计选择场站项为全局属性
完成我的页面的个人信息的集成和详情页面的开发
完成设备页面的中无人机机场的列表接口对接入和个项页面的布局的优化
完成设备页面的中无人机机场的详情接口的对接和使用和页面的更新布局
2026-05-29 14:13:34 +08:00
938aac2db9 添加修复大包更新安装覆盖的-待测试
添加修复了更新接口的令狐适配
添加了新的服务端地址-
2026-05-27 16:05:21 +08:00
afef5c4ef1 所有一级页面和不分二级页面 2026-05-25 12:58:29 +08:00
b9f747de05 v2版本登录 2026-05-19 08:46:35 +08:00
07b6b19b34 v2版本登录修改 2026-05-19 08:34:35 +08:00
f1a279cf57 v2版本的首页界面开发 2026-05-18 17:18:58 +08:00
03f797e717 底部tab的灵活可控 2026-05-14 15:08:19 +08:00
a4ac503abd -提交-bug已处理 2026-05-14 14:18:37 +08:00
fb529b2485 -暂提交-bug未处理 2026-05-13 10:34:07 +08:00
4b3ad72627 解耦登录中TCP连接的必要,使其能够快速响应登录 2026-05-06 16:28:43 +08:00
447 changed files with 74892 additions and 2522 deletions

21
.gitignore vendored
View File

@@ -20,7 +20,9 @@ build/
gen/
local.properties
.gradle/
.kotlin/
captures/
hs_err_pid*.log
.idea/modules.xml
.idea/workspace.xml
@@ -79,6 +81,25 @@ Debug/
# ----------------------------------------------------------------------------
lib/generated_plugin_registrant.dart
# ----------------------------------------------------------------------------
# Node.js 依赖目录(前端/工具链产物,不需要提交)
# ----------------------------------------------------------------------------
node_modules/
**/node_modules/
# ----------------------------------------------------------------------------
# 日志与崩溃转储(本地调试产物,不需要提交)
# ----------------------------------------------------------------------------
flutter_*.log
test_run.log
*.log
.sentry-native/
# ----------------------------------------------------------------------------
# Office 临时锁文件(Word/Excel 打开时生成,不需要提交)
# ----------------------------------------------------------------------------
~$*
# ----------------------------------------------------------------------------
# 编辑器与系统文件
# ----------------------------------------------------------------------------

View File

@@ -0,0 +1,45 @@
kotlin version: 2.2.20
error message: Failed connecting to the daemon in 4 retries
error message: Daemon compilation failed: Could not connect to Kotlin compile daemon
java.lang.RuntimeException: Could not connect to Kotlin compile daemon
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemon(GradleKotlinCompilerWork.kt:214)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.compileWithDaemonOrFallbackImpl(GradleKotlinCompilerWork.kt:159)
at org.jetbrains.kotlin.compilerRunner.GradleKotlinCompilerWork.run(GradleKotlinCompilerWork.kt:111)
at org.jetbrains.kotlin.compilerRunner.GradleCompilerRunnerWithWorkers$GradleKotlinCompilerWorkAction.execute(GradleCompilerRunnerWithWorkers.kt:74)
at org.gradle.workers.internal.DefaultWorkerServer.execute(DefaultWorkerServer.java:63)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:66)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1$1.create(NoIsolationWorkerFactory.java:62)
at org.gradle.internal.classloader.ClassLoaderUtils.executeInClassloader(ClassLoaderUtils.java:100)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.lambda$execute$0(NoIsolationWorkerFactory.java:62)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:44)
at org.gradle.workers.internal.AbstractWorker$1.call(AbstractWorker.java:41)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:210)
at org.gradle.internal.operations.DefaultBuildOperationRunner$CallableBuildOperationWorker.execute(DefaultBuildOperationRunner.java:205)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:67)
at org.gradle.internal.operations.DefaultBuildOperationRunner$2.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:167)
at org.gradle.internal.operations.DefaultBuildOperationRunner.execute(DefaultBuildOperationRunner.java:60)
at org.gradle.internal.operations.DefaultBuildOperationRunner.call(DefaultBuildOperationRunner.java:54)
at org.gradle.workers.internal.AbstractWorker.executeWrappedInBuildOperation(AbstractWorker.java:41)
at org.gradle.workers.internal.NoIsolationWorkerFactory$1.execute(NoIsolationWorkerFactory.java:59)
at org.gradle.workers.internal.DefaultWorkerExecutor.lambda$submitWork$0(DefaultWorkerExecutor.java:174)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runExecution(DefaultConditionalExecutionQueue.java:194)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.access$700(DefaultConditionalExecutionQueue.java:127)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner$1.run(DefaultConditionalExecutionQueue.java:169)
at org.gradle.internal.Factories$1.create(Factories.java:31)
at org.gradle.internal.work.DefaultWorkerLeaseService.withLocks(DefaultWorkerLeaseService.java:263)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:127)
at org.gradle.internal.work.DefaultWorkerLeaseService.runAsWorkerThread(DefaultWorkerLeaseService.java:132)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.runBatch(DefaultConditionalExecutionQueue.java:164)
at org.gradle.internal.work.DefaultConditionalExecutionQueue$ExecutionRunner.run(DefaultConditionalExecutionQueue.java:133)
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Unknown Source)
at java.base/java.util.concurrent.FutureTask.run(Unknown Source)
at org.gradle.internal.concurrent.ExecutorPolicy$CatchAndRecordFailures.onExecute(ExecutorPolicy.java:64)
at org.gradle.internal.concurrent.AbstractManagedExecutor$1.run(AbstractManagedExecutor.java:48)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source)
at java.base/java.lang.Thread.run(Unknown Source)

View File

@@ -1,3 +1,6 @@
import java.io.FileInputStream
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
@@ -5,6 +8,13 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
// 🔥 加载签名配置
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "com.maibu.maibu_satabot_v2"
compileSdk = flutter.compileSdkVersion
@@ -30,11 +40,20 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}

View File

@@ -6,3 +6,68 @@
# 如果你用 dio + json_serializable / gson
-keepattributes Signature
-keepattributes *Annotation*
# ===== 火山引擎 RTC SDK 保留规则 =====
# 保留所有火山引擎相关类
-dontwarn com.ss.bytertc.**
-keep class com.ss.bytertc.** { *; }
# 荣耀音频相关类
-dontwarn com.hihonor.android.magicx.media.audio.interfaces.**
-keep class com.hihonor.android.magicx.media.audio.interfaces.** { *; }
# ===== 声网 Agora RTC SDK 保留规则 =====
# 保留所有声网相关类
-dontwarn io.agora.**
-keep class io.agora.** { *; }
# 保留native方法
-keepclasseswithmembernames class * {
native <methods>;
}
# ===== FastJSON 保留规则 =====
# AWT 相关
-dontwarn java.awt.**
-keep class java.awt.Color { *; }
-keep class java.awt.Font { *; }
-keep class java.awt.Point { *; }
-keep class java.awt.Rectangle { *; }
# Javax Money
-dontwarn javax.money.**
-keep class javax.money.CurrencyUnit { *; }
-dontwarn org.javamoney.moneta.**
-keep class org.javamoney.moneta.Money { *; }
# JAX-RS
-dontwarn javax.ws.rs.**
-keep class javax.ws.rs.Consumes { *; }
-keep class javax.ws.rs.Produces { *; }
-keep class javax.ws.rs.core.Response { *; }
-keep class javax.ws.rs.core.StreamingOutput { *; }
-keep class javax.ws.rs.ext.MessageBodyReader { *; }
-keep class javax.ws.rs.ext.MessageBodyWriter { *; }
-keep class javax.ws.rs.ext.Provider { *; }
# Jersey
-dontwarn org.glassfish.jersey.internal.spi.**
-keep class org.glassfish.jersey.internal.spi.AutoDiscoverable { *; }
# Joda Time
-dontwarn org.joda.time.**
-keep class org.joda.time.DateTime { *; }
-keep class org.joda.time.DateTimeZone { *; }
-keep class org.joda.time.Duration { *; }
-keep class org.joda.time.Instant { *; }
-keep class org.joda.time.LocalDate { *; }
-keep class org.joda.time.LocalDateTime { *; }
-keep class org.joda.time.LocalTime { *; }
-keep class org.joda.time.Period { *; }
-keep class org.joda.time.ReadablePartial { *; }
-keep class org.joda.time.format.DateTimeFormat { *; }
-keep class org.joda.time.format.DateTimeFormatter { *; }
# Springfox
-dontwarn springfox.documentation.spring.web.json.**
-keep class springfox.documentation.spring.web.json.Json { *; }

View File

@@ -1,85 +1,42 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <!-- 关键! -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 如果还需要扫描闪光灯控制,可选添加 -->
<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.maibu.maibu_satabot_v2">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
<application
android:label="飒沓机器人"
android:label="maibu_satabot_v2"
android:name="${applicationName}"
android:usesCleartextTraffic="true"
android:largeHeap="true"
android:hardwareAccelerated="true"
android:enableOnBackInvokedCallback="true"
android:icon="@mipmap/launcher_icon">
<meta-data android:name="android.max_aspect" android:value="2.1" />
android:icon="@mipmap/launcher_icon"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- 高德 Key -->
<meta-data
android:name="com.amap.api.v2.apikey"
android:value="bbb1f0f20eed6bf679eddf2625630aba" />
android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>

View File

@@ -1,7 +1,16 @@
allprojects {
repositories {
// 使用阿里云镜像加速(优先)
maven { url = uri("https://maven.aliyun.com/repository/google") }
maven { url = uri("https://maven.aliyun.com/repository/central") }
maven { url = uri("https://maven.aliyun.com/repository/public") }
google()
mavenCentral()
// 火山引擎 RTC 仓库
maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") }
// BytePlus 公共仓库(包含火山引擎依赖)
maven { url = uri("https://artifact.byteplus.com/repository/public/") }
}
}

View File

@@ -1,2 +1,21 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.3,TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true -Djavax.net.ssl.trustStoreType=JKS
# 限制 Kotlin 编译守护进程内存,避免与 Gradle 守护进程叠加挤占系统内存
kotlin.daemon.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=512m
android.useAndroidX=true
# 使用阿里云镜像加速
android.enableJetifier=true
# 禁用代理(覆盖全局配置)
systemProp.http.proxyHost=
systemProp.http.proxyPort=
systemProp.https.proxyHost=
systemProp.https.proxyPort=
# 放行SSL证书与协议,解决TLS握手异常
systemProp.http.ssl.insecure=true
systemProp.http.ssl.allowall=true
systemProp.http.ssl.ignore.validity.dates=true
# Android SDK 镜像配置(使用清华大学镜像)
systemProp.android.sdkmanager.channel=stable

BIN
assets/images/chucao.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

BIN
assets/images/dianzhan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

BIN
assets/images/huawei.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

BIN
assets/images/qinxi.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
assets/images/xunjian.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@@ -19,10 +19,11 @@
"password": "Password",
"login_button": "Login",
"logout": "Logout",
"confirm_logout": "Are you sure to logout?",
"username_hint": "Enter username",
"password_hint": "Enter password",
"login_success": "Login successful",
"login_failed": "Login failed"
"login_success": "Login Success",
"login_failed": "Login Failed"
},
"home": {
@@ -98,7 +99,9 @@
"value": "Value",
"initialized": "Initialized",
"no_data": "No Data",
"voltage": "Voltage"
"voltage": "Voltage",
"tcp_reconnected": "TCP connection restored",
"tcp_reconnect_failed": "TCP reconnection failed, please check network"
},
"machine_details": {
@@ -285,7 +288,7 @@
"location": "Location",
"refresh": "Refresh",
"select_work_mode": "Select Work Mode",
"bow_mode": "Bow Mode",
"bow_mode": "Coverage Mode",
"bow_mode_subtitle": "Standard full coverage path planning",
"custom_mode": "Custom Mode",
"custom_mode_subtitle": "Manually set work area",
@@ -308,6 +311,185 @@
"refresh_failed": "Refresh failed"
},
"home_v2": {
"select_site": "Select Site",
"no_sites": "No available sites",
"site_code": "Code: %s",
"weather": "Cloudy 28°C",
"emergency_alarm": "Emergency Alarm",
"real_time_power": "Real-time Power",
"today_generation": "Today's Generation",
"equivalent_hours": "Equivalent Hours",
"alarm_count": "Alarm Count",
"pending_work_order": "Pending Orders",
"quick_entry": "Quick Entry",
"device_report": "Device Report",
"scan_qr": "Scan QR",
"work_order": "Work Order",
"alarm_center": "Alarm Center",
"power_plant_map": "Plant Map",
"device_list": "Device List",
"power_trend": "Power Trend",
"day": "Day",
"month": "Month",
"year": "Year",
"plant_overview": "Plant Overview",
"installed_capacity": "Installed Capacity",
"commission_date": "Commission Date",
"work_order_stats": "Work Order Stats",
"work_order_pending": "Pending",
"work_order_processing": "Processing",
"work_order_pending_acceptance": "Pending Acceptance",
"power_trend": "Power Trend (Today)",
"day": "Day",
"month": "Month"
},
"device_list_v2": {
"title": "Device Status",
"search_hint": "Search device name/ID",
"search": "Search",
"all": "All",
"robot": "Robot",
"drone_station": "Drone Station",
"inverter": "Inverter",
"combiner_box": "Combiner Box",
"module": "Module",
"monitor": "Monitor",
"total_devices": "All Devices",
"online": "Online",
"alarm": "Alarm",
"offline": "Offline",
"no_data": "No data",
"select_site_first": "Please select a site first",
"no_drone_stations": "No drone stations at this site",
"view_details_from_tab": "Please view details from the 'Drone Station' tab",
"device_type_not_supported": "Device type not supported: %s",
"battery_level": "Battery",
"current_task": "Current Task",
"task": "Task",
"power": "Power",
"today_energy": "Today Energy",
"current": "Current",
"temperature": "Temperature",
"radiation": "Radiation",
"data": "Data",
"inspection_robot": "Inspection Robot",
"cleaning_robot": "Cleaning Robot",
"weeding_robot": "Weeding Robot"
},
"alarm_center": {
"title": "Alarm Center",
"retry": "Retry",
"filter_title": "Filter",
"alarm_level": "Alarm Level",
"time_range": "Time Range",
"area": "Area",
"device": "Device",
"high_risk": "High Risk",
"medium_risk": "Medium Risk",
"low_risk": "Low Risk",
"hint": "Hint",
"today": "Today",
"this_week": "This Week",
"this_month": "This Month",
"all": "All",
"apply_filter": "Apply Filter",
"ai_diagnosis": "AI Diagnosis",
"view_details": "View Details",
"unprocessed": "Unprocessed",
"processing": "Processing",
"confirmed": "Confirmed",
"recovered": "Recovered",
"unconfirmed": "Unconfirmed",
"today_new": "Today's New"
},
"work_order_v2": {
"title": "Work Orders",
"retry": "Retry",
"add": "Add",
"pending": "Pending",
"executing": "Executing",
"completed": "Completed",
"all": "All",
"today_completed": "Today Completed",
"order_no": "Order No.",
"priority": "Priority",
"create_time": "Create Time",
"complete_time": "Complete Time",
"location": "Location",
"executor": "Executor",
"high": "High",
"medium": "Medium",
"low": "Low",
"progress": "Progress"
},
"my_v2": {
"title": "Profile",
"clear_cache": "Clear Cache",
"confirm_clear_cache": "Are you sure to clear all cache data?",
"cancel": "Cancel",
"confirm": "Confirm",
"night_mode": "Night Mode",
"system_settings": "System Settings",
"personal_info": "Personal Info",
"offline_cache": "Offline Cache",
"message": "My Messages",
"message_settings": "Message Settings",
"favorite": "My Favorites",
"work_order": "My Orders",
"help_feedback": "Help & Feedback",
"about_us": "About Us",
"suspend_bar": "Float Bar",
"suspend_bar_desc": "Show device status float bar at the bottom",
"tab_settings": "Tab Settings"
},
"report": {
"title": "Field Report",
"submit": "Submit",
"select_location": "Select Location",
"report_type": "Report Type",
"device_fault": "Device Fault",
"patrol_record": "Patrol Record",
"hidden_danger": "Hidden Danger",
"defect_report": "Defect Report",
"select_device": "Select Device",
"please_select_device": "Please select device",
"problem_description": "Problem Description",
"description_hint": "Please describe the problem in detail...",
"upload_media": "Upload Image/Video",
"take_photo": "Take Photo",
"record_video": "Record Video",
"problem_level": "Problem Level",
"normal": "Normal",
"important": "Important",
"urgent": "Urgent",
"cancel": "Cancel",
"select": "Select",
"submit_success": "Submitted",
"submit_success_message": "Your report has been submitted successfully",
"submit_failed": "Submission Failed",
"confirm": "OK",
"main_building_a": "Main Building A",
"main_building_b": "Main Building B",
"boiler_room": "Boiler Room",
"turbine_room": "Turbine Room",
"control_room": "Control Room",
"power_distribution_room": "Power Distribution Room",
"boiler_1": "Boiler #1",
"boiler_2": "Boiler #2",
"turbine_1": "Turbine #1",
"generator_1": "Generator #1",
"transformer_1": "Transformer #1",
"water_pump_1": "Water Pump #1",
"select_image": "Select Image",
"select_video": "Select Video"
},
"product": {
"intro": "Product Introduction",
"highlights": "Product Highlights",

View File

@@ -19,6 +19,7 @@
"password": "密码",
"login_button": "登录",
"logout": "退出登录",
"confirm_logout": "确定要退出登录吗?",
"username_hint": "请输入用户名",
"password_hint": "请输入密码",
"login_success": "登录成功",
@@ -98,7 +99,9 @@
"value": "数值",
"initialized": "已初始化",
"no_data": "暂无数据",
"voltage": "电压"
"voltage": "电压",
"tcp_reconnected": "TCP连接已恢复",
"tcp_reconnect_failed": "TCP重连失败,请检查网络"
},
"machine_details": {
@@ -299,7 +302,7 @@
"location": "定位",
"refresh": "刷新",
"select_work_mode": "选择作业模式",
"bow_mode": "弓字模式",
"bow_mode": "覆盖模式",
"bow_mode_subtitle": "标准全覆盖路径规划",
"custom_mode": "自定义模式",
"custom_mode_subtitle": "手动设定作业区域",
@@ -321,6 +324,186 @@
"clear_cache_success": "已清除所有缓存,恢复初始状态",
"refresh_failed": "刷新失败"
},
"home_v2": {
"select_site": "选择电站",
"no_sites": "暂无可用电站",
"site_code": "编号: %s",
"weather": "多云 28°C",
"emergency_alarm": "紧急告警",
"real_time_power": "实时发电功率",
"today_generation": "今日发电量",
"equivalent_hours": "等效小时数",
"alarm_count": "告警数",
"pending_work_order": "待办工单",
"quick_entry": "快捷入口",
"device_report": "设备上报",
"scan_qr": "扫一扫",
"work_order": "工单任务",
"alarm_center": "告警中心",
"power_plant_map": "电厂地图",
"device_list": "设备列表",
"power_trend": "发电趋势",
"day": "日",
"month": "月",
"year": "年",
"plant_overview": "电站概览",
"installed_capacity": "装机容量",
"commission_date": "投运日期",
"work_order_stats": "工单统计",
"work_order_pending": "待处理",
"work_order_processing": "处理中",
"work_order_pending_acceptance": "待验收",
"power_trend": "发电趋势(今日)",
"day": "日",
"month": "月"
},
"device_list_v2": {
"title": "设备状态",
"search_hint": "搜索设备名称/编号",
"search": "搜索",
"all": "全部",
"robot": "机器人",
"drone_station": "无人机机场",
"inverter": "逆变器",
"combiner_box": "汇流箱",
"module": "组件",
"monitor": "监控",
"total_devices": "全部设备",
"online": "在线",
"alarm": "告警",
"offline": "离线",
"no_data": "暂无数据",
"select_site_first": "请先选择场站",
"no_drone_stations": "该场站暂无无人机机场",
"view_details_from_tab": "请从\"无人机机场\"标签页查看详情",
"device_type_not_supported": "该设备类型暂不支持查看详情: %s",
"battery_level": "电量",
"current_task": "当前任务",
"task": "任务",
"power": "功率",
"today_energy": "今日发电",
"current": "电流",
"temperature": "温度",
"radiation": "辐照度",
"data": "数据",
"inspection_robot": "巡检机器人",
"cleaning_robot": "清洗机器人",
"weeding_robot": "除草机器人"
},
"alarm_center": {
"title": "告警中心",
"retry": "重试",
"filter_title": "筛选条件",
"alarm_level": "告警等级",
"time_range": "时间范围",
"area": "区域",
"device": "设备",
"high_risk": "高危",
"medium_risk": "中危",
"low_risk": "低危",
"hint": "提示",
"today": "今天",
"this_week": "本周",
"this_month": "本月",
"all": "全部",
"apply_filter": "应用筛选",
"ai_diagnosis": "AI 诊断建议",
"view_details": "查看详情",
"unprocessed": "未处理",
"processing": "处理中",
"confirmed": "已确认",
"recovered": "已恢复",
"unconfirmed": "未确认",
"today_new": "今日新增"
},
"work_order_v2": {
"title": "工单任务",
"retry": "重试",
"add": "添加",
"pending": "待处理",
"executing": "执行中",
"completed": "已完成",
"all": "全部",
"today_completed": "今日完成",
"order_no": "工单号",
"priority": "优先级",
"create_time": "创建时间",
"complete_time": "完成时间",
"location": "地点",
"executor": "执行人",
"high": "高",
"medium": "中",
"low": "低",
"progress": "进度",
"no_data": "暂无数据"
},
"my_v2": {
"title": "我的",
"clear_cache": "清除缓存",
"confirm_clear_cache": "确定要清除所有缓存数据吗?",
"cancel": "取消",
"confirm": "确定",
"night_mode": "夜间模式",
"system_settings": "系统设置",
"personal_info": "个人信息",
"offline_cache": "离线缓存",
"message": "我的消息",
"message_settings": "消息设置",
"favorite": "我的收藏",
"work_order": "我的工单",
"help_feedback": "帮助与反馈",
"about_us": "关于我们",
"suspend_bar": "悬浮条",
"suspend_bar_desc": "在页面底部显示设备状态悬浮条",
"tab_settings": "Tab 设置"
},
"report": {
"title": "现场上报",
"submit": "提交",
"select_location": "选择位置",
"report_type": "上报类型",
"device_fault": "设备故障",
"patrol_record": "巡检记录",
"hidden_danger": "隐患上报",
"defect_report": "缺陷上报",
"select_device": "选择设备",
"please_select_device": "请选择设备",
"problem_description": "问题描述",
"description_hint": "请详细描述问题现象...",
"upload_media": "上传图片/视频",
"take_photo": "拍照",
"record_video": "录像",
"problem_level": "问题等级",
"normal": "一般",
"important": "重要",
"urgent": "紧急",
"cancel": "取消",
"select": "选择",
"submit_success": "提交成功",
"submit_success_message": "您的上报已成功提交",
"submit_failed": "提交失败",
"confirm": "确定",
"main_building_a": "主厂房A区",
"main_building_b": "主厂房B区",
"boiler_room": "锅炉房",
"turbine_room": "汽机房",
"control_room": "控制室",
"power_distribution_room": "配电室",
"boiler_1": "锅炉1号",
"boiler_2": "锅炉2号",
"turbine_1": "汽轮机1号",
"generator_1": "发电机1号",
"transformer_1": "变压器1号",
"water_pump_1": "水泵1号",
"select_image": "选择图片",
"select_video": "选择视频"
},
"product": {
"intro": "产品介绍",
"highlights": "产品亮点",

52
build_patch.bat Normal file
View File

@@ -0,0 +1,52 @@
@echo off
chcp 65001 >nul
setlocal enabledelayedexpansion
echo ============================================
echo Re Build Patch
echo ============================================
echo.
set PATCH_VERSION=1.0.2
set TARGET_VERSION_CODE=1
echo [1/3] Building Release APK ...
echo.
call flutter build apk --release
if %errorlevel% neq 0 (
echo.
echo APK build failed!
pause
exit /b 1
)
echo.
echo APK build done
echo.
echo [2/3] Generating patch ...
echo.
call dart run flutter_patcher:pack ^
--apk build/app/outputs/flutter-apk/app-release.apk ^
--version %PATCH_VERSION% ^
--target-version-code %TARGET_VERSION_CODE%
if %errorlevel% neq 0 (
echo.
echo Patch generation failed!
pause
exit /b 1
)
echo.
echo Patch generation done
echo.
echo [3/3] Output:
echo.
echo APK: build\app\outputs\flutter-apk\app-release.apk
echo Patch: dist\libapp.so
echo Manifest: dist\manifest.json
echo.
echo ============================================
echo Next: Upload dist\libapp.so to server
echo ============================================
echo.
pause

View File

@@ -2,4 +2,5 @@ description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:
- get_it: true
- provider: true
- provider: true
- shared_preferences: true

3
dist/download/.gitkeep vendored Normal file
View File

@@ -0,0 +1,3 @@
# 完整安装包目录
将 app-release.apk 放在此目录下用于测试

6
dist/manifest.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"version": "1.0.10",
"md5": "416e3f0e2a701bb8b4a0856a29dba597",
"targetVersionCode": 1,
"abi": "arm64-v8a"
}

3
dist/patch/.gitkeep vendored Normal file
View File

@@ -0,0 +1,3 @@
# 差量更新包目录
将生成的 .patch 文件放在此目录下

9
dist/version/check.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"hasUpdate": true,
"patch": {
"version": "1.0.2",
"patchUrl": "http://127.0.0.1:8080/libapp.so",
"md5": "",
"targetVersionCode": 1
}
}

View File

@@ -0,0 +1,213 @@
# DeviceTaskCubit 使用指南
## 功能概述
DeviceTaskCubit 提供了设备任务管理的完整功能:
- 获取任务池并过滤出当前设备的任务
- 取消任务
- 暂停任务
- 恢复任务
- 全局管理 taskId
## 在页面中集成
### 1. 在 BlocProvider 中注册
```dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import '../presentation/bloc/device_task_cubit.dart';
class YourPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => GetIt.I<DeviceTaskCubit>(),
child: YourPageContent(),
);
}
}
```
### 2. 获取任务池(选择航线时调用)
```dart
// 当用户选择航线后,获取该设备的任务
final deviceId = targetDevice?.deviceId ?? '';
if (deviceId.isNotEmpty) {
context.read<DeviceTaskCubit>().fetchAndFilterTask(deviceId);
}
```
### 3. 监听状态变化(自动显示错误弹窗)
```dart
BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
listener: (context, state) {
// 🔥 自动显示错误弹窗(2秒后自动消失)
if (state.shouldShowError && state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
// 操作成功提示
if (state.operationType == DeviceTaskOperationType.cancel &&
!state.isLoading) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('取消任务成功')),
);
}
},
builder: (context, state) {
// 显示加载状态
if (state.isLoading) {
return const CircularProgressIndicator();
}
// 显示当前任务ID
final taskId = state.currentTaskId;
return Text('当前任务ID: $taskId');
},
)
```
**关键点:**
- `shouldShowError` 为 true 时,表示发生了错误,需要显示弹窗
- 弹窗会自动在 2 秒后消失
- **不会影响页面展示**,页面继续正常运行
### 4. 执行任务操作
#### 取消任务
```dart
final deviceId = targetDevice?.deviceId ?? '';
context.read<DeviceTaskCubit>().cancelTask(deviceId);
```
#### 暂停任务
```dart
final deviceId = targetDevice?.deviceId ?? '';
context.read<DeviceTaskCubit>().pauseTask(deviceId);
```
#### 恢复任务
```dart
final deviceId = targetDevice?.deviceId ?? '';
context.read<DeviceTaskCubit>().recoveryTask(deviceId);
```
### 5. 手动更新 taskId(选择新航线时)
```dart
// 如果需要在选择航线时手动设置 taskId
context.read<DeviceTaskCubit>().updateCurrentTaskId(newTaskId);
```
### 6. 清除当前任务
```dart
// 退出页面或切换设备时清除
context.read<DeviceTaskCubit>().clearCurrentTask();
```
## 注意事项
1. **自动获取用户信息和场站ID**
- Cubit 内部会自动从 AppUserCubit 和 SiteCubit 获取所需参数
- 无需手动传递 userId、orgId、siteId
2. **统一错误处理**
- 所有错误都会通过 ErrorHandler 转换为友好提示
- 不会显示原始错误信息(如 HTTP 404、连接超时等)
3. **taskId 全局管理**
- fetchAndFilterTask 会自动过滤并保存当前设备的 taskId
- 所有操作接口都会使用这个全局保存的 taskId
4. **状态监听**
- operationType 可以区分当前正在进行的操作类型
- isLoading 表示是否正在执行网络请求
## 完整示例
```dart
class RoutePlanningPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => GetIt.I<DeviceTaskCubit>(),
child: Scaffold(
appBar: AppBar(title: const Text('路径规划')),
body: BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
listener: (context, state) {
if (state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage!)),
);
}
},
builder: (context, state) {
return Column(
children: [
// 显示当前任务ID
Text('当前任务ID: ${state.currentTaskId ?? "无"}'),
// 操作按钮
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
ElevatedButton(
onPressed: () {
final deviceId = 'YOUR_DEVICE_ID';
context.read<DeviceTaskCubit>().pauseTask(deviceId);
},
child: const Text('暂停'),
),
ElevatedButton(
onPressed: () {
final deviceId = 'YOUR_DEVICE_ID';
context.read<DeviceTaskCubit>().recoveryTask(deviceId);
},
child: const Text('恢复'),
),
ElevatedButton(
onPressed: () {
final deviceId = 'YOUR_DEVICE_ID';
context.read<DeviceTaskCubit>().cancelTask(deviceId);
},
child: const Text('取消'),
),
],
),
// 加载指示器
if (state.isLoading)
const CircularProgressIndicator(),
],
);
},
),
),
);
}
}
```
## 错误处理优化
项目中已实现统一的错误处理机制:
- 所有接口错误都会转换为友好的中文提示
- 不会显示原始的错误信息(如 "HTTP 404"、"Connection timeout" 等)
- 提示会自动消失(2秒后)
- 页面不会崩溃
示例错误提示:
- "网络连接超时,请检查网络设置"
- "登录已过期,请重新登录"
- "操作失败,请稍后重试"
- "未知错误,请稍后重试"

View File

@@ -0,0 +1,296 @@
# 无人机机场 OSD 实时数据展示功能
## 📋 功能概述
在无人机机场详情页面添加了 **OSD 实时数据卡片**,通过 MQTT 订阅 `thing/product/${gatewaySn}/osd` topic,实时展示机场的遥测数据。
## 🎯 核心特性
### 1. 滑动窗口设计
- **左右滑动切换**:用户可以左右滑动查看不同的 OSD 数据字段
- **底部指示器**:显示当前页码和总页数
- **滑动提示**:左右箭头提示用户可以滑动
### 2. 智能状态管理
- **在线时**:显示实时 OSD 数据(电量、温度、风速等)
- **离线时**:显示"机场离线,暂无实时数据"提示
- **加载中**:显示加载动画
### 3. 数据字段展示
目前展示的 OSD 字段包括:
| 字段 | 图标 | 颜色规则 | 示例值 |
|------|------|---------|--------|
| 电量 | 🔋 battery_full | >50% 绿色, 20-50% 橙色, <20% 红色 | 85% |
| 温度 | 🌡️ thermostat | 蓝色 | 25°C |
| 风速 | 💨 air | 绿色 | 3.5 m/s |
| 降雨量 | 💧 water_drop | 蓝色 | 小雨 / 12 mm |
| 网络状态 | 📶 network_check | 灰色 | 等级 4 |
| GPS | 🛰️ gps_fixed | 紫色 | 12 颗 |
## 📁 文件结构
```
lib/features/v2/device_list/
├── presentation/
│ ├── pages/
│ │ └── drone_station_detail_page.dart # 详情页(集成 OSD 卡片)
│ └── widgets/
│ └── drone_station_osd_card.dart # OSD 实时数据卡片组件
```
## 🔧 技术实现
### 1. MQTT 数据订阅
```dart
// 订阅机场 OSD topic
final stationTopic = 'thing/product/$gatewaySn/osd';
// 监听数据流
_subscription = _dataSource.stationOsdStream.listen((osd) {
setState(() {
_currentOsd = osd;
_parseOsdFields(osd); // 解析并更新 UI
});
});
```
### 2. 数据解析
```dart
void _parseOsdFields(DroneOsdEntity osd) {
final data = osd.rawData;
_osdFields = [
{
'icon': Icons.battery_full_rounded,
'label': '电量',
'value': data['battery'] != null ? '${data['battery']}%' : '未知',
'color': _getBatteryColor(data['battery']),
},
// ... 其他字段
];
}
```
### 3. 滑动卡片实现
使用 `PageView.builder` 实现左右滑动:
```dart
PageView.builder(
itemCount: _osdFields.length,
onPageChanged: (index) {
setState(() {
_currentIndex = index;
});
},
itemBuilder: (context, index) {
final field = _osdFields[index];
return _buildOsdPage(field, index);
},
)
```
## 🎨 UI 设计
### 卡片布局
```
┌─────────────────────────────┐
│ [图标] 电量 ◀ ▶ │
│ │
│ 85% │
│ │
│ 1 / 6 │
│ │
│ ● ○ ○ ○ ○ ○ │
└─────────────────────────────┘
```
### 状态指示器
- **当前页**:长条蓝色圆点(16px)
- **其他页**:短条灰色圆点(6px)
## 📊 数据映射规则
### 降雨量格式化
```dart
String _formatRainfall(dynamic rainfall) {
if (rainfall is String) {
switch (rainfall.toLowerCase()) {
case 'no_rain': return '无降雨';
case 'light_rain': return '小雨';
case 'moderate_rain': return '中雨';
case 'heavy_rain': return '大雨';
default: return '$rainfall mm';
}
}
return '$rainfall mm';
}
```
### 电量颜色规则
```dart
Color _getBatteryColor(dynamic battery) {
final value = battery is num ? battery.toDouble() : 0.0;
if (value > 50) return Color(0xFF00B42A); // 绿色
if (value > 20) return Color(0xFFFF7D00); // 橙色
return Color(0xFFF53F3F); // 红色
}
```
## 🔄 生命周期管理
### 启动监听
```dart
@override
void initState() {
super.initState();
_startListening(); // 开始监听 MQTT
}
```
### 停止监听
```dart
@override
void dispose() {
_stopListening(); // 取消订阅,释放资源
super.dispose();
}
```
### 动态更新
```dart
@override
void didUpdateWidget(DroneStationOsdCard oldWidget) {
super.didUpdateWidget(oldWidget);
// gatewaySn 或 isOnline 变化时重新订阅
if (oldWidget.gatewaySn != widget.gatewaySn ||
oldWidget.isOnline != widget.isOnline) {
_stopListening();
_startListening();
}
}
```
## 🚀 使用方法
### 在详情页中集成
```dart
DroneStationOsdCard(
gatewaySn: widget.station.gatewaySn,
isOnline: detail.isOnline,
)
```
### 参数说明
| 参数 | 类型 | 说明 |
|------|------|------|
| `gatewaySn` | String | 机场网关序列号(用于 MQTT topic) |
| `isOnline` | bool | 机场是否在线(决定是否显示实时数据) |
## 📝 扩展建议
### 1. 添加更多 OSD 字段
在 `_parseOsdFields` 方法中添加新字段:
```dart
{
'icon': Icons.new_icon,
'label': '新字段名称',
'value': data['newField'] != null ? '${data['newField']}' : '未知',
'color': const Color(0xFF165DFF),
},
```
### 2. 自定义滑动方向
修改 `PageView` 的 `scrollDirection`:
```dart
PageView.builder(
scrollDirection: Axis.vertical, // 改为上下滑动
// ...
)
```
### 3. 自动轮播
添加定时器自动切换卡片:
```dart
Timer.periodic(Duration(seconds: 3), (timer) {
if (_osdFields.isNotEmpty) {
setState(() {
_currentIndex = (_currentIndex + 1) % _osdFields.length;
});
_pageController.animateToPage(_currentIndex, ...);
}
});
```
### 4. 手势优化
添加双击放大、长按复制等功能:
```dart
GestureDetector(
onDoubleTap: () {
// 双击放大
},
onLongPress: () {
// 长按复制数值
Clipboard.setData(ClipboardData(text: field['value']));
},
child: _buildOsdPage(field, index),
)
```
## ⚠️ 注意事项
1. **MQTT 连接**:确保应用已连接到 MQTT 服务器
2. **topic 格式**:必须为 `thing/product/${gatewaySn}/osd`
3. **数据格式**:MQTT 消息 payload 必须是 JSON 格式
4. **内存管理**:页面销毁时必须取消订阅,避免内存泄漏
5. **在线判断**:只有机场在线时才订阅 MQTT,离线时不订阅
## 🐛 调试技巧
### 查看 MQTT 日志
```dart
debugPrint('🔊 [DroneStationOsdCard] 开始监听机场 OSD: ${widget.gatewaySn}');
debugPrint('✅ [DroneStationOsdCard] 收到机场 OSD 数据');
```
### 检查数据解析
```dart
debugPrint('📊 [DroneStationOsdCard] OSD 原始数据: ${osd.rawData}');
debugPrint('📊 [DroneStationOsdCard] 解析后字段数: ${_osdFields.length}');
```
## 📦 依赖项
- `flutter_bloc`: 状态管理
- `mqtt_client`: MQTT 通信
- `equatable`: 实体类比较
- `get_it`: 依赖注入
## ✅ 完成清单
- [x] 创建 `DroneStationOsdCard` 组件
- [x] 实现 MQTT 数据订阅
- [x] 实现滑动窗口 UI
- [x] 实现数据解析和格式化
- [x] 集成到详情页
- [x] 添加状态管理(在线/离线/加载中)
- [x] 添加生命周期管理
- [x] 添加调试日志

View File

@@ -0,0 +1,303 @@
# Flutter 项目统一错误处理规范
## 核心原则
**所有接口异常都不应该影响页面展示,只显示友好提示弹窗(2秒后自动消失)**
## 实现方案
### 1. Cubit/Bloc 层处理
#### ✅ 正确做法
```dart
class MyCubit extends Cubit<MyState> {
Future<void> fetchData() async {
try {
emit(state.copyWith(isLoading: true));
final result = await useCase.call(params);
result.fold(
(failure) {
// 🔥 设置错误信息 + 标记需要显示弹窗
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 关键!
));
},
(data) {
emit(state.copyWith(
isLoading: false,
data: data,
));
},
);
} catch (e) {
// 🔥 捕获所有异常
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 关键!
));
}
}
}
```
#### ❌ 错误做法
```dart
// ❌ 不要抛出异常到页面层
throw Exception('网络错误');
// ❌ 不要显示原始错误信息
emit(state.copyWith(errorMessage: e.toString()));
// ❌ 不要让页面崩溃
if (error) throw error;
```
### 2. State 设计
```dart
class MyState extends Equatable {
final bool isLoading;
final String? errorMessage;
final bool shouldShowError; // 🔥 关键字段
const MyState({
this.isLoading = false,
this.errorMessage,
this.shouldShowError = false, // 默认 false
});
MyState copyWith({
bool? isLoading,
String? errorMessage,
bool? shouldShowError,
}) {
return MyState(
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage,
shouldShowError: shouldShowError ?? false, // 🔥 默认重置为 false
);
}
}
```
**关键点:**
- `shouldShowError` 默认为 `false`
- 每次 emit 时如果不传,会自动重置为 `false`
- 这样可以确保错误弹窗只显示一次
### 3. 页面层监听
```dart
BlocConsumer<MyCubit, MyState>(
listener: (context, state) {
// 🔥 自动显示错误弹窗
if (state.shouldShowError && state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
},
builder: (context, state) {
// 页面正常渲染,不受错误影响
if (state.isLoading) {
return const CircularProgressIndicator();
}
return YourContent();
},
)
```
### 4. ErrorHandler 工具类
```dart
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
class ErrorHandler {
/// 获取友好的错误消息
static String getErrorMessage(Object error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return '网络连接超时,请检查网络设置';
case DioExceptionType.connectionError:
return '网络连接失败,请检查网络';
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
if (statusCode == 401) {
return '登录已过期,请重新登录';
} else if (statusCode == 403) {
return '没有权限执行此操作';
} else if (statusCode == 404) {
return '请求的资源不存在';
} else if (statusCode == 500) {
return '服务器异常,请稍后重试';
} else {
return '服务器响应异常';
}
default:
return '请求失败,请稍后重试';
}
} else {
return '操作失败,请稍后重试';
}
}
}
```
## 完整示例
### DeviceTaskCubit 示例
```dart
class DeviceTaskCubit extends Cubit<DeviceTaskState> {
Future<void> cancelTask(String deviceId) async {
final taskId = state.currentTaskId;
if (taskId == null) {
emit(state.copyWith(errorMessage: '无可用任务'));
return;
}
emit(state.copyWith(
isLoading: true,
operationType: DeviceTaskOperationType.cancel,
));
try {
final result = await _cancelTaskUseCase.call(params);
result.fold(
(failure) {
_logger.logWithLevel('❌ 取消任务失败: ${failure.message}');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(failure.message),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示弹窗
));
},
(success) {
_logger.logWithLevel('✅ 取消任务成功');
emit(state.copyWith(
isLoading: false,
operationType: DeviceTaskOperationType.none,
));
},
);
} catch (e) {
_logger.logWithLevel('❌ 取消任务异常: $e');
emit(state.copyWith(
isLoading: false,
errorMessage: ErrorHandler.getErrorMessage(e),
operationType: DeviceTaskOperationType.none,
shouldShowError: true, // 🔥 标记需要显示弹窗
));
}
}
}
```
### 页面使用示例
```dart
class TaskPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => GetIt.I<DeviceTaskCubit>(),
child: Scaffold(
appBar: AppBar(title: const Text('任务管理')),
body: BlocConsumer<DeviceTaskCubit, DeviceTaskState>(
listener: (context, state) {
// 🔥 自动显示错误弹窗
if (state.shouldShowError && state.errorMessage != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.errorMessage!),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
),
);
}
},
builder: (context, state) {
return Column(
children: [
// 页面内容不受错误影响
Text('当前任务ID: ${state.currentTaskId ?? "无"}'),
ElevatedButton(
onPressed: () {
context.read<DeviceTaskCubit>().cancelTask(deviceId);
},
child: const Text('取消任务'),
),
if (state.isLoading)
const CircularProgressIndicator(),
],
);
},
),
),
);
}
}
```
## 错误提示文案规范
| 错误类型 | 提示文案 |
|---------|---------|
| 网络超时 | "网络连接超时,请检查网络设置" |
| 连接失败 | "网络连接失败,请检查网络" |
| 401 未授权 | "登录已过期,请重新登录" |
| 403 禁止访问 | "没有权限执行此操作" |
| 404 资源不存在 | "请求的资源不存在" |
| 500 服务器错误 | "服务器异常,请稍后重试" |
| 其他业务错误 | "操作失败,请稍后重试" |
| 未知错误 | "未知错误,请稍后重试" |
**注意:**
- ✅ 使用友好的中文提示
- ❌ 不要显示 HTTP 状态码
- ❌ 不要显示技术术语(如 "DioException"、"timeout")
- ❌ 不要显示完整的错误堆栈
## 优势
1. **页面不崩溃** - 所有异常都被捕获
2. **用户体验好** - 友好的中文提示
3. **自动消失** - 2秒后弹窗自动关闭
4. **不影响操作** - 用户可以继续使用页面
5. **统一规范** - 全项目统一的错误处理方式
## 检查清单
在开发新功能时,确保:
- [ ] Cubit 中所有 try-catch 都使用了 `ErrorHandler.getErrorMessage()`
- [ ] State 中有 `shouldShowError` 字段
- [ ] 错误时设置 `shouldShowError: true`
- [ ] copyWith 中 `shouldShowError` 默认为 `false`
- [ ] 页面 BlocConsumer listener 中监听 `shouldShowError`
- [ ] 使用 SnackBar 显示错误(2秒自动消失)
- [ ] 不显示原始错误信息

388
docs/ERROR_HANDLING_FIX.md Normal file
View File

@@ -0,0 +1,388 @@
# 统一错误处理修复记录
## 问题描述
之前项目中存在多个地方直接将原始错误信息(如 `DioException [connection error]...`)显示在页面上,严重影响用户体验。这些错误包括:
- 网络连接失败
- HTTP 状态码错误
- 超时错误
- 其他技术异常
**问题表现:**
- 全屏显示错误页面
- 直接展示技术错误信息(如 `DioException`、`HTTP 500` 等)
- 用户无法继续操作
- 错误信息不友好,普通用户看不懂
## 解决方案
采用统一的错误处理机制,确保:
1. ✅ **所有错误都被拦截**,不会直接显示原始错误
2. ✅ **友好的中文提示**,使用 SnackBar 浮动显示 2 秒后自动消失
3. ✅ **不影响页面渲染**,错误发生时页面保持不变,用户可以继续操作
4. ✅ **统一的错误处理模式**,所有 BLoC/Cubit 都遵循相同规范
## 修复的文件列表
### 1. DeviceStatusBloc (设备状态)
**文件路径:** `lib/features/v2/device_list/presentation/bloc/device_status_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 将 `emit(DeviceStatusError(e.toString()))` 改为使用 `ErrorHandler.getErrorMessage(e)`
- ✅ 设置 `shouldShowError: true` 标记
**State 更新:**
```dart
class DeviceStatusError extends DeviceStatusState {
final String message;
final bool shouldShowError; // 🔥 新增
const DeviceStatusError({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// device_status_page.dart
BlocConsumer<DeviceStatusBloc, DeviceStatusState>(
listener: (context, state) {
if (state is DeviceStatusError && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
),
);
}
},
builder: (context, state) {
if (state is DeviceStatusError) {
return const SizedBox.shrink(); // 🔥 不再显示全屏错误
}
// ... 正常内容
},
)
```
---
### 2. DroneStationBloc (无人机机场)
**文件路径:** `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 更新所有错误状态:
- `DroneStationError`
- `UAVDetailError`
- `VideoStreamError`
- `UavVideoStreamError`
- ✅ 所有错误都设置 `shouldShowError: true`
**State 更新:**
```dart
// lib/features/v2/device_list/presentation/bloc/drone_station_state.dart
class DroneStationError extends DroneStationState {
final String message;
final bool shouldShowError; // 🔥 新增
const DroneStationError({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// device_status_page.dart - _buildDroneStationList
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is DroneStationError && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
},
builder: (context, state) {
if (state is DroneStationError) {
return const SizedBox.shrink(); // 🔥 保持页面
}
// ... 正常内容
},
)
```
---
### 3. RobotListBloc (机器人列表)
**文件路径:** `lib/features/v2/device_list/presentation/bloc/robot_list_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 将 `emit(RobotListError(e.toString()))` 改为使用 `ErrorHandler`
- ✅ 设置 `shouldShowError: true`
**State 更新:**
```dart
class RobotListError extends RobotListState {
final String message;
final bool shouldShowError; // 🔥 新增
const RobotListError({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// robot_list_page.dart
BlocConsumer<RobotListBloc, RobotListState>(
listener: (context, state) {
if (state is RobotListError && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
},
builder: (context, state) {
if (state is RobotListError) {
return const SizedBox.shrink();
}
// ... 正常内容
},
)
```
---
### 4. HomeV2Bloc (首页 V2)
**文件路径:** `lib/features/v2/home/presentation/bloc/home_v2_bloc.dart`
**修改内容:**
- ✅ 添加 `ErrorHandler` 导入
- ✅ 将所有 `emit(HomeV2Error(failure.message))` 改为使用 `ErrorHandler`
- ✅ 设置 `shouldShowError: true`
**State 更新:**
```dart
class HomeV2Error extends HomeV2State {
final String message;
final bool shouldShowError; // 🔥 新增
const HomeV2Error({
required this.message,
this.shouldShowError = false,
});
}
```
**页面更新:**
```dart
// home_v2_page.dart
BlocConsumer<HomeV2Bloc, HomeV2State>(
listener: (context, state) {
if (state is HomeV2Error && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(...);
}
},
builder: (context, state) {
if (state is HomeV2Error) {
return const SizedBox.shrink();
}
// ... 正常内容
},
)
```
---
## 统一错误处理流程
### 1. BLoC/Cubit 层
```dart
try {
// 业务逻辑
final result = await useCase.call(params);
result.fold(
(failure) => emit(ErrorState(
message: ErrorHandler.getErrorMessage(failure.message),
shouldShowError: true, // 🔥 关键:标记需要显示弹窗
)),
(data) => emit(SuccessState(data)),
);
} catch (e) {
emit(ErrorState(
message: ErrorHandler.getErrorMessage(e),
shouldShowError: true, // 🔥 关键:标记需要显示弹窗
));
}
```
### 2. State 层
```dart
class ErrorState extends SomeState {
final String message;
final bool shouldShowError; // 🔥 关键:用于标记是否显示弹窗
const ErrorState({
required this.message,
this.shouldShowError = false, // 默认不显示
});
}
```
### 3. Page 层
```dart
BlocConsumer<SomeBloc, SomeState>(
listener: (context, state) {
// 🔥 监听错误并显示友好提示
if (state is ErrorState && state.shouldShowError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.orange,
),
);
}
},
builder: (context, state) {
if (state is ErrorState) {
// 🔥 返回空容器,保持当前页面不变
return const SizedBox.shrink();
}
// ... 正常内容
},
)
```
---
## ErrorHandler 工具类
**文件路径:** `lib/core/network/error_handler.dart`
**功能:**
- 将所有 Dio 异常转换为友好的中文提示
- 支持多种错误类型:
- 连接超时 → "网络连接超时,请检查网络设置"
- 连接失败 → "网络连接失败,请检查网络"
- 401 → "登录已过期,请重新登录"
- 403 → "没有权限执行此操作"
- 404 → "请求的资源不存在"
- 500 → "服务器异常,请稍后重试"
- 其他 → "操作失败,请稍后重试"
**使用方法:**
```dart
// 在 BLoC/Cubit 中
String friendlyMessage = ErrorHandler.getErrorMessage(error);
// 或在页面中直接显示
ErrorHandler.handleError(context, error);
```
---
## 修复效果对比
### 修复前 ❌
```
┌─────────────────────────────┐
│ │
│ ⚠️ Error Icon │
│ │
│ DioException [connection │
│ error]: The connection │
│ errored: Failed host │
│ lookup... │
│ │
│ [ 重试 ] │
│ │
└─────────────────────────────┘
```
- 全屏错误页面
- 显示原始技术错误
- 用户无法继续操作
- 体验极差
### 修复后 ✅
```
┌─────────────────────────────┐
│ │
│ [正常页面内容] │
│ │
│ 用户可以看到页面 │
│ 可以继续操作 │
│ │
└─────────────────────────────┘
↓
┌──────────────┐
│ 网络连接失败 │ ← SnackBar 浮动显示
│ 请检查网络 │ 2秒后自动消失
└──────────────┘
```
- 页面保持不变
- 友好的中文提示
- SnackBar 浮动显示
- 2秒自动消失
- 用户可以继续操作
---
## 最佳实践
### ✅ DO - 应该这样做
1. 所有 BLoC/Cubit 的错误处理都使用 `ErrorHandler.getErrorMessage()`
2. 所有错误状态都设置 `shouldShowError: true`
3. 页面使用 `BlocConsumer` 监听错误
4. 错误时使用 `SizedBox.shrink()` 保持页面
5. SnackBar 设置为 `floating` 和 `orange` 背景
### ❌ DON'T - 不要这样做
1. ❌ 直接使用 `e.toString()` 作为错误消息
2. ❌ 在页面中显示全屏错误页面
3. ❌ 直接展示原始技术错误(DioException、HTTP 状态码等)
4. ❌ 让用户点击"重试"按钮才能恢复
5. ❌ 不同页面使用不同的错误处理方式
---
## 后续工作
### 待迁移的 BLoC/Cubit
以下 BLoC/Cubit 可能还需要迁移到统一的错误处理模式:
- [ ] 其他未检查的 BLoC
- [ ] 第三方库相关的错误处理
- [ ] WebSocket 连接的错误处理
### 优化建议
1. 考虑为不同类型的错误设置不同的 SnackBar 颜色
2. 可以添加错误日志上报功能
3. 可以考虑添加错误重试机制(在后台自动重试)
4. 可以为特定场景定制错误提示文案
---
## 总结
通过本次修复,我们实现了:
✅ 统一的错误处理机制
✅ 友好的用户提示
✅ 不影响页面渲染
✅ 提升用户体验
✅ 代码规范统一
**核心原则:**
> 任何接口异常都不应该影响页面的展示,给个几秒弹窗就行!
---
**修复日期:** 2026-06-15
**修复人员:** AI Assistant
**影响范围:** 设备管理、首页相关的所有 BLoC/Cubit

520
docs/SOP_使用文档.md Normal file
View File

@@ -0,0 +1,520 @@
# 迈步 Satabot App 使用说明书
> 版本:1.0.0 | 适用对象:终端用户(运维人员)
---
## 目录
1. [应用简介](#1-应用简介)
2. [安装与登录](#2-安装与登录)
3. [首页](#3-首页)
4. [设备管理](#4-设备管理)
5. [机器人控制](#5-机器人控制)
6. [无人机巡检](#6-无人机巡检)
7. [告警中心](#7-告警中心)
8. [工单管理](#8-工单管理)
9. [问题上报](#9-问题上报)
10. [我的](#10-我的)
11. [蓝牙设备绑定](#11-蓝牙设备绑定)
12. [常见问题](#12-常见问题)
---
## 1. 应用概述
### 1.1 产品简介
迈步 Satabot 是一款面向光伏电站的智能管理移动应用,为电站运维人员提供一站式的设备监控、远程控制、告警处理和工单管理能力。通过本 App,运维人员可以随时随地掌握电站运行状况,快速响应设备异常,提升运维效率。
### 1.2 适用人群
| 角色 | 说明 |
|------|------|
| 电站运维人员 | 日常巡检、设备监控、告警处理、工单执行 |
| 设备管理人员 | 设备绑定、参数配置、运行状态查看 |
| 管理人员 | 电站发电数据查看、工单统计 |
### 1.3 核心功能
| 功能模块 | 说明 |
|---------|------|
| 首页 | 电站发电概览、实时功率、设备统计、快捷入口 |
| 设备管理 | 设备列表、分类筛选、搜索、蓝牙扫描、设备详情 |
| 机器人控制 | 远程方向控制、任务管理、运行参数设置 |
| 无人机巡检 | 机场状态、实时监控、视频直播、飞行任务管理 |
| 告警中心 | 告警统计、告警处理、派发工单、AI 诊断 |
| 工单管理 | 工单全流程(新建→执行→完成/挂起/转派) |
| 问题上报 | 问题类型选择、媒体上传、设备关联 |
| 个人中心 | 个人信息、系统设置、夜间模式、导航自定义 |
### 1.4 设备类型
本 App 管理以下类型的设备:
| 设备类型 | 说明 |
|---------|------|
| 光伏逆变器 | 将直流电转换为交流电的核心设备 |
| 汇流箱 | 汇集多个光伏组件的直流电 |
| 光伏组件 | 将太阳能转化为直流电的组件 |
| 监控设备 | 数据采集与传输设备 |
| 清洁除草机器人 | MC700 系列,光伏面板清洁除草机器人 |
| 无人机机场 | 无人机自动机巢,含无人机巡检 |
### 1.5 底部导航栏
App 底部有以下功能页签,可在"我的 → 底部导航设置"中自定义显示和排序:
| 页签 | 名称 | 说明 |
|------|------|------|
| 首页 | 电站概览 | 发电数据、设备统计、快捷入口 |
| 设备 | 设备管理 | 设备列表、机器人、无人机 |
| 告警 | 告警中心 | 查看和处理设备告警 |
| 工单 | 工单管理 | 工单全流程管理 |
| 上报 | 问题上报 | 上报设备问题 |
| 我的 | 个人中心 | 个人信息、系统设置 |
---
## 2. 安装与登录
### 2.1 安装
- **Android**:扫描二维码下载 APK 安装
- **iOS**:扫描二维码跳转 App Store 安装
- 安装时如提示"未知来源",请在手机设置中允许安装
### 2.2 登录
1. 打开 App,进入登录页面
2. 输入**用户名**和**密码**
3. 勾选"已阅读并同意用户协议"
4. 点击**登录**按钮
5. 登录成功后自动跳转到首页
> **提示**:如果忘记密码,请联系管理员重置。
### 2.3 注册
1. 在登录页面点击**注册账号**
2. 填写用户名、密码等信息
3. 提交后等待管理员审核
### 2.4 修改密码
1. 登录后进入"我的"页面
2. 点击"修改密码"
3. 输入旧密码和新密码
4. 提交完成修改
---
## 3. 首页
### 3.1 页面说明
首页展示当前选中电站的整体运行情况。
### 3.2 切换电站
- 页面顶部左侧显示当前电站名称
- 点击电站名称可下拉切换其他电站
- 切换后所有数据自动刷新
### 3.3 功能区域
| 区域 | 说明 |
|------|------|
| 天气信息 | 显示电站所在地天气 |
| 电站概览 | 装机容量、今日发电量等 |
| 功率卡片 | 实时功率、今日累计发电量、功率趋势图 |
| 统计网格 | 设备总数、在线数、离线数、异常数 |
| 工单卡片 | 待处理工单数量,点击进入工单页 |
| 快捷入口 | 设备管理、告警、工单等快速跳转 |
| TCP 状态 | 右上角圆点显示通信连接状态(绿色=正常) |
### 3.4 下拉刷新
- 在首页任意位置**下拉**可刷新所有数据
---
## 4. 设备管理
### 4.1 进入设备页
点击底部导航栏的**设备**页签。
### 4.2 切换电站
- 页面顶部左侧可切换电站,切换后设备列表自动刷新
### 4.3 搜索设备
- 页面上方搜索框中输入设备名称
- 点击搜索按钮或按确认键进行搜索
### 4.4 设备分类筛选
页面中部有设备类型筛选条:
| 筛选项 | 说明 |
|--------|------|
| 全部 | 显示所有设备 |
| 逆变器 | 仅显示逆变器设备 |
| 汇流箱 | 仅显示汇流箱设备 |
| 组件 | 仅显示光伏组件 |
| 监控 | 仅显示监控设备 |
| 机器人 | 仅显示清洁除草机器人 |
| 无人机机场 | 仅显示无人机机巢 |
### 4.5 添加设备
- 点击页面右上角 **+** 按钮
- 可选择**扫一扫**(扫码绑定)或**蓝牙**(蓝牙搜索绑定)
### 4.6 蓝牙扫描
- 点击搜索框旁边的**蓝牙图标**
- App 开始扫描附近蓝牙设备
- 扫描结果中,名称为纯数字的设备(时间戳格式)自动**置顶**显示
- 可在搜索框中输入关键词过滤设备
- 点击设备可进入设备详情页
### 4.7 设备详情
点击设备列表中的设备,进入设备详情页:
- **光伏设备**:显示设备运行参数(功率、电压、电流、温度等)
- **机器人**:进入机器人控制页(详见第 5 章)
- **无人机机场**:进入机场详情页(详见第 6 章)
---
## 5. 机器人控制
### 5.1 进入方式
设备页 → 筛选"机器人" → 点击机器人设备
### 5.2 机器人详情页
| 区域 | 说明 |
|------|------|
| 设备信息卡 | 显示机器人名称、序列号、在线状态、电量 |
| 状态栏 | 运行状态、工作模式、当前位置 |
| 控制面板 | 方向控制(前进/后退/左转/右转/停止) |
| 检查清单 | 任务执行前的检查项 |
| 任务信息 | 当前任务详情 |
| 操作按钮 | 启动任务、暂停、取消等 |
### 5.3 远程控制
1. 进入机器人详情页后,App 自动申请远程控制权限
2. 获得权限后可使用控制面板上的方向按钮
3. 点击**停止**按钮停止移动
4. 退出页面时自动释放控制权限
### 5.4 任务管理
| 操作 | 说明 |
|------|------|
| 创建任务 | 设置工作区域参数后创建清洁任务 |
| 启动任务 | 选择任务后点击启动 |
| 暂停任务 | 执行中点击暂停 |
| 恢复任务 | 暂停后点击恢复 |
| 取消任务 | 取消当前任务 |
### 5.5 运行参数设置
1. 在机器人详情页点击设置图标
2. 可调整运行参数(速度、切割高度等)
3. 点击保存生效
---
## 6. 无人机巡检
### 6.1 进入方式
设备页 → 筛选"无人机机场" → 点击机场设备
### 6.2 机场详情页
| 信息 | 说明 |
|------|------|
| 机场状态 | 在线状态、仓内温度 |
| 无人机状态 | 电量、位置、飞行状态 |
| 视频监控 | 仓内监控画面 |
### 6.3 实时监控
- 点击"实时监控"进入无人机监控页
- 显示无人机 OSD 数据:经纬度、高度、速度、电量、航向等
### 6.4 视频直播
1. 点击"视频直播"进入直播页面
2. 可切换摄像头:广角 / 变焦 / 红外
3. 支持画面全屏、方向控制
### 6.5 飞行任务
| 操作 | 说明 |
|------|------|
| 查看任务 | 查看已有飞行任务列表 |
| 创建任务 | 选择航线、设置参数后创建 |
| 执行任务 | 选择任务后点击执行 |
| 暂停任务 | 飞行中可暂停 |
| 返航 | 一键返航 |
---
## 7. 告警中心
### 7.1 进入方式
点击底部导航栏的**告警**页签。
### 7.2 告警统计
- 页面上方显示告警数量统计卡片
- 按状态分类:待处理、处理中、已处理、已关闭
### 7.3 告警列表
- 按状态 Tab 分类展示
- 下拉刷新,上拉加载更多
- 点击告警条目进入详情页
### 7.4 告警详情
| 信息 | 说明 |
|------|------|
| 告警信息 | 告警名称、级别、时间、设备 |
| 告警详情 | 详细描述、可能原因 |
| AI 诊断 | AI 辅助分析告警原因和建议 |
### 7.5 处理告警
1. 在告警详情页点击"处理"
2. 选择处理方式(确认/关闭)
3. 填写处理说明
4. 提交完成处理
### 7.6 派发工单
1. 在告警详情页点击"派发工单"
2. 选择工单模型
3. 填写派发信息
4. 提交后自动生成工单
---
## 8. 工单管理
### 8.1 进入方式
点击底部导航栏的**工单**页签。
### 8.2 工单统计
- 页面上方显示各状态工单数量统计
- 点击数字可快速筛选对应状态的工单
### 8.3 工单列表
- 按状态 Tab 分类展示:待处理、处理中、已完成、已挂起
- 下拉刷新,上拉加载更多
- 点击工单条目进入详情页
### 8.4 工单详情
显示工单编号、类型、设备、描述、处理记录等信息。
### 8.5 工单操作
| 操作 | 说明 |
|------|------|
| 开始执行 | 接收工单后开始处理 |
| 完成 | 处理完毕后标记完成 |
| 挂起 | 暂时无法处理,挂起工单 |
| 恢复 | 挂起的工单恢复处理 |
| 转派 | 将工单转给其他人员 |
### 8.6 新建工单
1. 在工单页面点击"上报"按钮
2. 选择工单类型
3. 填写问题描述
4. 关联设备
5. 提交生成工单
---
## 9. 问题上报
### 9.1 进入方式
点击底部导航栏的**上报**页签。
### 9.2 上报流程
1. **选择上报类型**:故障 / 建议 / 投诉等
2. **选择严重等级**:紧急 / 严重 / 一般 / 轻微
3. **选择设备**:从设备列表中关联相关设备
4. **上传媒体**:拍照、录像或选择图片/视频
5. **填写描述**:文字描述问题详情
6. 点击**提交**完成上报
### 9.3 媒体上传
- 点击拍照按钮直接拍照
- 或从手机相册选择图片/视频
- 点击已上传的媒体可预览大图/视频
---
## 10. 我的
### 10.1 进入方式
点击底部导航栏的**我的**页签。
### 10.2 功能列表
| 功能 | 说明 |
|------|------|
| 用户信息卡 | 头像、昵称、联系方式,点击进入编辑页 |
| 快捷操作 | 常用功能快速入口 |
| 个人资料 | 修改昵称、头像等 |
| 系统设置 | 夜间模式、语言切换、清除缓存 |
| 底部导航设置 | 自定义底部 Tab 显示/隐藏和排序 |
| 版本更新 | 检查并下载新版本 |
| 退出登录 | 清除登录状态返回登录页 |
### 10.3 夜间模式
- 我的 → 系统设置 → 夜间模式开关
- 开启后 App 切换为深色主题
### 10.4 底部导航设置
1. 我的 → 底部导航设置(或首页 → 设置入口)
2. 可开关每个 Tab 的显示/隐藏
3. 至少保留一个 Tab
4. 可调整 Tab 排列顺序
5. 设置即时生效
---
## 11. 蓝牙设备绑定
### 11.1 方式一:扫码绑定
1. 设备页 → 点击右上角 **+**
2. 选择"扫一扫"
3. 扫描设备上的二维码
4. App 获取设备名称(如 `MC700AIR-CN-JS-1777272534998-0000000A-75`)
5. 进入绑定页面
6. 选择所属组织和场站
7. 点击提交完成绑定
### 11.2 方式二:蓝牙搜索绑定
1. 设备页 → 点击搜索框旁的**蓝牙图标**
2. App 扫描附近蓝牙设备
3. 在搜索框输入关键词可过滤设备
4. 蓝牙名为纯数字的设备(时间戳格式)自动置顶
5. 点击目标设备进入设备详情页
6. 点击**连接设备**按钮连接蓝牙
7. 连接成功后,**绑定设备**按钮变为可点击状态
8. 未连接时"绑定设备"按钮为灰色,不可点击
9. 点击"绑定设备"
- App 自动获取全部设备列表
- 用蓝牙名(时间戳,如 `1777272534998`)匹配设备
- 匹配成功后自动跳转到绑定页面
10. 选择所属组织和场站
11. 点击提交完成绑定
### 11.3 设备名说明
| 位置 | 名称示例 | 说明 |
|------|---------|------|
| 蓝牙广播名 | `1777272534998` | 纯数字时间戳 |
| 设备完整名 | `MC700AIR-CN-JS-1777272534998-0000000A-75` | 设备序列号 |
| 匹配方式 | 完整名包含蓝牙名 | 蓝牙名是完整名的一部分 |
---
## 12. 常见问题
### Q1:登录提示"登录失败"?
- 检查用户名和密码是否正确
- 检查网络是否正常
- 联系管理员确认账号是否已开通
### Q2:蓝牙扫描不到设备?
- 确认手机蓝牙已开启
- 确认已授予 App 蓝牙权限和定位权限
- 靠近设备后重试
- 安卓手机需开启"位置信息"开关
### Q3:蓝牙连接失败?
- 确认设备已通电
- 确认设备未被其他手机连接
- 靠近设备重试
- 重启手机蓝牙后重试
### Q4:绑定设备按钮灰色不可点击?
- "绑定设备"按钮需要**先连接蓝牙**后才能使用
- 请先点击"连接设备"按钮,连接成功后按钮变为可点击
### Q5:点击绑定设备后提示"未匹配到设备"?
- 确认设备已录入后台系统
- 联系管理员确认设备名中是否包含当前蓝牙名(时间戳)
### Q6:首页数据显示"加载失败"?
- 检查网络连接
- 点击"重试"按钮
- 确认当前账号有权限访问该电站
### Q7:机器人控制无反应?
- 确认机器人在线
- 确认已获得远程控制权限
- 确认机器人未在执行其他任务
- 退出页面重新进入
### Q8:无人机视频不显示?
- 确认无人机和机场在线
- 确认网络畅通
- 尝试切换摄像头
### Q9:TCP 状态显示异常?
- 首页右上角圆点显示 TCP 连接状态
- 红色表示未连接,绿色表示正常
- 如持续异常,检查网络或联系管理员
### Q10:App 版本过旧无法使用?
- 进入"我的"→"版本更新"检查新版本
- 下载并安装最新版本
---
## 联系与反馈
如遇到本说明书未涵盖的问题,请通过以下方式联系:
- **客服电话**:请联系管理员获取
- **问题反馈**:App 内"上报"页面提交问题反馈
- **技术支持**:请联系管理员获取联系方式

View File

@@ -0,0 +1,197 @@
# Tab 配置系统开发规范
## 📋 功能说明
Tab 配置系统支持动态开关控制底部导航栏的 Tab 项显示/隐藏,配置自动保存到 SharedPreferences。
## 🔧 添加新 Tab 项的步骤
当需要添加新的 Tab 项时,必须按以下顺序修改 **3 个文件**:
### 1️⃣ 修改 `tab_config.dart` - 添加默认配置
**文件路径:** `lib/features/main_container/domain/tab_config.dart`
在 `TabConfig.defaultConfig()` 方法的 `items` 列表中添加新的 `TabConfigItem`:
```dart
TabConfigItem(
id: 'your_tab_id', // 唯一标识符(小写+下划线)
name: '显示名称', // 中文名称(简洁,建议2-4个字)
nameEn: 'Display Name', // 英文名称
icon: 'icon_name_rounded', // 图标名称(Material Icons)
isEnabled: true, // 默认是否启用(true/false)
order: 7, // 排序序号(从1开始递增)
),
```
**注意事项:**
- `id` 必须唯一,建议使用有意义的英文标识
- `name` 要简洁,符合 TAB 命名规范
- `order` 决定 Tab 在导航栏中的显示顺序
- `isEnabled` 控制默认是否显示该 Tab
---
### 2️⃣ 修改 `custom_main_container.dart` - 添加页面映射
**文件路径:** `lib/features/main_container/presentation/pages/custom_main_container.dart`
#### 2.1 导入新页面
在文件顶部添加新页面的 import:
```dart
import 'package:maibu_satabot_v2/features/xxx/presentation/pages/your_page.dart';
```
#### 2.2 在 `_buildPages` 方法中添加 case
```dart
List<Widget> _buildPages(List<dynamic> enabledTabs) {
return enabledTabs.map((tab) {
switch (tab.id) {
case 'home_v2':
return const HomeV2Page();
case 'home':
return const HomePage();
case 'device':
return const DeviceStatusPage();
case 'ai':
return const AiPage();
case 'warning':
return const WarningCenterPage();
case 'my':
return const MyPage();
case 'your_tab_id': // 👈 新增这个 case
return const YourPage();
default:
return const HomePage();
}
}).toList();
}
```
---
### 3️⃣ 修改 `tab_settings_page.dart` - 添加图标映射
**文件路径:** `lib/features/main_container/presentation/pages/tab_settings_page.dart`
在 `_getIconData` 方法中添加新图标的映射:
```dart
IconData _getIconData(String iconName) {
switch (iconName) {
case 'home_rounded':
return Icons.home_rounded;
case 'grid_view_rounded':
return Icons.grid_view_rounded;
case 'devices_rounded':
return Icons.devices_rounded;
case 'auto_awesome_rounded':
return Icons.auto_awesome_rounded;
case 'warning_amber_rounded':
return Icons.warning_amber_rounded;
case 'person_rounded':
return Icons.person_rounded;
case 'your_icon_name': // 👈 新增这个 case
return Icons.your_icon_name;
default:
return Icons.home_rounded;
}
}
```
**注意:** 如果使用的是 Material Icons 标准图标,通常不需要修改此文件,除非使用了特殊图标。
---
## ✅ 检查清单
添加新 Tab 后,请确认:
- [ ] 已在 `tab_config.dart` 中添加 `TabConfigItem` 配置
- [ ] 已在 `custom_main_container.dart` 中导入新页面
- [ ] 已在 `custom_main_container.dart` 的 `switch` 中添加 case 分支
- [ ] 已在 `tab_settings_page.dart` 中添加图标映射(如需要)
- [ ] `id`、`order` 没有与其他 Tab 冲突
- [ ] 测试开关功能是否正常
- [ ] 测试切换 Tab 是否正常显示对应页面
---
## 🚀 完整示例:添加"消息"Tab
假设要添加一个"消息"Tab,显示消息中心页面:
### 步骤 1:修改 `tab_config.dart`
```dart
TabConfigItem(
id: 'message',
name: '消息',
nameEn: 'Messages',
icon: 'notifications_rounded',
isEnabled: true,
order: 7,
),
```
### 步骤 2:修改 `custom_main_container.dart`
```dart
// 顶部添加导入
import 'package:maibu_satabot_v2/features/message/presentation/pages/message_page.dart';
// 在 _buildPages 方法中添加
case 'message':
return const MessagePage();
```
### 步骤 3:修改 `tab_settings_page.dart`
```dart
case 'notifications_rounded':
return Icons.notifications_rounded;
```
---
## ⚠️ 常见问题
### Q1: 添加新 Tab 后看不到?
**A:** 点击 Tab 配置页面的"重置为默认"按钮,或清除应用数据重新运行。
### Q2: 点击 Tab 后页面不显示?
**A:** 检查 `custom_main_container.dart` 中的 `switch-case` 是否正确添加了新 Tab 的映射。
### Q3: 图标显示不正确?
**A:** 检查 `tab_settings_page.dart` 中的 `_getIconData` 方法是否添加了对应的图标映射。
### Q4: Tab 顺序不对?
**A:** 检查 `tab_config.dart` 中各 Tab 的 `order` 值,确保按期望顺序排列。
---
## 📝 核心原理
1. **配置管理:** `TabConfigCubit` 管理所有 Tab 的配置和选中状态
2. **持久化:** 配置自动保存到 SharedPreferences,重启应用后保持
3. **动态渲染:** 底部导航栏根据 `enabledItems` 动态渲染启用的 Tab
4. **页面切换:** 使用 `IndexedStack` 保持各页面状态,通过 `selectedIndex` 切换
---
## 🎯 最佳实践
1. **Tab 命名:** 保持简洁,中文 2-4 个字,避免过长
2. **默认状态:** 新功能建议设置 `isEnabled: false`,通过灰度发布逐步开放
3. **排序规划:** 预留 order 间隔(如 10, 20, 30),方便后续插入新 Tab
4. **图标选择:** 优先使用 Material Icons 的 `_rounded` 版本,风格统一
5. **测试验证:** 每次修改后测试开关、切换、持久化等功能
---
**最后更新:** 2026-05-20
**维护者:** 开发团队

View File

@@ -0,0 +1,195 @@
# 无人机实时视频接口对接完成总结
## ✅ 已完成工作
### 1. API 层
- [x] 添加 API 常量 `changeUAVLens` 到 `HttpApiConsts`
### 2. 实体层 (Domain/Entities)
- [x] 创建 `UavVideoStreamEntity` 实体类
- [x] 定义 `UavLensType` 枚举(广角、变焦、红外)
- [x] 定义 `VideoQualityType` 枚举(自适应、低、中、高清晰度)
- [x] 实现 RTC 参数解析方法(appId、roomId、token、userId)
### 3. 数据源层 (Data/Datasources)
- [x] 扩展 `DroneStationDataSource` 接口,添加 `getUavVideoStream` 方法
- [x] 实现 `DroneStationDataSourceImpl.getUavVideoStream`
- [x] 支持可选的镜头类型参数
- [x] 支持自定义清晰度和 Token 有效期
- [x] 添加详细的日志输出用于调试
### 4. 仓库层 (Data/Repositories)
- [x] 扩展 `DroneStationRepository` 接口
- [x] 实现 `DroneStationRepositoryImpl.getUavVideoStream`
- [x] 使用 `Either<Failure, T>` 模式处理错误
### 5. 用例层 (Domain/UseCases)
- [x] 创建 `GetUavVideoStreamUseCase`
- [x] 封装业务逻辑,供 BLoC 调用
- [x] 提供合理的默认值
### 6. 状态管理层 (Presentation/BLoC)
- [x] 添加 `UavVideoStreamLoad` 事件
- [x] 添加 `UavVideoStreamLoading` 状态
- [x] 添加 `UavVideoStreamLoaded` 状态
- [x] 添加 `UavVideoStreamError` 状态
- [x] 在 `DroneStationBloc` 中实现事件处理逻辑
### 7. 依赖注入 (DI)
- [x] 导入 `GetUavVideoStreamUseCase`
- [x] 注册 `GetUavVideoStreamUseCase` 为单例
- [x] 更新 `DroneStationBloc` 工厂,注入新的 UseCase
### 8. UI 层 (Presentation/Pages)
- [x] 创建示例页面 `UavLiveVideoPage`
- [x] 实现镜头切换功能(PopupMenuButton)
- [x] 实现加载状态显示
- [x] 实现错误处理和重试机制
- [x] 预留 RTC SDK 集成位置
### 9. 测试
- [x] 编写单元测试 `get_uav_video_stream_usecase_test.dart`
- [x] 测试成功场景
- [x] 测试失败场景
- [x] 测试默认参数
### 10. 文档
- [x] 创建集成指南 `UAV_VIDEO_INTEGRATION_GUIDE.md`
- [x] 创建使用示例 `UAV_VIDEO_USAGE_EXAMPLES.md`
## 📁 文件清单
### 新增文件(7个)
1. `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart` - 实体类
2. `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart` - 用例
3. `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart` - 示例页面
4. `test/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase_test.dart` - 单元测试
5. `docs/UAV_VIDEO_INTEGRATION_GUIDE.md` - 集成指南
6. `docs/UAV_VIDEO_USAGE_EXAMPLES.md` - 使用示例
7. `docs/UAV_VIDEO_COMPLETION_SUMMARY.md` - 本文件
### 修改文件(9个)
1. `lib/core/consts/http_api_consts.dart` - 添加 API 常量
2. `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
3. `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
4. `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
5. `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
6. `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
7. `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
8. `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
9. `lib/core/di/injection.dart` - 注册依赖
## 核心特性
### 清洁架构设计
- **分层清晰**:Entity → UseCase → Repository → DataSource → BLoC → UI
- **依赖倒置**:高层模块不依赖低层模块的具体实现
- **可测试性**:每层都可以独立测试
### 可扩展性
- **多镜头支持**:通过枚举轻松扩展更多镜头类型
- **多清晰度支持**:支持自适应、低、中、高四种清晰度
- **多 RTC SDK 支持**:兼容火山引擎和声网两种 RTC SDK
- **可复用**:可在任何页面中使用,无需重复实现
### 健壮性
- **错误处理**:使用 `Either<Failure, T>` 统一处理错误
- **默认值**:提供合理的默认参数,简化调用
- **日志记录**:详细的日志输出,方便调试
- **重试机制**:支持手动重试和自动刷新
## 快速开始
### 方式一:直接使用示例页面
```dart
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U',
cameraIndex: '176-0-0',
),
),
);
```
### 方式二:在自定义页面中集成
参考 `docs/UAV_VIDEO_USAGE_EXAMPLES.md` 中的详细示例。
## 📋 API 说明
### 请求参数
```json
{
"sn": "1581F8HGX253U00A063U", // 必填:设备序列号
"lensType": "", // 可选:wide/zoom/ir
"cameraIndex": "176-0-0", // 必填:摄像头编号
"qualityType": "adaptive", // 可选:adaptive/low/medium/high
"videoExpire": 720000000 // 可选:Token有效期(毫秒)
}
```
### 返回数据
```json
{
"msg": "操作成功",
"code": 200,
"data": {
"sn": "1581F8HGX253U00A063U",
"camera_index": "176-0-0",
"url": "app_id=xxx&room_id=xxx&token=xxx&user_id=xxx",
"expire_ts": 1781155234,
"url_type": "volc" // volc 或 agora
}
}
```
## 🔧 后续工作
1. [ ] 集成 RTC SDK 显示实际视频画面
- 根据 `urlType` 选择火山引擎或声网 SDK
- 初始化 RTC 引擎并加入房间
- 渲染远程视频流
2. [ ] 添加视频录制功能
- 开始/停止录制
- 保存录制文件
3. [ ] 添加截图功能
- 截取当前帧
- 保存到相册
4. [ ] 优化视频加载超时处理
- 设置合理的超时时间
- 提供友好的超时提示
5. [ ] 支持多路视频同时观看
- 分屏显示多个摄像头
- 切换主视图
## 📚 相关文档
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
- [使用示例](./UAV_VIDEO_USAGE_EXAMPLES.md)
- [API 接口定义](../lib/core/consts/http_api_consts.dart)
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
## ✅ 验证结果
- [x] 所有文件编译通过,无错误
- [x] 单元测试编写完成
- [x] 依赖注入配置正确
- [x] 文档齐全
## 💡 注意事项
1. **不要在页面上直接调用 API**:必须通过 BLoC 管理状态
2. **及时释放资源**:在 `dispose()` 中关闭 BLoC
3. **合理设置 Token 有效期**:建议设置为较长的时间,避免频繁刷新
4. **检查 RTC 参数**:确保 AppId、RoomId、Token、UserId 正确解析
5. **根据 urlType 选择 SDK**:volc 使用火山引擎,agora 使用声网
## 🎉 总结
无人机实时视频接口已成功对接,采用清洁架构设计,具有良好的可扩展性和可维护性。所有代码已通过编译检查,单元测试覆盖主要场景,文档齐全。下一步只需集成 RTC SDK 即可显示实际视频画面。

View File

@@ -0,0 +1,323 @@
# 无人机实时视频接口对接说明
## 概述
本模块实现了无人机实时视频流的获取功能,采用清洁架构设计,支持多种镜头类型切换(广角、变焦、红外),并兼容火山引擎和声网两种RTC SDK。
## API 接口
### 接口地址
```
POST http://1.95.137.212:59015/iot/UAV/changeLens
```
### 请求参数
```json
{
"sn": "1581F8HGX253U00A063U", // 无人机设备序列号(必填)
"lensType": "", // 镜头类型:wide(广角)、zoom(变焦)、ir(红外),可为空
"cameraIndex": "176-0-0", // 摄像头编号(必填)
"qualityType": "adaptive", // 清晰度:adaptive(自适应)、low、medium、high(默认adaptive)
"videoExpire": 720000000 // Token有效期(毫秒,默认720000000)
}
```
### 返回数据
```json
{
"msg": "操作成功",
"code": 200,
"data": {
"sn": "1581F8HGX253U00A063U",
"camera_index": "176-0-0",
"url": "app_id=xxx&expire_time=xxx&room_id=xxx&token=xxx&user_id=xxx",
"expire_ts": 1781155234,
"url_type": "volc" // volc(火山引擎) 或 agora(声网)
}
}
```
## 架构设计
### 目录结构
```
lib/features/v2/device_list/
├── domain/
│ ├── entities/
│ │ └── uav_video_stream_entity.dart # 实体类
│ ├── repositories/
│ │ ── drone_station_repository.dart # 仓库接口
│ └── usecases/
│ └── get_uav_video_stream_usecase.dart # 用例
├── data/
│ ├── datasources/
│ │ ├── drone_station_datasource.dart # 数据源接口
│ │ └── drone_station_datasource_impl.dart # 数据源实现
│ └── repositories/
│ └── drone_station_repository_impl.dart # 仓库实现
── presentation/
├── bloc/
│ ├── drone_station_bloc.dart # BLoC状态管理
│ ├── drone_station_event.dart # 事件定义
│ └── drone_station_state.dart # 状态定义
└── pages/
── uav_live_video_page.dart # 示例页面
```
### 核心组件
#### 1. 实体类 (Entity)
**文件**: `domain/entities/uav_video_stream_entity.dart`
定义了枚举类型和实体类:
- `UavLensType`: 镜头类型枚举(wide、zoom、ir)
- `VideoQualityType`: 视频质量枚举(adaptive、low、medium、high)
- `UavVideoStreamEntity`: 视频流实体,包含解析RTC参数的方法
#### 2. 数据源 (DataSource)
**文件**: `data/datasources/drone_station_datasource_impl.dart`
实现了API调用逻辑:
- 发送POST请求到 `/iot/UAV/changeLens`
- 处理响应并转换为实体对象
- 包含详细的日志输出用于调试
#### 3. 仓库 (Repository)
**文件**: `data/repositories/drone_station_repository_impl.dart`
使用 `fpdart` 的 `Either` 类型处理错误:
- 成功时返回 `Right(UavVideoStreamEntity)`
- 失败时返回 `Left(Failure)`
#### 4. 用例 (UseCase)
**文件**: `domain/usecases/get_uav_video_stream_usecase.dart`
封装业务逻辑,供BLoC调用。
#### 5. BLoC 状态管理
**文件**: `presentation/bloc/drone_station_bloc.dart`
新增事件和状态:
- **事件**: `UavVideoStreamLoad` - 加载视频流
- **状态**:
- `UavVideoStreamLoading` - 加载中
- `UavVideoStreamLoaded` - 加载成功
- `UavVideoStreamError` - 加载失败
#### 6. 依赖注入
**文件**: `core/di/injection.dart`
已注册以下单例:
```dart
sl.registerLazySingleton<GetUavVideoStreamUseCase>(
() => GetUavVideoStreamUseCase(sl()),
);
sl.registerFactory<DroneStationBloc>(
() => DroneStationBloc(sl(), sl(), sl(), sl()),
);
```
## 使用方式
### 方式一:直接使用示例页面
```dart
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U',
cameraIndex: '176-0-0',
),
),
);
```
### 方式二:在现有页面中使用
#### 1. 导入必要的类
```dart
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
```
#### 2. 初始化 BLoC
```dart
late DroneStationBloc _bloc;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 加载视频流
_loadVideoStream(UavLensType.wide);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
```
#### 3. 加载视频流
```dart
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive,
videoExpire: 720000000,
),
);
}
```
#### 4. 监听状态变化
```dart
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
final videoStream = state.videoStream;
debugPrint('URL Type: ${videoStream.urlType}');
debugPrint('AppId: ${videoStream.appId}');
debugPrint('RoomId: ${videoStream.roomId}');
debugPrint('UserId: ${videoStream.userId}');
// TODO: 根据 urlType 初始化对应的 RTC 引擎
if (videoStream.urlType.toLowerCase() == 'volc') {
// 使用火山引擎 RTC SDK
} else if (videoStream.urlType.toLowerCase() == 'agora') {
// 使用声网 RTC SDK
}
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
});
}
},
builder: (context, state) {
// 根据状态渲染UI
if (state is UavVideoStreamLoading) {
return const CircularProgressIndicator();
}
if (state is UavVideoStreamLoaded) {
// 显示视频画面
return Container();
}
if (state is UavVideoStreamError) {
return Text('错误: ${state.message}');
}
return Container();
},
)
```
#### 5. 切换镜头类型
```dart
// 切换到广角镜头
_loadVideoStream(UavLensType.wide);
// 切换到变焦镜头
_loadVideoStream(UavLensType.zoom);
// 切换到红外镜头
_loadVideoStream(UavLensType.ir);
```
## RTC 集成
获取到视频流后,需要根据 `urlType` 选择对应的 RTC SDK:
### 火山引擎 (volc)
```dart
final appId = videoStream.appId;
final roomId = videoStream.roomId;
final token = videoStream.token;
final userId = videoStream.userId;
// 使用 volc_engine_rtc SDK
final engine = await RTCEngine.createRTCEngine(
RTCVideoContext(appId: appId, eventHandler: handler),
);
final room = await engine.createRTCRoom(roomId);
await room.joinRoom(token: token, userId: userId);
```
### 声网 (agora)
```dart
final appId = videoStream.appId;
final channelId = videoStream.roomId;
final token = videoStream.token;
final uid = int.tryParse(videoStream.userId) ?? 0;
// 使用 agora_rtc_engine SDK
final engine = createAgoraRtcEngine();
await engine.initialize(RtcEngineContext(appId: appId));
await engine.joinChannel(
token: token,
channelId: channelId,
uid: uid,
options: ChannelMediaOptions(...),
);
```
## 注意事项
1. **可扩展性**: 本模块采用清洁架构设计,所有业务逻辑与UI分离,便于在其他页面复用。
2. **错误处理**: 使用 `Either<Failure, T>` 模式统一处理错误,确保异常不会直接抛出。
3. **日志记录**: 数据源层包含详细的日志输出,方便调试和问题排查。
4. **默认值**:
- `lensType` 可为空,后端会根据实际情况选择默认镜头
- `qualityType` 默认为 `adaptive`(自适应)
- `videoExpire` 默认为 `720000000` 毫秒
5. **Token 有效期**: `videoExpire` 参数单位为毫秒,建议设置较长的有效期以避免频繁刷新。
6. **多镜头支持**: 通过 `UavLensType` 枚举可以轻松扩展更多镜头类型。
## 后续工作
1. [ ] 集成 RTC SDK 显示实际视频画面
2. [ ] 添加视频录制功能
3. [ ] 添加截图功能
4. [ ] 优化视频加载超时处理
5. [ ] 添加视频质量切换功能
6. [ ] 支持多路视频同时观看
## 相关文件清单
### 新增文件
- `lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart`
- `lib/features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart`
- `lib/features/v2/device_list/presentation/pages/uav_live_video_page.dart`
### 修改文件
- `lib/core/consts/http_api_consts.dart` - 添加 API 常量
- `lib/features/v2/device_list/data/datasources/drone_station_datasource.dart` - 添加接口方法
- `lib/features/v2/device_list/data/datasources/drone_station_datasource_impl.dart` - 实现接口
- `lib/features/v2/device_list/data/repositories/drone_station_repository_impl.dart` - 实现仓库
- `lib/features/v2/device_list/domain/repositories/drone_station_repository.dart` - 添加仓库接口
- `lib/features/v2/device_list/presentation/bloc/drone_station_event.dart` - 添加事件
- `lib/features/v2/device_list/presentation/bloc/drone_station_state.dart` - 添加状态
- `lib/features/v2/device_list/presentation/bloc/drone_station_bloc.dart` - 添加事件处理
- `lib/core/di/injection.dart` - 注册依赖

View File

@@ -0,0 +1,298 @@
# 无人机视频页面集成完成
## 📋 更新内容
已将新的无人机实时视频接口集成到现有的**无人机视频回传/远程控制**页面中。
---
## 🎯 集成位置
### 1. 无人机详情页面 → 无人机状态卡片
**文件**: `lib/features/v2/device_list/presentation/pages/drone_station_detail_page.dart`
**修改内容**:
```dart
// 点击"无人机状态"卡片时,传递设备序列号和摄像头索引
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => DroneVideoControlPage(
droneSn: detail.deviceSn, // ✅ 传递设备序列号
cameraIndex: detail.gatewayCameraList != null &&
detail.gatewayCameraList!.isNotEmpty
? detail.gatewayCameraList!.first.cameraIndex
: '176-0-0', // 默认值
),
),
);
```
---
### 2. 无人机视频控制页面
**文件**: `lib/features/v2/device_list/presentation/pages/drone_video_control_page.dart`
**新增功能**:
#### ✅ 接收参数
```dart
class DroneVideoControlPage extends StatefulWidget {
final String droneSn; // 无人机设备序列号
final String cameraIndex; // 摄像头编号
const DroneVideoControlPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
}
```
#### ✅ 自动加载视频流
```dart
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 默认加载广角镜头的视频流
_loadVideoStream(UavLensType.wide);
}
```
#### ✅ 镜头切换功能
在视频播放器右下角添加了镜头类型选择器:
- **广角** (wide)
- **变焦** (zoom)
- **红外** (ir)
点击可快速切换不同镜头的视频流。
#### ✅ 加载状态显示
```
┌─────────────────────────┐
│ │
│ ⏳ 正在加载... │
│ │
└─────────────────────────┘
```
#### ✅ 错误处理和重试
```
┌─────────────────────────┐
│ │
│ ❌ 加载失败 │
│ 错误信息: xxxxx │
│ │
│ [ 重试 ] │
│ │
└─────────────────────────┘
```
---
## 🔄 工作流程
```mermaid
graph LR
A[无人机详情页] --> B[点击无人机状态卡片]
B --> C[打开视频控制页面]
C --> D[自动调用changeLens接口]
D --> E{加载结果}
E -->|成功| F[显示视频画面]
E -->|失败| G[显示错误+重试按钮]
F --> H[用户切换镜头]
H --> D
G -->|点击重试| D
```
---
## 📱 UI 展示
### 视频播放器区域
```
┌──────────────────────────────────────┐
│ ● REC 00:12:36 │
│ │
│ │
│ [视频画面/加载状态] │
│ │
│ │
│ ┌────────┐ │
│ │广角 ▼│ │ ← 镜头切换
│ └────────┘ │
└──────────────────────────────────────┘
```
### 镜头切换菜单
```
┌──────────┐
│ 广角 │ ← 当前选中
│ 变焦 │
│ 红外 │
└──────────┘
```
---
## 🔧 技术实现
### 1. BLoC 状态管理
```dart
// 监听视频流状态
BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
if (state is UavVideoStreamLoaded) {
// 获取视频流数据
_videoStream = state.videoStream;
// TODO: 初始化 RTC 引擎并显示视频
debugPrint('AppId: ${state.videoStream.appId}');
debugPrint('RoomId: ${state.videoStream.roomId}');
debugPrint('UserId: ${state.videoStream.userId}');
} else if (state is UavVideoStreamError) {
// 显示错误信息
_errorMessage = state.message;
}
},
// ...
)
```
### 2. 视频流加载
```dart
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
_currentLensType = lensType;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive,
videoExpire: 720000000,
),
);
}
```
### 3. 镜头切换
```dart
PopupMenuButton<UavLensType>(
icon: Text(_getLensTypeName(_currentLensType)),
onSelected: (UavLensType lensType) {
if (lensType != _currentLensType) {
_loadVideoStream(lensType); // 重新加载视频流
}
},
itemBuilder: (context) => [
PopupMenuItem(value: UavLensType.wide, child: Text('广角')),
PopupMenuItem(value: UavLensType.zoom, child: Text('变焦')),
PopupMenuItem(value: UavLensType.ir, child: Text('红外')),
],
)
```
---
## ✅ 已完成的功能
- ✅ 从无人机详情页传递设备序列号和摄像头索引
- ✅ 自动调用 `changeLens` 接口获取视频流
- ✅ 支持三种镜头类型切换(广角/变焦/红外)
- ✅ 加载状态显示
- ✅ 错误处理和重试机制
- ✅ 打印视频流参数(AppId、RoomId、UserId等)
- ✅ 清洁架构设计,易于扩展和维护
---
## 🔜 下一步工作(TODO)
### 1. 集成 RTC SDK 显示实际视频
在 `_buildVideoPlayer()` 方法中,当 `_videoStream != null` 时:
```dart
if (_videoStream != null) {
// 根据 urlType 选择对应的 RTC SDK
if (_videoStream!.urlType == 'volc') {
// 使用火山引擎 RTC SDK
return VolcEngineRtcView(
appId: _videoStream!.appId,
roomId: _videoStream!.roomId,
token: _videoStream!.token,
userId: _videoStream!.userId,
);
} else if (_videoStream!.urlType == 'agora') {
// 使用声网 RTC SDK
return AgoraRtcView(
appId: _videoStream!.appId,
channelId: _videoStream!.roomId,
token: _videoStream!.token,
uid: int.parse(_videoStream!.userId),
);
}
}
```
### 2. 参考现有实现
可以参考 `drone_station_detail_page.dart` 中的以下方法:
- `_initFloatingVolcEngine()` - 火山引擎初始化
- `_initFloatingAgoraEngine()` - 声网引擎初始化
- `_disposeVolcEngine()` / `_disposeAgoraEngine()` - 资源释放
### 3. 添加更多控制功能
- 云台控制(上下左右)
- 变焦倍数调节
- 拍照/录像
- AI识别结果展示
---
## 📝 测试步骤
1. **进入无人机详情页**
- 确保无人机在线且有摄像头列表
2. **点击"无人机状态"卡片**
- 应该跳转到视频控制页面
3. **观察加载过程**
- 看到 "正在加载视频流..." 提示
- 成功后显示视频画面(当前为占位图)
4. **测试镜头切换**
- 点击右下角的镜头类型按钮
- 选择"广角"、"变焦"或"红外"
- 观察是否重新加载并切换成功
5. **测试错误处理**
- 断开网络连接
- 应该显示错误信息和重试按钮
- 点击重试应该重新加载
---
## 🎉 总结
✅ **集成完成!**
现在无人机视频控制页面已经:
- 正确接收设备参数
- 自动调用新的视频流接口
- 支持多镜头切换
- 完善的加载和错误处理
只需最后一步:**集成 RTC SDK 显示实际视频画面**,即可完整实现无人机实时视频监控功能!

View File

@@ -0,0 +1,642 @@
# 无人机实时视频接口使用示例
## 快速开始
本指南展示如何在你的项目中快速集成无人机实时视频功能。
## 1. 基础用法 - 直接导航到视频页面
最简单的方式是直接导航到 `UavLiveVideoPage`:
```dart
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/pages/uav_live_video_page.dart';
// 在任意地方调用
void _openVideo() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: '1581F8HGX253U00A063U', // 无人机序列号
cameraIndex: '176-0-0', // 摄像头编号
),
),
);
}
```
## 2. 高级用法 - 在自定义页面中集成
如果你需要在自己的页面中集成视频功能,可以按照以下步骤操作:
### 步骤 1: 创建页面并初始化 BLoC
```dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../core/di/injection.dart';
import '../../domain/entities/uav_video_stream_entity.dart';
import '../bloc/drone_station_bloc.dart';
import '../bloc/drone_station_event.dart';
import '../bloc/drone_station_state.dart';
class MyCustomVideoPage extends StatefulWidget {
final String droneSn;
final String cameraIndex;
const MyCustomVideoPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
@override
State<MyCustomVideoPage> createState() => _MyCustomVideoPageState();
}
class _MyCustomVideoPageState extends State<MyCustomVideoPage> {
late DroneStationBloc _bloc;
UavVideoStreamEntity? _videoStream;
bool _isLoading = false;
String? _errorMessage;
UavLensType? _currentLensType;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
// 默认加载广角镜头
_loadVideoStream(UavLensType.wide);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
}
```
### 步骤 2: 实现加载视频流方法
```dart
/// 加载视频流
void _loadVideoStream(UavLensType lensType) {
setState(() {
_isLoading = true;
_errorMessage = null;
_currentLensType = lensType;
});
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: lensType,
qualityType: VideoQualityType.adaptive, // 自适应清晰度
videoExpire: 720000000, // Token有效期(毫秒)
),
);
}
```
### 步骤 3: 构建 UI 并监听状态变化
```dart
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _bloc,
child: Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context),
),
title: const Text(
'无人机实时视频',
style: TextStyle(color: Colors.white),
),
centerTitle: true,
actions: [
// 镜头切换按钮
PopupMenuButton<UavLensType>(
icon: const Icon(Icons.videocam, color: Colors.white),
tooltip: '切换镜头',
onSelected: (lensType) {
_loadVideoStream(lensType);
},
itemBuilder: (context) => [
const PopupMenuItem(
value: UavLensType.wide,
child: Row(
children: [
Icon(Icons.videocam, size: 20),
SizedBox(width: 8),
Text('广角镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.zoom,
child: Row(
children: [
Icon(Icons.zoom_in, size: 20),
SizedBox(width: 8),
Text('变焦镜头'),
],
),
),
const PopupMenuItem(
value: UavLensType.ir,
child: Row(
children: [
Icon(Icons.thermostat, size: 20),
SizedBox(width: 8),
Text('红外镜头'),
],
),
),
],
),
],
),
body: BlocConsumer<DroneStationBloc, DroneStationState>(
listener: (context, state) {
// 监听状态变化
if (state is UavVideoStreamLoaded) {
setState(() {
_videoStream = state.videoStream;
_isLoading = false;
});
debugPrint('=== 视频流加载成功 ===');
debugPrint('URL Type: ${state.videoStream.urlType}');
debugPrint('AppId: ${state.videoStream.appId}');
debugPrint('RoomId: ${state.videoStream.roomId}');
debugPrint('UserId: ${state.videoStream.userId}');
// TODO: 这里可以初始化 RTC 引擎并显示视频
_initRtcEngine(state.videoStream);
} else if (state is UavVideoStreamError) {
setState(() {
_errorMessage = state.message;
_isLoading = false;
});
}
},
builder: (context, state) {
// 根据状态渲染不同的 UI
// 加载中状态
if (_isLoading || state is UavVideoStreamLoading) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: Colors.white),
SizedBox(height: 16),
Text(
'正在加载视频流...',
style: TextStyle(color: Colors.white),
),
],
),
);
}
// 错误状态
if (_errorMessage != null || state is UavVideoStreamError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
_errorMessage ?? (state as UavVideoStreamError).message,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
if (_currentLensType != null) {
_loadVideoStream(_currentLensType!);
}
},
child: const Text('重试'),
),
],
),
);
}
// 无视频信号
if (_videoStream == null) {
return const Center(
child: Text(
'暂无视频信号',
style: TextStyle(color: Colors.white),
),
);
}
// 视频已加载,显示视频画面
return Container(
width: double.infinity,
height: double.infinity,
color: Colors.black,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.videocam_off,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
'视频流已获取\nURL Type: ${_videoStream!.urlType}\nCamera: $_currentLensType',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Text(
'请集成 RTC SDK 后在此处显示视频画面',
style: TextStyle(
color: Colors.grey[600],
fontSize: 12,
),
),
],
),
),
);
},
),
),
);
}
```
### 步骤 4: 初始化 RTC 引擎(可选)
```dart
/// 初始化 RTC 引擎
Future<void> _initRtcEngine(UavVideoStreamEntity videoStream) async {
final appId = videoStream.appId;
final roomId = videoStream.roomId;
final token = videoStream.token;
final userId = videoStream.userId.isNotEmpty
? videoStream.userId
: 'user_${DateTime.now().millisecondsSinceEpoch}';
if (appId.isEmpty || roomId.isEmpty || token.isEmpty) {
debugPrint('RTC 参数缺失');
return;
}
debugPrint('=== RTC 初始化 ===');
debugPrint('AppId: $appId');
debugPrint('RoomId: $roomId');
debugPrint('UserId: $userId');
debugPrint('URL Type: ${videoStream.urlType}');
// 根据 urlType 选择不同的 RTC SDK
final sdkType = videoStream.urlType.toLowerCase() == 'agora'
? RtcSdkType.agora
: RtcSdkType.volcengine;
if (sdkType == RtcSdkType.agora) {
await _initAgoraEngine(appId, roomId, token, userId);
} else {
await _initVolcEngine(appId, roomId, token, userId);
}
}
// TODO: 实现具体的 RTC 引擎初始化逻辑
// 参考 drone_station_detail_page.dart 中的实现
```
## 3. 常见场景示例
### 场景 1: 从列表页跳转到视频页
```dart
// 在设备列表中点击某个设备
void _onDeviceTap(Device device) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => UavLiveVideoPage(
droneSn: device.sn,
cameraIndex: device.cameraIndex,
),
),
);
}
```
### 场景 2: 支持多个摄像头切换
```dart
class MultiCameraVideoPage extends StatefulWidget {
final String droneSn;
final List<String> cameraIndices;
const MultiCameraVideoPage({
super.key,
required this.droneSn,
required this.cameraIndices,
});
@override
State<MultiCameraVideoPage> createState() => _MultiCameraVideoPageState();
}
class _MultiCameraVideoPageState extends State<MultiCameraVideoPage> {
late DroneStationBloc _bloc;
int _currentCameraIndex = 0;
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
_loadCurrentCamera();
}
void _loadCurrentCamera() {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndices[_currentCameraIndex],
lensType: UavLensType.wide,
),
);
}
void _switchCamera(int index) {
setState(() {
_currentCameraIndex = index;
});
_loadCurrentCamera();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('摄像头 ${_currentCameraIndex + 1}/${widget.cameraIndices.length}'),
actions: [
// 切换摄像头按钮
IconButton(
icon: const Icon(Icons.switch_camera),
onPressed: () {
final nextIndex = (_currentCameraIndex + 1) % widget.cameraIndices.length;
_switchCamera(nextIndex);
},
),
],
),
body: BlocBuilder<DroneStationBloc, DroneStationState>(
builder: (context, state) {
// ... 根据状态渲染UI
},
),
);
}
}
```
### 场景 3: 自动刷新视频流(Token 过期时)
```dart
class AutoRefreshVideoPage extends StatefulWidget {
final String droneSn;
final String cameraIndex;
const AutoRefreshVideoPage({
super.key,
required this.droneSn,
required this.cameraIndex,
});
@override
State<AutoRefreshVideoPage> createState() => _AutoRefreshVideoPageState();
}
class _AutoRefreshVideoPageState extends State<AutoRefreshVideoPage> {
late DroneStationBloc _bloc;
Timer? _refreshTimer;
static const _tokenRefreshInterval = Duration(minutes: 55); // 每55分钟刷新一次(Token有效期约1小时)
@override
void initState() {
super.initState();
_bloc = sl<DroneStationBloc>();
_loadVideoStream();
// 启动定时刷新
_startAutoRefresh();
}
void _startAutoRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer.periodic(_tokenRefreshInterval, (timer) {
debugPrint('⏰ 自动刷新视频流 Token');
_loadVideoStream();
});
}
void _loadVideoStream() {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: UavLensType.wide,
),
);
}
@override
void dispose() {
_refreshTimer?.cancel();
_bloc.close();
super.dispose();
}
}
```
## 4. 最佳实践
### ✅ 推荐做法
1. **始终检查错误状态**
```dart
if (state is UavVideoStreamError) {
// 显示友好的错误提示
showSnackBar(context, '视频加载失败: ${state.message}');
}
```
2. **提供重试机制**
```dart
ElevatedButton(
onPressed: () => _loadVideoStream(_currentLensType!),
child: const Text('重试'),
)
```
3. **记录关键日志**
```dart
debugPrint('视频流加载成功: URL Type=${videoStream.urlType}');
```
4. **合理设置 Token 有效期**
```dart
videoExpire: 720000000, // 约8天,避免频繁刷新
```
5. **及时释放资源**
```dart
@override
void dispose() {
_bloc.close();
super.dispose();
}
```
### ❌ 避免的做法
1. **不要在页面外直接调用 API**
```dart
// 错误:绕过 BLoC 直接调用
final result = await repository.getUavVideoStream(...);
// ✅ 正确:通过 BLoC 管理状态
_bloc.add(UavVideoStreamLoad(...));
```
2. **不要忘记处理加载状态**
```dart
// ❌ 错误:没有加载指示器
if (state is UavVideoStreamLoaded) { ... }
// ✅ 正确:显示加载状态
if (state is UavVideoStreamLoading) {
return CircularProgressIndicator();
}
```
3. **不要硬编码参数**
```dart
// 错误:硬编码
sn: '1581F8HGX253U00A063U',
// ✅ 正确:使用变量
sn: widget.droneSn,
```
## 5. 故障排查
### 问题 1: 视频流加载失败
**可能原因:**
- 网络问题
- 设备序列号错误
- 摄像头编号错误
- Token 过期
**解决方案:**
1. 检查网络连接
2. 验证 `droneSn` 和 `cameraIndex` 是否正确
3. 查看控制台日志输出
4. 尝试重新加载
### 问题 2: 视频画面不显示
**可能原因:**
- RTC SDK 未正确初始化
- RTC 参数解析失败
- SDK 版本不兼容
**解决方案:**
1. 检查 `urlType` 字段,确认使用正确的 SDK
2. 验证 AppId、RoomId、Token、UserId 是否正确解析
3. 查看 RTC SDK 的日志输出
4. 参考 `drone_station_detail_page.dart` 中的实现
### 问题 3: 镜头切换无效
**可能原因:**
- 后端不支持该镜头类型
- 摄像头不支持指定的镜头
**解决方案:**
1. 检查后端返回的错误信息
2. 尝试其他镜头类型
3. 联系后端确认支持的镜头类型
## 6. 扩展功能
### 添加视频录制功能
```dart
// TODO: 集成 RTC SDK 的录制功能
Future<void> _startRecording() async {
// 根据使用的 RTC SDK 调用相应的录制 API
}
Future<void> _stopRecording() async {
// 停止录制并保存文件
}
```
### 添加截图功能
```dart
// TODO: 集成 RTC SDK 的截图功能
Future<void> _takeScreenshot() async {
// 根据使用的 RTC SDK 调用相应的截图 API
// 保存截图到相册
}
```
### 添加视频质量切换
```dart
void _changeQuality(VideoQualityType quality) {
_bloc.add(
UavVideoStreamLoad(
sn: widget.droneSn,
cameraIndex: widget.cameraIndex,
lensType: _currentLensType,
qualityType: quality, // 切换清晰度
),
);
}
```
## 7. 相关文档
- [完整集成指南](./UAV_VIDEO_INTEGRATION_GUIDE.md)
- [API 接口文档](../lib/core/consts/http_api_consts.dart)
- [实体类定义](../lib/features/v2/device_list/domain/entities/uav_video_stream_entity.dart)
- [BLoC 状态管理](../lib/features/v2/device_list/presentation/bloc/)
## 8. 技术支持
如遇到问题,请:
1. 查看控制台日志输出
2. 参考示例代码 `uav_live_video_page.dart`
3. 查阅集成指南文档
4. 联系开发团队

12
fix_json.ps1 Normal file
View File

@@ -0,0 +1,12 @@
$zhPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\zh-CN.json'
$enPath = 'C:\Users\jsmbz\Documents\flutter-app-02\assets\languages\en-US.json'
$zhContent = [System.IO.File]::ReadAllText($zhPath)
$zhContent = $zhContent.Replace('"bow_mode": "弓字模式"', '"bow_mode": "覆盖模式"')
[System.IO.File]::WriteAllText($zhPath, $zhContent)
$enContent = [System.IO.File]::ReadAllText($enPath)
$enContent = $enContent.Replace('"bow_mode": "Bow Mode"', '"bow_mode": "Coverage Mode"')
[System.IO.File]::WriteAllText($enPath, $enContent)
Write-Host "Done!"

106
flutter_01.log Normal file
View File

@@ -0,0 +1,106 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.48:37689 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [1,623ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [9.3s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [14.1s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [20ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [17ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (4 available) [8.2s]
• PKB110 (mobile) • 192.168.2.48:37689 • android-arm64 • Android 16 (API 36)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[✓] Network resources [1,268ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

109
flutter_02.log Normal file
View File

@@ -0,0 +1,109 @@
Flutter crash report.
Please report a bug at https://github.com/flutter/flutter/issues.
## command
flutter --no-color run --machine --track-widget-creation --device-id=192.168.2.39:42831 --start-paused --dart-define=flutter.inspector.structuredErrors=true --devtools-server-address=http://127.0.0.1:9100 lib\main.dart
## exception
FormatException: FormatException: Unexpected character (at character 1)
Failed to init NativeSymbolResolver (SymInitialize 3221225476)
^
```
#0 _ChunkedJsonParser.fail (dart:convert-patch/convert_patch.dart:1467:5)
#1 _ChunkedJsonParser.parseNumber (dart:convert-patch/convert_patch.dart:1333:9)
#2 _ChunkedJsonParser.parse (dart:convert-patch/convert_patch.dart:935:22)
#3 _JsonStringDecoderSink.addSlice (dart:convert-patch/convert_patch.dart:1555:13)
#4 _JsonStringDecoderSink.add (dart:convert-patch/convert_patch.dart:1560:5)
#5 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#6 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#7 _rootRunUnary (dart:async/zone.dart:1538:47)
#8 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#9 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#10 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#11 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#12 _SinkTransformerStreamSubscription._add (dart:async/stream_transformers.dart:67:11)
#13 _EventSinkWrapper.add (dart:async/stream_transformers.dart:13:11)
#14 _StringAdapterSink.add (dart:convert/string_conversion.dart:228:11)
#15 _StringAdapterSink.addSlice (dart:convert/string_conversion.dart:233:7)
#16 _Utf8ConversionSink.addSlice (dart:convert/string_conversion.dart:313:20)
#17 _Utf8ConversionSink.add (dart:convert/string_conversion.dart:306:5)
#18 _ConverterStreamEventSink.add (dart:convert/chunked_conversion.dart:70:18)
#19 _SinkTransformerStreamSubscription._handleData (dart:async/stream_transformers.dart:115:24)
#20 _rootRunUnary (dart:async/zone.dart:1538:47)
#21 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#22 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#23 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#24 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#25 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#26 _StreamController._add (dart:async/stream_controller.dart:663:7)
#27 _StreamController.add (dart:async/stream_controller.dart:618:5)
#28 _Socket._onData (dart:io-patch/socket_patch.dart:2880:41)
#29 _rootRunUnary (dart:async/zone.dart:1546:13)
#30 _CustomZone.runUnary (dart:async/zone.dart:1429:19)
#31 _CustomZone.runUnaryGuarded (dart:async/zone.dart:1329:7)
#32 _BufferingStreamSubscription._sendData (dart:async/stream_impl.dart:381:11)
#33 _BufferingStreamSubscription._add (dart:async/stream_impl.dart:312:7)
#34 _SyncStreamControllerDispatch._sendData (dart:async/stream_controller.dart:798:19)
#35 _StreamController._add (dart:async/stream_controller.dart:663:7)
#36 _StreamController.add (dart:async/stream_controller.dart:618:5)
#37 new _RawSocket.<anonymous closure> (dart:io-patch/socket_patch.dart:2315:31)
#38 _NativeSocket.issueReadEvent.issue (dart:io-patch/socket_patch.dart:1655:14)
#39 _microtaskLoop (dart:async/schedule_microtask.dart:40:35)
#40 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5)
#41 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:127:13)
#42 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:194:5)
```
## flutter doctor
```
[✓] Flutter (Channel stable, 3.38.7, on Microsoft Windows [版本 10.0.26200.9168], locale zh-CN) [841ms]
• Flutter version 3.38.7 on channel stable at D:\fluttersdk\flutter_windows_3.38.7-stable\flutter
• Upstream repository https://github.com/flutter/flutter.git
• Framework revision 3b62efc2a3 (8 months ago), 2026-01-13 13:47:42 -0800
• Engine revision 78fc3012e4
• Dart version 3.10.7
• DevTools version 2.51.1
• Pub download mirror https://pub.flutter-io.cn
• Flutter download mirror https://storage.flutter-io.cn
• Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, omit-legacy-version-file, enable-lldb-debugging
[✓] Windows Version (11 家庭版 中文版 64-bit, 25H2, 2009) [4.4s]
[✓] Android toolchain - develop for Android devices (Android SDK version 36.1.0) [9.0s]
• Android SDK at C:\Users\jsmbz\AppData\Local\Android\Sdk
• Emulator version 36.3.10.0 (build_id 14472402) (CL:N/A)
• Platform android-36, build-tools 36.1.0
• ANDROID_HOME = C:\Users\jsmbz\AppData\Local\Android\Sdk
• Java binary at: D:\devtools\anrdstod\jbr\bin\java
This is the JDK bundled with the latest Android Studio installation on this machine.
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
• Java version OpenJDK Runtime Environment (build 21.0.8+-14196175-b1038.72)
• All Android licenses accepted.
[✓] Chrome - develop for the web [11ms]
• Chrome at C:\Program Files\Google\Chrome\Application\chrome.exe
[✗] Visual Studio - develop Windows apps [8ms]
✗ Visual Studio not installed; this is necessary to develop Windows apps.
Download at https://visualstudio.microsoft.com/downloads/.
Please install the "Desktop development with C++" workload, including all of its default components
[✓] Connected device (7 available) [11.6s]
• 21091116AC (mobile) • 192.168.2.16:35367 • android-arm64 • Android 13 (API 33)
• PKB110 (mobile) • 192.168.2.39:42831 • android-arm64 • Android 16 (API 36)
• PKB110 (wireless) (mobile) • adb-4TRKWOEE4HR8AMEA-ti69Ed._adb-tls-connect._tcp • android-arm64 • Android 16 (API 36)
• 21091116AC (wireless) (mobile) • adb-QK4LCEO78TWOVKOB-v9ALzk._adb-tls-connect._tcp • android-arm64 • Android 13 (API 33)
• Windows (desktop) • windows • windows-x64 • Microsoft Windows [版本 10.0.26200.9168]
• Chrome (web) • chrome • web-javascript • Google Chrome 151.0.7922.138
• Edge (web) • edge • web-javascript • Microsoft Edge 151.0.4129.93
[✓] Network resources [939ms]
• All expected network resources are available.
! Doctor found issues in 1 category.
```

View File

@@ -1,5 +1,7 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
platform :ios, '13.0'
source 'https://github.com/volcengine/volcengine-specs.git'
source 'https://cdn.cocoapods.org/'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'

View File

@@ -1,7 +1,58 @@
PODS:
- agora_rtc_engine (6.5.3):
- AgoraIrisRTC_iOS (= 4.5.2-build.1)
- AgoraRtcEngine_iOS (= 4.5.2)
- Flutter
- AgoraInfra_iOS (1.2.13.1)
- AgoraIrisRTC_iOS (4.5.2-build.1)
- AgoraRtcEngine_iOS (4.5.2):
- AgoraRtcEngine_iOS/AIAEC (= 4.5.2)
- AgoraRtcEngine_iOS/AIAECLL (= 4.5.2)
- AgoraRtcEngine_iOS/AINS (= 4.5.2)
- AgoraRtcEngine_iOS/AINSLL (= 4.5.2)
- AgoraRtcEngine_iOS/AudioBeauty (= 4.5.2)
- AgoraRtcEngine_iOS/ClearVision (= 4.5.2)
- AgoraRtcEngine_iOS/ContentInspect (= 4.5.2)
- AgoraRtcEngine_iOS/FaceCapture (= 4.5.2)
- AgoraRtcEngine_iOS/FaceDetection (= 4.5.2)
- AgoraRtcEngine_iOS/LipSync (= 4.5.2)
- AgoraRtcEngine_iOS/ReplayKit (= 4.5.2)
- AgoraRtcEngine_iOS/RtcBasic (= 4.5.2)
- AgoraRtcEngine_iOS/SpatialAudio (= 4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecDec (= 4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (= 4.5.2)
- AgoraRtcEngine_iOS/VideoCodecDec (= 4.5.2)
- AgoraRtcEngine_iOS/VideoCodecEnc (= 4.5.2)
- AgoraRtcEngine_iOS/VirtualBackground (= 4.5.2)
- AgoraRtcEngine_iOS/VQA (= 4.5.2)
- AgoraRtcEngine_iOS/AIAEC (4.5.2)
- AgoraRtcEngine_iOS/AIAECLL (4.5.2)
- AgoraRtcEngine_iOS/AINS (4.5.2)
- AgoraRtcEngine_iOS/AINSLL (4.5.2)
- AgoraRtcEngine_iOS/AudioBeauty (4.5.2)
- AgoraRtcEngine_iOS/ClearVision (4.5.2)
- AgoraRtcEngine_iOS/ContentInspect (4.5.2)
- AgoraRtcEngine_iOS/FaceCapture (4.5.2)
- AgoraRtcEngine_iOS/FaceDetection (4.5.2)
- AgoraRtcEngine_iOS/LipSync (4.5.2)
- AgoraRtcEngine_iOS/ReplayKit (4.5.2)
- AgoraRtcEngine_iOS/RtcBasic (4.5.2):
- AgoraInfra_iOS (= 1.2.13.1)
- AgoraRtcEngine_iOS/SpatialAudio (4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecDec (4.5.2)
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (4.5.2)
- AgoraRtcEngine_iOS/VideoCodecDec (4.5.2)
- AgoraRtcEngine_iOS/VideoCodecEnc (4.5.2)
- AgoraRtcEngine_iOS/VirtualBackground (4.5.2)
- AgoraRtcEngine_iOS/VQA (4.5.2)
- connectivity_plus (0.0.1):
- Flutter
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_blue_plus_darwin (0.0.2):
- Flutter
- FlutterMacOS
- flutter_webrtc (1.4.0):
- Flutter
- WebRTC-SDK (= 144.7559.01)
@@ -44,6 +95,8 @@ PODS:
- GTMSessionFetcher/Core (2.3.0)
- image_picker_ios (0.0.1):
- Flutter
- iris_method_channel (0.0.1):
- Flutter
- isar_community_flutter_libs (1.0.0):
- Flutter
- MLImage (1.0.0-beta4)
@@ -72,6 +125,8 @@ PODS:
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- open_file_ios (1.0.3):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- permission_handler_apple (9.3.0):
@@ -89,29 +144,67 @@ PODS:
- Flutter
- vibration (3.0.0):
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- FlutterMacOS
- volc_engine_rtc (3.60.4):
- Flutter
- VolcApiEngine (= 1.7.0)
- VolcEngineRTC (= 3.60.103.2100)
- VolcApiEngine (1.7.0)
- VolcEngineRTC (3.60.103.2100):
- VolcEngineRTC/BMF (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSharpenExtension (= 3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSRExtension (= 3.60.103.2100)
- VolcEngineRTC/Core (= 3.60.103.2100)
- VolcEngineRTC/CVByteNN (= 3.60.103.2100)
- VolcEngineRTC/RealXBase (= 3.60.103.2100)
- VolcEngineRTC/RTCFFmpeg (= 3.60.103.2100)
- VolcEngineRTC/BMF (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSharpenExtension (3.60.103.2100)
- VolcEngineRTC/ByteRTCVideoSRExtension (3.60.103.2100)
- VolcEngineRTC/Core (3.60.103.2100)
- VolcEngineRTC/CVByteNN (3.60.103.2100)
- VolcEngineRTC/RealXBase (3.60.103.2100)
- VolcEngineRTC/RTCFFmpeg (3.60.103.2100)
- WebRTC-SDK (144.7559.01)
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- agora_rtc_engine (from `.symlinks/plugins/agora_rtc_engine/ios`)
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
- Flutter (from `Flutter`)
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- vibration (from `.symlinks/plugins/vibration/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
- volc_engine_rtc (from `.symlinks/plugins/volc_engine_rtc/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
https://github.com/volcengine/volcengine-specs.git:
- VolcApiEngine
- VolcEngineRTC
trunk:
- AgoraInfra_iOS
- AgoraIrisRTC_iOS
- AgoraRtcEngine_iOS
- GoogleDataTransport
- GoogleMLKit
- GoogleToolboxForMac
@@ -128,20 +221,30 @@ SPEC REPOS:
- WebRTC-SDK
EXTERNAL SOURCES:
agora_rtc_engine:
:path: ".symlinks/plugins/agora_rtc_engine/ios"
connectivity_plus:
:path: ".symlinks/plugins/connectivity_plus/ios"
device_info_plus:
:path: ".symlinks/plugins/device_info_plus/ios"
Flutter:
:path: Flutter
flutter_blue_plus_darwin:
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
flutter_webrtc:
:path: ".symlinks/plugins/flutter_webrtc/ios"
geolocator_apple:
:path: ".symlinks/plugins/geolocator_apple/darwin"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
iris_method_channel:
:path: ".symlinks/plugins/iris_method_channel/ios"
isar_community_flutter_libs:
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
mobile_scanner:
:path: ".symlinks/plugins/mobile_scanner/ios"
open_file_ios:
:path: ".symlinks/plugins/open_file_ios/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
permission_handler_apple:
@@ -154,12 +257,22 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
vibration:
:path: ".symlinks/plugins/vibration/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
volc_engine_rtc:
:path: ".symlinks/plugins/volc_engine_rtc/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
agora_rtc_engine: 0c7d50312967c4dc31c3c45e50589ce48f57e08a
AgoraInfra_iOS: 3691b2b277a1712a35ae96de25af319de0d73d08
AgoraIrisRTC_iOS: eab58c126439adf5ec99632828a558ea216860da
AgoraRtcEngine_iOS: 97e2398a2addda9057815a2a583a658e36796ff6
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_blue_plus_darwin: ee2377ea51a6c9e481f2ed76ec597717ef5d287d
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
@@ -169,6 +282,7 @@ SPEC CHECKSUMS:
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
iris_method_channel: 7d661cf3259b3009ae423508470dbeb9374446ee
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
@@ -176,6 +290,7 @@ SPEC CHECKSUMS:
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
mobile_scanner: b67191637a5ea7ba15652ca208069d2bae1900ab
nanopb: 438bc412db1928dac798aa6fd75726007be04262
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
@@ -184,9 +299,13 @@ SPEC CHECKSUMS:
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
volc_engine_rtc: 1bc697fba3c7a2f6513a18ec2abfb629188ce6ff
VolcApiEngine: e1f8b47ec5ef64bf6b48e3600be132c42643f8f0
VolcEngineRTC: 889b981b13621208b25942263f0fd5817c93b9bc
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
PODFILE CHECKSUM: 2da7455b7ebb4d9567497e4f758b13986b3bb736
COCOAPODS: 1.16.2

View File

@@ -494,14 +494,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -519,7 +519,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -537,7 +537,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -553,7 +553,7 @@
CURRENT_PROJECT_VERSION = 1;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
@@ -680,14 +680,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
@@ -706,14 +706,14 @@
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = R5KTW5WPS3;
DEVELOPMENT_TEAM = 93X3AN3P79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";

View File

@@ -66,5 +66,27 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<!-- 相机权限 -->
<key>NSCameraUsageDescription</key>
<string>需要使用相机拍照和录像</string>
<!-- 相册权限 -->
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以选择图片和视频</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要保存拍摄的照片和视频到相册</string>
<!-- 麦克风权限(录像需要) -->
<key>NSMicrophoneUsageDescription</key>
<string>录像时需要使用麦克风录制声音</string>
<!-- 允许 HTTP 连接(火山引擎视频流) -->
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- 蓝牙权限 -->
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接设备</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接设备</string>
</dict>
</plist>

View File

@@ -0,0 +1,947 @@
import 'dart:async';
import 'package:agora_rtc_engine/agora_rtc_engine.dart' as agora;
import 'package:flutter/material.dart';
/// 声网视频播放状态
enum AgoraPlayerState {
/// 空闲:未连接或已主动断开
idle,
/// 连接中:初始化引擎 / 加入频道 / 等待远端首帧
connecting,
/// 播放中:已收到远端首帧
playing,
/// 失败:参数缺失、加入频道失败、超时或 SDK 报错
error,
}
/// 通用声网(Agora RTC)视频展示组件
///
/// 只需要 appId / channelId / token 三个值即可拉流播放,组件内部自行完成
/// 引擎创建、加入频道、远端首帧监听、渲染与资源释放,调用方不用管生命周期。
///
/// 两种用法:
///
/// 1)直接当普通 Widget 嵌进布局:
/// ```dart
/// AgoraVideoPlayer(appId: 'x', channelId: 'y', token: 'z')
/// ```
///
/// 2)一行代码弹出(推荐,关闭弹窗即自动释放引擎):
/// ```dart
/// onTap: () => AgoraVideoPlayer.show(
/// context,
/// appId: 'x',
/// channelId: 'y',
/// token: 'z',
/// ),
/// ```
class AgoraVideoPlayer extends StatefulWidget {
/// 声网 App ID
final String appId;
/// 频道名(后端接口里的 channel / room_id)
final String channelId;
/// 频道 Token,未开启鉴权时可传空串
final String token;
/// 本端 uid,纯观看传 0 由 SDK 自动分配
final int uid;
/// 频道模式,默认直播模式
final agora.ChannelProfileType channelProfile;
/// 本端角色,默认观众(只拉流不推流,不占用相机麦克风)
final agora.ClientRoleType clientRole;
/// 画面填充模式
final agora.RenderModeType renderMode;
/// 是否订阅音频,默认只订阅视频
final bool subscribeAudio;
/// Android 使用 SurfaceView 渲染(出现黑屏 / 层级遮挡时可切 true)
final bool useAndroidSurfaceView;
/// iOS、桌面端使用 Flutter Texture 渲染
final bool useFlutterTexture;
/// 是否在 initState 自动连接
final bool autoConnect;
/// 连接超时:超过该时长仍未收到远端首帧判定为失败
final Duration connectTimeout;
/// 状态变化回调
final ValueChanged<AgoraPlayerState>? onStateChanged;
/// 首帧解码成功回调,参数为远端 uid
final ValueChanged<int>? onFirstFrame;
/// 错误回调
final ValueChanged<String>? onError;
/// 视频区背景色
final Color backgroundColor;
/// 正在建连时的提示文案
final String loadingText;
/// 已进房但未收到画面时的提示文案
final String waitingText;
/// 失败时是否显示内置重试按钮
final bool showRetryButton;
/// 自定义失败态 UI,返回 null 则使用内置样式
final Widget Function(BuildContext context, String message)? errorBuilder;
/// 重连令牌:值变化即强制重新连接,即使 appId / channelId / token 完全相同
/// (供「重新加载」按钮使用)
final int reloadToken;
const AgoraVideoPlayer({
super.key,
required this.appId,
required this.channelId,
required this.token,
this.uid = 0,
this.channelProfile =
agora.ChannelProfileType.channelProfileLiveBroadcasting,
this.clientRole = agora.ClientRoleType.clientRoleAudience,
this.renderMode = agora.RenderModeType.renderModeFit,
this.subscribeAudio = false,
this.useAndroidSurfaceView = false,
this.useFlutterTexture = false,
this.autoConnect = true,
this.connectTimeout = const Duration(seconds: 15),
this.onStateChanged,
this.onFirstFrame,
this.onError,
this.backgroundColor = Colors.black,
this.loadingText = '视频加载中...',
this.waitingText = '等待视频流...',
this.showRetryButton = true,
this.errorBuilder,
this.reloadToken = 0,
});
/// 从后端返回的 `app_id=xx&channel=yy&token=zz&user_id=0` 形式参数串构建
factory AgoraVideoPlayer.fromUrl({
Key? key,
required String url,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
Duration connectTimeout = const Duration(seconds: 15),
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
final p = parseParams(url);
return AgoraVideoPlayer(
key: key,
appId: p['app_id'] ?? p['appid'] ?? '',
channelId:
p['channel'] ?? p['channel_id'] ?? p['room_id'] ?? p['roomid'] ?? '',
token: p['token'] ?? '',
uid: int.tryParse(p['user_id'] ?? p['uid'] ?? '') ?? 0,
renderMode: renderMode,
connectTimeout: connectTimeout,
onStateChanged: onStateChanged,
);
}
/// 解析 `k=v&k=v` 参数串
static Map<String, String> parseParams(String url) {
final params = <String, String>{};
for (final pair in url.split('&')) {
final kv = pair.split('=');
if (kv.length == 2) {
params[kv[0].trim()] = Uri.decodeComponent(kv[1]);
}
}
return params;
}
/// 【核心入口】一行代码弹出居中视频弹窗(16:9)
///
/// 关闭弹窗时组件被移出 widget 树,自动触发 dispose →
/// leaveChannel + release,调用方不需要手动清理任何东西。
static Future<void> show(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
String title = '实时视频',
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
bool barrierDismissible = true,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (dialogContext) => Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(
Icons.close,
color: Colors.white70,
size: 20,
),
onPressed: () => Navigator.pop(dialogContext),
),
],
),
),
AspectRatio(
aspectRatio: 16 / 9,
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
),
);
}
/// 以底部弹层形式弹出(适合列表页预览,不遮挡整屏)
static Future<void> showBottomSheet(
BuildContext context, {
required String appId,
required String channelId,
required String token,
int uid = 0,
double heightFactor = 0.42,
agora.RenderModeType renderMode = agora.RenderModeType.renderModeFit,
ValueChanged<AgoraPlayerState>? onStateChanged,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.black,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (sheetContext) => SizedBox(
height: MediaQuery.of(sheetContext).size.height * heightFactor,
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(2),
),
),
Expanded(
child: AgoraVideoPlayer(
appId: appId,
channelId: channelId,
token: token,
uid: uid,
renderMode: renderMode,
onStateChanged: onStateChanged,
),
),
],
),
),
);
}
/// 【手动参数入口】弹出带 App ID / Channel / Token 三个输入框的弹窗
///
/// 填完点「重新加载」开始拉流;即使参数一模一样也会强制重连。
/// 适用于接口未就绪、需要手工验证频道参数的场景。
/// 关闭弹窗同样会自动释放引擎。
static Future<void> showManualInput(
BuildContext context, {
String title = '实时视频',
String initialAppId = '',
String initialChannelId = '',
String initialToken = '',
bool barrierDismissible = true,
}) {
return showDialog<void>(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: Colors.black87,
builder: (_) => _AgoraManualParamsDialog(
title: title,
initialAppId: initialAppId,
initialChannelId: initialChannelId,
initialToken: initialToken,
),
);
}
@override
State<AgoraVideoPlayer> createState() => _AgoraVideoPlayerState();
}
class _AgoraVideoPlayerState extends State<AgoraVideoPlayer> {
agora.RtcEngine? _engine;
agora.RtcEngineEventHandler? _handler;
AgoraPlayerState _state = AgoraPlayerState.idle;
String? _errorMessage;
int? _remoteUid;
bool _isJoined = false;
bool _isDisposed = false;
bool _firstFrameNotified = false;
Timer? _connectTimer;
/// 连接序号:参数快速变化时用于丢弃过期的异步流程
int _connectSeq = 0;
@override
void initState() {
super.initState();
if (widget.autoConnect) {
_connect();
}
}
@override
void didUpdateWidget(covariant AgoraVideoPlayer oldWidget) {
super.didUpdateWidget(oldWidget);
final changed =
oldWidget.appId != widget.appId ||
oldWidget.channelId != widget.channelId ||
oldWidget.token != widget.token ||
oldWidget.uid != widget.uid ||
oldWidget.reloadToken != widget.reloadToken;
if (changed) {
_log('参数变化,重新连接: ${widget.channelId}');
_connect();
}
}
@override
void dispose() {
_isDisposed = true;
_cancelConnectTimer();
_release();
super.dispose();
}
void _log(String msg) => debugPrint('🎥 [Agora] $msg');
void _updateState(AgoraPlayerState next, {String? message}) {
if (_state == next && message == _errorMessage) return;
_state = next;
_errorMessage = message;
if (next == AgoraPlayerState.playing) {
_cancelConnectTimer();
}
if (mounted) {
setState(() {});
}
widget.onStateChanged?.call(next);
if (next == AgoraPlayerState.error && message != null) {
widget.onError?.call(message);
}
}
void _startConnectTimer() {
_cancelConnectTimer();
_connectTimer = Timer(widget.connectTimeout, () {
if (_isDisposed || _state == AgoraPlayerState.playing) return;
_log('⏰ 连接超时(${widget.connectTimeout.inSeconds}s 未收到首帧)');
_updateState(AgoraPlayerState.error, message: '视频连接超时,请重试');
});
}
void _cancelConnectTimer() {
_connectTimer?.cancel();
_connectTimer = null;
}
/// 建立连接:先释放旧引擎,再创建 → 初始化 → 注册回调 → 加入频道
Future<void> _connect() async {
final seq = ++_connectSeq;
_cancelConnectTimer();
await _release();
if (_isDisposed || seq != _connectSeq) return;
if (widget.appId.isEmpty || widget.channelId.isEmpty) {
_updateState(AgoraPlayerState.error, message: 'appId / channelId 不能为空');
return;
}
_remoteUid = null;
_isJoined = false;
_firstFrameNotified = false;
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
try {
final engine = agora.createAgoraRtcEngine();
_engine = engine;
await engine.initialize(
agora.RtcEngineContext(
appId: widget.appId,
channelProfile: widget.channelProfile,
),
);
if (_isDisposed || seq != _connectSeq) return;
await engine.enableVideo();
if (_isDisposed || seq != _connectSeq) return;
_handler = _buildEventHandler();
engine.registerEventHandler(_handler!);
_log('加入频道: ${widget.channelId}');
await engine.joinChannel(
token: widget.token,
channelId: widget.channelId,
uid: widget.uid,
options: agora.ChannelMediaOptions(
channelProfile: widget.channelProfile,
clientRoleType: widget.clientRole,
autoSubscribeVideo: true,
autoSubscribeAudio: widget.subscribeAudio,
),
);
if (_isDisposed || seq != _connectSeq) return;
_log('✅ joinChannel 调用成功,等待远端首帧...');
} catch (e) {
_log('❌ 初始化失败: $e');
_updateState(AgoraPlayerState.error, message: '视频连接失败: $e');
}
}
agora.RtcEngineEventHandler _buildEventHandler() {
return agora.RtcEngineEventHandler(
onJoinChannelSuccess: (agora.RtcConnection connection, int elapsed) {
_log('✅ 加入频道成功: ${connection.channelId}, 耗时 ${elapsed}ms');
if (!mounted) return;
setState(() => _isJoined = true);
},
onUserJoined: (agora.RtcConnection connection, int rUid, int elapsed) {
_log('👤 远端用户加入: uid=$rUid');
if (!mounted) return;
// VideoViewController.remote 内部断言 uid != 0,uid 为 0 时构建会崩
if (rUid == 0) {
_log('⚠️ 远端 uid=0,无法用 remote View 渲染,已忽略');
return;
}
setState(() => _remoteUid ??= rUid);
},
onUserOffline: (
agora.RtcConnection connection,
int rUid,
agora.UserOfflineReasonType reason,
) {
_log('👋 远端用户离开: uid=$rUid, reason=$reason');
if (!mounted || _remoteUid != rUid) return;
setState(() => _remoteUid = null);
// 推流端掉线,回到等待态并重新计时
_updateState(AgoraPlayerState.connecting);
_startConnectTimer();
},
onRemoteVideoStateChanged: (
agora.RtcConnection connection,
int rUid,
agora.RemoteVideoState state,
agora.RemoteVideoStateReason reason,
int elapsed,
) {
if (!mounted) return;
_log('📹 远端视频状态: uid=$rUid, state=$state, reason=$reason');
if (state == agora.RemoteVideoState.remoteVideoStateDecoding) {
if (rUid != 0 && _remoteUid == null) {
setState(() => _remoteUid = rUid);
}
_updateState(AgoraPlayerState.playing);
if (!_firstFrameNotified) {
_firstFrameNotified = true;
widget.onFirstFrame?.call(rUid);
}
} else if (state == agora.RemoteVideoState.remoteVideoStateFrozen) {
_updateState(AgoraPlayerState.connecting);
} else if (state == agora.RemoteVideoState.remoteVideoStateFailed) {
_updateState(AgoraPlayerState.error, message: '远端视频流异常: $reason');
}
},
onLeaveChannel: (agora.RtcConnection connection, agora.RtcStats stats) {
_log('已离开频道: ${connection.channelId}');
if (!mounted) return;
setState(() {
_isJoined = false;
_remoteUid = null;
});
},
onError: (agora.ErrorCodeType err, String msg) {
_log('❌ SDK 错误: $err - $msg');
_updateState(AgoraPlayerState.error, message: '声网错误($err): $msg');
},
);
}
Future<void> _release() async {
final engine = _engine;
final handler = _handler;
_engine = null;
_handler = null;
if (engine == null) return;
try {
if (handler != null) {
engine.unregisterEventHandler(handler);
}
await engine.leaveChannel();
await engine.release();
_log('🗑️ 引擎已释放');
} catch (e) {
_log('⚠️ 释放引擎失败: $e');
}
}
@override
Widget build(BuildContext context) {
// 关键:远端 uid 未知时绝不能构建 remote View(SDK 内部有 uid != 0 断言)
final canRender = _engine != null && _isJoined && _remoteUid != null;
return Container(
color: widget.backgroundColor,
child: Stack(
fit: StackFit.expand,
children: [
if (canRender) _buildVideoView(),
if (_state != AgoraPlayerState.playing) _buildOverlay(canRender),
],
),
);
}
Widget _buildVideoView() {
return agora.AgoraVideoView(
controller: agora.VideoViewController.remote(
rtcEngine: _engine!,
canvas: agora.VideoCanvas(
uid: _remoteUid!,
renderMode: widget.renderMode,
),
connection: agora.RtcConnection(channelId: widget.channelId),
useAndroidSurfaceView: widget.useAndroidSurfaceView,
useFlutterTexture: widget.useFlutterTexture,
),
);
}
Widget _buildOverlay(bool canRender) {
if (_state == AgoraPlayerState.error) {
final message = _errorMessage ?? '视频加载失败';
if (widget.errorBuilder != null) {
return widget.errorBuilder!(context, message);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 48, color: Colors.redAccent),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
message,
style: const TextStyle(color: Colors.white70, fontSize: 13),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
if (widget.showRetryButton) ...[
const SizedBox(height: 16),
ElevatedButton(
onPressed: _connect,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
),
child: const Text('重试'),
),
],
],
),
);
}
final text = canRender ? widget.waitingText : widget.loadingText;
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
),
const SizedBox(height: 12),
Text(text, style: const TextStyle(color: Colors.white70, fontSize: 13)),
],
),
);
}
}
/// 手动填写声网参数的弹窗,由 [AgoraVideoPlayer.showManualInput] 弹出
///
/// 结构:标题栏 + 三个输入框 + 「重新加载」按钮 + 16:9 视频区。
/// 点「重新加载」会递增 reloadToken,即使参数未变也会强制断开重连。
class _AgoraManualParamsDialog extends StatefulWidget {
final String title;
final String initialAppId;
final String initialChannelId;
final String initialToken;
const _AgoraManualParamsDialog({
required this.title,
this.initialAppId = '',
this.initialChannelId = '',
this.initialToken = '',
});
@override
State<_AgoraManualParamsDialog> createState() =>
_AgoraManualParamsDialogState();
}
class _AgoraManualParamsDialogState extends State<_AgoraManualParamsDialog> {
static const Color _primary = Color(0xFF165DFF);
static const Color _danger = Color(0xFFF53F3F);
static const Color _success = Color(0xFF00B42A);
late final TextEditingController _appIdCtrl = TextEditingController(
text: widget.initialAppId,
);
late final TextEditingController _channelCtrl = TextEditingController(
text: widget.initialChannelId,
);
late final TextEditingController _tokenCtrl = TextEditingController(
text: widget.initialToken,
);
/// 已生效的参数(点「重新加载」才同步,输入过程中不触发重连)
String _appId = '';
String _channelId = '';
String _token = '';
/// 递增即强制重连
int _reloadToken = 0;
/// 是否已发起过加载(未发起时视频区只放占位文案)
bool _started = false;
String? _hint;
AgoraPlayerState _state = AgoraPlayerState.idle;
@override
void initState() {
super.initState();
_appId = widget.initialAppId.trim();
_channelId = widget.initialChannelId.trim();
_token = widget.initialToken.trim();
// 调用方已经把参数给齐时直接开播,不用手点
_started = _appId.isNotEmpty && _channelId.isNotEmpty;
}
@override
void dispose() {
_appIdCtrl.dispose();
_channelCtrl.dispose();
_tokenCtrl.dispose();
super.dispose();
}
void _reload() {
FocusScope.of(context).unfocus();
final appId = _appIdCtrl.text.trim();
final channelId = _channelCtrl.text.trim();
if (appId.isEmpty || channelId.isEmpty) {
setState(() {
_hint = 'App ID 与 Channel 不能为空';
_started = false;
});
return;
}
setState(() {
_appId = appId;
_channelId = channelId;
_token = _tokenCtrl.text.trim();
_reloadToken++;
_started = true;
_hint = null;
});
debugPrint('🎥 [Agora] 手动重新加载: channel=$_channelId, seq=$_reloadToken');
}
String get _stateText {
switch (_state) {
case AgoraPlayerState.idle:
return '未连接';
case AgoraPlayerState.connecting:
return '连接中…';
case AgoraPlayerState.playing:
return '播放中';
case AgoraPlayerState.error:
return '连接失败';
}
}
Color get _stateColor {
switch (_state) {
case AgoraPlayerState.idle:
return Colors.white38;
case AgoraPlayerState.connecting:
return Colors.amberAccent;
case AgoraPlayerState.playing:
return _success;
case AgoraPlayerState.error:
return _danger;
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.black,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeader(),
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildInput(
controller: _appIdCtrl,
label: 'App ID',
hint: '声网控制台的应用 ID',
),
_buildInput(
controller: _channelCtrl,
label: 'Channel',
hint: '频道名 / room_id',
),
_buildInput(
controller: _tokenCtrl,
label: 'Token',
hint: '未开启鉴权可留空',
maxLines: 2,
),
if (_hint != null) ...[
const SizedBox(height: 2),
Text(
_hint!,
style: const TextStyle(color: _danger, fontSize: 12),
),
],
const SizedBox(height: 10),
ElevatedButton.icon(
onPressed: _reload,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重新加载'),
style: ElevatedButton.styleFrom(
backgroundColor: _primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
const SizedBox(height: 10),
_buildStatusBar(),
],
),
),
AspectRatio(aspectRatio: 16 / 9, child: _buildVideoArea()),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _buildHeader() {
return Container(
height: 48,
padding: const EdgeInsets.only(left: 16),
color: const Color(0xFF2A2E34),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: const TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
onPressed: () => Navigator.pop(context),
),
],
),
);
}
Widget _buildStatusBar() {
return Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: _stateColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
'状态:$_stateText',
style: TextStyle(color: _stateColor, fontSize: 12),
),
const SizedBox(width: 12),
Flexible(
child: Text(
_started ? 'channel: $_channelId' : '未发起连接',
style: const TextStyle(color: Colors.white24, fontSize: 11),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildVideoArea() {
if (!_started) {
return Container(
color: Colors.black,
alignment: Alignment.center,
padding: const EdgeInsets.symmetric(horizontal: 24),
child: const Text(
'填写 App ID / Channel 后点「重新加载」',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white38, fontSize: 13),
),
);
}
return AgoraVideoPlayer(
appId: _appId,
channelId: _channelId,
token: _token,
reloadToken: _reloadToken,
onStateChanged: (s) {
if (!mounted) return;
setState(() => _state = s);
},
);
}
Widget _buildInput({
required TextEditingController controller,
required String label,
String? hint,
int maxLines = 1,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: controller,
maxLines: maxLines,
style: const TextStyle(color: Colors.white, fontSize: 13),
cursorColor: _primary,
decoration: InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: Colors.white54, fontSize: 13),
hintText: hint,
hintStyle: const TextStyle(color: Colors.white24, fontSize: 12),
isDense: true,
filled: true,
fillColor: const Color(0xFF1A1D21),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.white12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: _primary),
),
),
),
);
}
}

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
/// 胶囊样式全局 Toast:体积小、大圆角、清爽,数秒后自动消失。
/// 通过根 Overlay 展示,不依赖调用方 BuildContext 和 Navigator,
/// 适用于页面销毁(dispose)后仍需弹出提示的场景。
class CapsuleToast {
static OverlayEntry? _entry;
static bool _showing = false;
/// 显示胶囊提示
/// [message] 提示文案
/// [duration] 显示时长,默认 2 秒
/// [showCheck] 是否显示对勾图标
static void show(
String message, {
Duration duration = const Duration(seconds: 2),
bool showCheck = true,
}) {
// 从根 Element 向下遍历子树,查找第一个 OverlayState
// (Overlay 是根的子节点,不能用 findAncestorStateOfType 向上找),
// 无需依赖调用方 context,页面销毁后也能正常弹出。
final root = WidgetsBinding.instance.rootElement;
if (root == null) return;
final overlay = _findOverlayState(root);
if (overlay == null) {
debugPrint('💊 [CapsuleToast] 未找到 Overlay,无法弹出: $message');
return;
}
debugPrint('💊 [CapsuleToast] 弹出: $message');
// 延到下一帧再插入:页面退出(dispose)瞬间 Overlay 可能正在重建,
// 直接插入可能随旧页面一起被销毁。
WidgetsBinding.instance.addPostFrameCallback((_) {
_insertEntry(message, overlay, duration: duration, showCheck: showCheck);
});
}
/// 真正插入 OverlayEntry
static void _insertEntry(
String message,
OverlayState overlay, {
required Duration duration,
required bool showCheck,
}) {
if (!overlay.mounted) return;
// 有新的提示时先移除旧的
if (_showing) {
_entry?.remove();
_showing = false;
}
_entry = OverlayEntry(
builder: (context) => IgnorePointer(
child: SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Container(
margin: const EdgeInsets.only(top: 64),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE62C2C2C),
borderRadius: BorderRadius.circular(100),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showCheck) ...[
const Icon(
Icons.check_circle,
size: 14,
color: Color(0xFF4CD964),
),
const SizedBox(width: 6),
],
Text(
message,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
);
overlay.insert(_entry!);
_showing = true;
Future.delayed(duration, () {
if (_showing) {
_entry?.remove();
_showing = false;
}
});
}
/// 从指定 Element 开始深度优先遍历子树,找到第一个 OverlayState
static OverlayState? _findOverlayState(Element root) {
OverlayState? result;
void visitor(Element element) {
if (result != null) return;
if (element is StatefulElement && element.state is OverlayState) {
result = element.state as OverlayState;
return;
}
element.visitChildElements(visitor);
}
root.visitChildElements(visitor);
return result;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/network/tcp/tcp_status_cubit.dart';
class TcpStatusIndicator extends StatefulWidget {
final double size;
final bool showDisconnected; // 是否显示未连接状态
final VoidCallback? onTap; // 点击回调
const TcpStatusIndicator({
super.key,
this.size = 12.0,
this.showDisconnected = false, // 默认不显示未连接状态
this.onTap, // 点击回调
});
@override
State<TcpStatusIndicator> createState() => _TcpStatusIndicatorState();
}
class _TcpStatusIndicatorState extends State<TcpStatusIndicator>
with SingleTickerProviderStateMixin {
late TcpStatusCubit _tcpStatusCubit;
bool _hasActivity = false;
AnimationController? _controller;
Animation<double>? _animation;
@override
void initState() {
super.initState();
_tcpStatusCubit = GetIt.I<TcpStatusCubit>();
_controller = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_animation = Tween<double>(
begin: 0.6,
end: 1.0,
).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeInOut));
_controller!.repeat(reverse: true);
_tcpStatusCubit.stream.listen((state) {
if (state.status != TcpConnectionStatus.disconnected) {
_hasActivity = true;
}
if (mounted) {
setState(() {});
}
});
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = _tcpStatusCubit.state;
if (!widget.showDisconnected &&
!_hasActivity &&
state.status == TcpConnectionStatus.disconnected) {
return const SizedBox.shrink();
}
Color color;
String tooltip;
bool shouldAnimate;
switch (state.status) {
case TcpConnectionStatus.connected:
color = Colors.green;
tooltip = 'TCP已连接';
shouldAnimate = false;
break;
case TcpConnectionStatus.connecting:
color = Colors.yellow;
tooltip = 'TCP连接中...';
shouldAnimate = true;
break;
case TcpConnectionStatus.error:
color = Colors.red;
tooltip = state.errorMessage ?? 'TCP连接错误';
shouldAnimate = false;
break;
case TcpConnectionStatus.disconnected:
default:
color = Colors.grey;
tooltip = 'TCP未连接';
shouldAnimate = false;
break;
}
return Tooltip(
message: tooltip,
child: InkWell(
onTap: widget.onTap,
borderRadius: BorderRadius.circular(widget.size / 2),
child: AnimatedBuilder(
animation: _animation!,
builder: (context, child) {
final opacity = shouldAnimate ? _animation!.value : 1.0;
return Container(
width: widget.size,
height: widget.size,
decoration: BoxDecoration(
color: color.withOpacity(opacity),
shape: BoxShape.circle,
boxShadow: state.status == TcpConnectionStatus.connected
? [
BoxShadow(
color: Colors.green.withOpacity(0.5 * opacity),
blurRadius: 6 * opacity,
spreadRadius: 2 * opacity,
),
]
: [],
),
);
},
),
),
);
}
}

View File

@@ -7,7 +7,9 @@ class AppUserCubit extends Cubit<AppUserState> {
AppUserCubit() : super(const AppUserState());
void setAuth(UserEntity user) {
print('✅ [AppUserCubit] setAuth 被调用,用户: ${user.username}, roleKey=${user.roleKey}, orgId=${user.orgId}, siteId=${user.siteId}');
emit(state.copyWith(user));
print('✅ [AppUserCubit] 状态已更新,当前用户: ${state.user?.username}, roleKey=${state.user?.roleKey}');
}
void clearAuth() {

View File

@@ -0,0 +1,521 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io' show Platform;
import 'dart:typed_data';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:permission_handler/permission_handler.dart';
import 'bytes_util.dart';
import 'protocol_parser.dart';
class BleManager {
static final BleManager instance = BleManager._internal();
BleManager._internal();
BluetoothDevice? _connectedDevice;
BluetoothDevice? _connectingDevice;
BluetoothCharacteristic? _writeCharacteristic;
BluetoothCharacteristic? _readCharacteristic;
/// 🔥 已连接设备的名称(连接时从扫描结果或设备属性保存,断开时清空)
String? _connectedDeviceName;
/// 有状态的协议解析器(支持 BLE 分片)
final ProtocolParser _parser = ProtocolParser();
final Map<DeviceIdentifier, ScanResult> _scanResults = {};
final StreamController<List<ScanResult>> _scanController =
StreamController.broadcast();
final StreamController<BlePacket> _packetController =
StreamController.broadcast();
final StreamController<BluetoothDevice?> _connectionController =
StreamController.broadcast();
final StreamController<BluetoothDevice?> _connectingController =
StreamController.broadcast();
StreamSubscription<List<ScanResult>>? _scanSubscription;
StreamSubscription<List<int>>? _readSubscription;
StreamSubscription<BluetoothAdapterState>? _adapterStateSubscription;
StreamSubscription<BluetoothConnectionState>? _deviceConnectionSubscription;
StreamSubscription<int>? _mtuSubscription;
/// 防止异步竞态:stopScan() 后 in-flight 的 startScan() 不应生效
int _scanGen = 0;
/// 协商后的 MTU 值,用于分片写入
int _negotiatedMtu = 23;
/// 已收到的数据包存储(跨页面持久化)
final List<BlePacket> receivedPacketStore = [];
static const int _maxStoredPackets = 100;
void clearReceivedPackets() {
receivedPacketStore.clear();
}
bool get isConnected => _connectedDevice != null;
BluetoothDevice? get connectedDevice => _connectedDevice;
BluetoothDevice? get connectingDevice => _connectingDevice;
/// 🔥 获取已连接设备的名称(连接时缓存,断开后为 null)
String? get connectedDeviceName => _connectedDeviceName;
Stream<List<ScanResult>> get scanResults => _scanController.stream;
Stream<BlePacket> get packetStream => _packetController.stream;
Stream<BluetoothAdapterState> get adapterState =>
FlutterBluePlus.adapterState;
/// 🔥 获取当前蓝牙扫描结果列表(供外部查询使用)
List<ScanResult> getScanResults() {
return _scanResults.values.toList();
}
/// 连接状态变化流:连接成功时发出 device,断开时发出 null
Stream<BluetoothDevice?> get connectionStream => _connectionController.stream;
/// 连接中状态流:开始连接时发出 device,连接完成/失败时发出 null
Stream<BluetoothDevice?> get connectingStream => _connectingController.stream;
Future<bool> checkBluetooth() async {
final state = await FlutterBluePlus.adapterState.first;
return state == BluetoothAdapterState.on;
}
Future<bool> requestPermissions() async {
if (Platform.isAndroid) {
final connStatus = await Permission.bluetoothConnect.status;
developer.log(
'[BLE] bluetoothConnect status: $connStatus',
name: 'BleManager',
);
if (connStatus.isDenied) {
await Permission.bluetoothConnect.request();
}
final scanStatus = await Permission.bluetoothScan.status;
developer.log(
'[BLE] bluetoothScan status: $scanStatus',
name: 'BleManager',
);
if (scanStatus.isDenied) {
await Permission.bluetoothScan.request();
}
final locStatus = await Permission.locationWhenInUse.status;
developer.log(
'[BLE] locationWhenInUse status: $locStatus',
name: 'BleManager',
);
if (locStatus.isDenied) {
await Permission.locationWhenInUse.request();
}
final allGranted =
await Permission.bluetoothConnect.isGranted &&
await Permission.bluetoothScan.isGranted;
developer.log(
'[BLE] all permissions granted: $allGranted',
name: 'BleManager',
);
return allGranted;
}
// iOS: 蓝牙权限由 Info.plist 声明,系统弹窗授权,无需主动请求
developer.log('[BLE] iOS platform, skipping Android permissions', name: 'BleManager');
return true;
}
Future<void> openBluetooth() async {
try {
await FlutterBluePlus.turnOn();
} catch (e) {
await _goToSystemSettings();
}
}
Future<void> _goToSystemSettings() async {
await openAppSettings();
}
Future<void> _openLocationSettings() async {
await openAppSettings();
}
Future<bool> _checkLocationService() async {
final locStatus = await Permission.location.serviceStatus;
developer.log(
'[BLE] location service status: $locStatus',
name: 'BleManager',
);
return locStatus == ServiceStatus.enabled;
}
Future<void> startScan({bool continuous = true}) async {
final int myGen = ++_scanGen;
developer.log(
'[BLE] startScan called, gen=$myGen, continuous=$continuous',
name: 'BleManager',
);
// 先停止任何残留扫描,确保干净启动
try { await FlutterBluePlus.stopScan(); } catch (_) {}
_scanResults.clear();
_scanController.add([]);
final isOn = await checkBluetooth();
if (myGen != _scanGen) return;
developer.log('[BLE] Bluetooth adapter on: $isOn', name: 'BleManager');
if (!isOn) {
developer.log(
'[BLE] Bluetooth is OFF, aborting scan',
name: 'BleManager',
);
return;
}
final granted = await requestPermissions();
if (myGen != _scanGen) return;
developer.log('[BLE] Permissions granted: $granted', name: 'BleManager');
if (!granted) {
developer.log(
'[BLE] Permissions not granted, aborting scan',
name: 'BleManager',
);
return;
}
if (Platform.isAndroid) {
final locEnabled = await _checkLocationService();
if (myGen != _scanGen) return;
if (!locEnabled) {
developer.log(
'[BLE] Location service OFF, directing to settings',
name: 'BleManager',
);
await _openLocationSettings();
return;
}
}
_scanSubscription?.cancel();
_scanSubscription = FlutterBluePlus.scanResults.listen(
(results) {
for (final result in results) {
_scanResults[result.device.remoteId] = result;
}
developer.log(
'[BLE] 📡 扫描到 ${results.length} 个设备: ${results.map((r) => '${r.device.platformName.isNotEmpty ? r.device.platformName : r.device.advName.isNotEmpty ? r.device.advName : r.device.remoteId} (${r.rssi}dBm)').join(', ')}',
name: 'BleManager',
);
_scanController.add(_scanResults.values.toList());
},
onError: (e) {
developer.log('[BLE] scanResults error: $e', name: 'BleManager');
},
);
try {
if (continuous) {
developer.log('[BLE] Starting continuous scan...', name: 'BleManager');
await FlutterBluePlus.startScan(
timeout: const Duration(days: 1),
androidUsesFineLocation: true,
androidScanMode: AndroidScanMode.lowLatency,
androidLegacy: true,
);
} else {
developer.log('[BLE] Starting 10s scan...', name: 'BleManager');
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 10),
androidUsesFineLocation: true,
androidScanMode: AndroidScanMode.lowLatency,
androidLegacy: true,
);
}
developer.log('[BLE] startScan completed', name: 'BleManager');
} catch (e, stackTrace) {
developer.log(
'[BLE] startScan error: $e\n$stackTrace',
name: 'BleManager',
);
}
}
Future<void> refreshScan() async {
await stopScan();
await Future.delayed(const Duration(milliseconds: 200));
await startScan(continuous: true);
}
Future<void> stopScan() async {
_scanGen++;
developer.log('[BLE] stopScan, gen=$_scanGen', name: 'BleManager');
_scanSubscription?.cancel();
await FlutterBluePlus.stopScan();
}
/// 连接设备,返回 null 表示成功,返回错误信息字符串表示失败
Future<String?> connect(BluetoothDevice device) async {
// 🔥 连接前先保存设备名(从扫描结果或设备属性中获取)
final scanResult = _scanResults[device.remoteId];
final advName = scanResult?.device.advName ?? '';
final platformName = device.platformName;
_connectedDeviceName = platformName.isNotEmpty
? platformName
: (advName.isNotEmpty ? advName : device.remoteId.toString());
developer.log(
'[BLE] 🔥 保存已连接设备名: $_connectedDeviceName',
name: 'BleManager',
);
// 标记正在连接,通知所有监听者
_connectingDevice = device;
_connectingController.add(device);
try {
await device
.connect(license: License.nonprofit, mtu: 512)
.timeout(const Duration(seconds: 15));
_connectedDevice = device;
// 连接成功,立即停止扫描(避免扫描射频干扰导致连接断开)
await stopScan();
// 清除连接中状态
_connectingDevice = null;
_connectingController.add(null);
// 监听设备连接状态,任何一方断开都能感知
_deviceConnectionSubscription?.cancel();
_deviceConnectionSubscription = device.connectionState.listen((state) {
developer.log(
'[BLE] device connectionState: $state',
name: 'BleManager',
);
if (state == BluetoothConnectionState.disconnected) {
_onDeviceDisconnected();
}
});
_connectionController.add(device);
await _discoverServices(device);
// 确认协商后的 MTU(Android 主动请求;iOS 由 CoreBluetooth 自动协商)
await _setupMtu(device);
return null; // 成功
} catch (e) {
// 连接失败,清除连接中状态
_connectingDevice = null;
_connectingController.add(null);
// 返回具体的错误原因
if (e is TimeoutException) {
return '连接超时(15秒),请确认设备在附近且已开启';
}
return '连接失败: $e';
}
}
/// 平台差异化的 MTU 协商
/// - Android: 主动 requestMtu(512),一般协商到 247/517
/// - iOS: CoreBluetooth 自动协商,requestMtu 会抛异常;读 mtuNow 并订阅 mtu 流
Future<void> _setupMtu(BluetoothDevice device) async {
if (Platform.isAndroid) {
try {
final mtu = await device.requestMtu(512);
_negotiatedMtu = mtu;
developer.log('[BLE] Android MTU negotiated: $mtu', name: 'BleManager');
} catch (e) {
developer.log('[BLE] requestMtu failed: $e', name: 'BleManager');
}
return;
}
if (Platform.isIOS) {
// iOS 上 connect 完成后系统会异步协商 MTU,稍等一拍再读取实际值
try {
await Future.delayed(const Duration(milliseconds: 300));
final mtu = device.mtuNow;
// iOS 保底 185(现代 iPhone CoreBluetooth 常见协商值),避免默认 23 把长帧切成 9 片
_negotiatedMtu = mtu > 23 ? mtu : 185;
developer.log(
'[BLE] iOS MTU (auto-negotiated): mtuNow=$mtu, using=$_negotiatedMtu',
name: 'BleManager',
);
// 订阅后续 MTU 变化(部分外设会在服务发现后再次协商)
_mtuSubscription?.cancel();
_mtuSubscription = device.mtu.listen((m) {
if (m > 23 && m != _negotiatedMtu) {
_negotiatedMtu = m;
developer.log('[BLE] iOS MTU updated: $m', name: 'BleManager');
}
});
} catch (e) {
_negotiatedMtu = 185;
developer.log(
'[BLE] iOS MTU setup failed, fallback to 185: $e',
name: 'BleManager',
);
}
}
}
void _onDeviceDisconnected() {
_connectedDevice = null;
_connectingDevice = null;
_connectedDeviceName = null; // 🔥 清空已连接设备名
_writeCharacteristic = null;
_readCharacteristic = null;
_negotiatedMtu = 23;
_readSubscription?.cancel();
_deviceConnectionSubscription?.cancel();
_mtuSubscription?.cancel();
_connectionController.add(null);
_connectingController.add(null);
// 清空协议解析器缓冲区
_parser.clear();
}
Future<void> disconnect() async {
final device = _connectedDevice;
if (device == null) return;
developer.log('[BLE] 发起断开连接...', name: 'BleManager');
await device.disconnect();
// 等待连接状态流确认已断开,设置超时防止无限等待
try {
await device.connectionState
.firstWhere((s) => s == BluetoothConnectionState.disconnected)
.timeout(const Duration(seconds: 3));
developer.log('[BLE] 连接已确认断开', name: 'BleManager');
} catch (_) {
developer.log('[BLE] 等待断开超时,强制清理', name: 'BleManager');
}
_onDeviceDisconnected();
}
Future<void> _discoverServices(BluetoothDevice device) async {
final services = await device.discoverServices();
for (final service in services) {
for (final characteristic in service.characteristics) {
if (characteristic.properties.write) {
_writeCharacteristic = characteristic;
}
if (characteristic.properties.notify) {
_readCharacteristic = characteristic;
await characteristic.setNotifyValue(true);
_readSubscription?.cancel();
_readSubscription = characteristic.lastValueStream.listen((value) {
_onDataReceived(value);
});
}
}
}
}
void _onDataReceived(List<int> data) {
developer.log(
'[BLE] 📩 收: ${data.length}B ${_bytesToHex(data)}',
name: 'BleManager',
);
_parser.append(data);
final packets = _parser.parse();
developer.log(
'[BLE] 📦 解析 ${packets.length}包 (buf ${_parser.bufferLength}B)',
name: 'BleManager',
);
if (packets.isEmpty && data.isNotEmpty) {
developer.log(
'[BLE] ⚠️ 非标准帧 raw(${data.length}B)',
name: 'BleManager',
);
final packet = BlePacket(
command: 0x00,
payload: Uint8List.fromList(data),
);
_storePacket(packet);
_packetController.add(packet);
return;
}
for (final packet in packets) {
developer.log(
'[BLE] cmd=0x${packet.command.toRadixString(16).toUpperCase().padLeft(2, '0')} '
'payload(${packet.payload.length}B): ${_bytesToHex(packet.payload)}',
name: 'BleManager',
);
_storePacket(packet);
_packetController.add(packet);
}
}
void _storePacket(BlePacket packet) {
receivedPacketStore.insert(0, packet);
if (receivedPacketStore.length > _maxStoredPackets) {
receivedPacketStore.removeLast();
}
}
String _bytesToHex(List<int> bytes) {
if (bytes.isEmpty) return '';
return bytes
.map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
.join(' ');
}
Future<void> sendCommand(int command, List<int> payload) async {
final char = _writeCharacteristic;
if (char == null) {
developer.log('[BLE] ❌ 写特征为空,无法发送', name: 'BleManager');
return;
}
final frame = ProtocolParser.pack(command, payload);
final crc = frame.length >= 5
? 'CRC16=0x${frame[frame.length - 4].toRadixString(16).padLeft(2, '0')}${frame[frame.length - 3].toRadixString(16).padLeft(2, '0')}'
: '';
developer.log(
'[BLE] 📤 发送: 0x${command.toRadixString(16).toUpperCase().padLeft(2, '0')}, '
'帧(${frame.length}B) $crc\n'
' ${_bytesToHex(frame)}',
name: 'BleManager',
);
// 分片写入:每次最多写 (MTU - 3) 字节,全部走 withResponse 确保可靠性
final maxWriteLen = _negotiatedMtu - 3;
final totalChunks = (frame.length + maxWriteLen - 1) ~/ maxWriteLen;
if (totalChunks > 1) {
developer.log(
'[BLE] 🔀 分${totalChunks}片写入 (platform=${Platform.isIOS ? "iOS" : "Android"}, MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
name: 'BleManager',
);
}
for (int offset = 0; offset < frame.length; offset += maxWriteLen) {
final end = (offset + maxWriteLen <= frame.length)
? offset + maxWriteLen
: frame.length;
final chunk = frame.sublist(offset, end);
if (totalChunks > 1) {
final chunkIdx = offset ~/ maxWriteLen + 1;
developer.log(
'[BLE] 片$chunkIdx/$totalChunks: offset=$offset len=${chunk.length}B ${_bytesToHex(chunk)}',
name: 'BleManager',
);
}
await char.write(chunk, withoutResponse: false);
// iOS 分片间加小延时,给 MCU 侧协议解析器留出帧间处理时间,
// 避免长帧(如 0x06 写配置 176B)被拆多片时因间隔过短触发丢帧
if (Platform.isIOS &&
totalChunks > 1 &&
offset + maxWriteLen < frame.length) {
await Future.delayed(const Duration(milliseconds: 20));
}
}
developer.log('[BLE] ✅ 写入完成', name: 'BleManager');
}
Future<void> sendRawBytes(List<int> bytes) async {
if (_writeCharacteristic == null) return;
await _writeCharacteristic!.write(bytes, withoutResponse: false);
}
Future<void> sendHeartbeat() async {
await sendCommand(0xFF, []);
}
void dispose() {
_scanSubscription?.cancel();
_readSubscription?.cancel();
_adapterStateSubscription?.cancel();
_deviceConnectionSubscription?.cancel();
_mtuSubscription?.cancel();
_scanController.close();
_packetController.close();
_connectionController.close();
_connectingController.close();
}
}

View File

@@ -0,0 +1,744 @@
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:typed_data';
import '../protocol/machine_protocol_constants.dart';
import 'mc700_device_config.dart';
/// BLE 协议解析出的单个字段
class BleField {
final String label;
final String value;
const BleField({required this.label, required this.value});
}
/// BLE 协议解析结果
class BleDecodedResult {
final List<BleField> fields;
final String rawHex;
/// 0x05 读配置时携带的配置实体(可修改后回写)
final Mc700DeviceConfig? configEntity;
const BleDecodedResult({
required this.fields,
required this.rawHex,
this.configEntity,
});
bool get isEmpty => fields.isEmpty;
}
/// BLE 协议解析器
/// 与 TCP net_message_dispatcher 使用相同的协议格式:AB AA cmd [payload] ... AA AB
/// payload 通常为 UTF-8 逗号分隔文本,部分指令为二进制
class BleProtocolDecoder {
/// 入口:按命令类型分发解析
static BleDecodedResult decode(int command, Uint8List payload) {
final hex = _bytesToHex(payload);
try {
switch (command) {
case MachineProtocolConstants.cmdStatusInfo:
return _decodeStatusInfo(payload, hex);
case MachineProtocolConstants.cmdRemoteControl:
return _decodeRemoteControl(payload, hex);
case MachineProtocolConstants.cmdGetId:
return _decodeGetId(payload, hex);
case MachineProtocolConstants.cmdGetAuth:
return _decodeGetAuth(payload, hex);
case MachineProtocolConstants.cmdReadConfig:
return _decodeReadConfig(payload, hex);
case MachineProtocolConstants.cmdWriteConfig:
return _decodeWriteConfig(payload, hex);
case MachineProtocolConstants.cmdHeartbeat:
return _decodeHeartbeat(payload, hex);
case MachineProtocolConstants.cmdPathPlanning:
return _decodePathPlanning(payload, hex);
case MachineProtocolConstants.cmdObstacleAvoid:
return _decodeObstacleAvoid(payload, hex);
default:
return _decodeGeneric(command, payload, hex);
}
} catch (e) {
return BleDecodedResult(
fields: [const BleField(label: '解析错误', value: '异常')],
rawHex: hex,
);
}
}
// ─── 0x02 状态信息(与 TCP RunningStatusEntity.fromFields 一致,24字段逗号分隔) ───
static BleDecodedResult _decodeStatusInfo(Uint8List data, String hex) {
final fields = <BleField>[];
// 🔥 关键修复:协议帧为 AB AA 02 [24个逗号字段] CRC_lo CRC_hi AA AB。
// ProtocolParser 只剥离了帧尾 AA AB,payload 末尾仍带 2 字节 CRC16(小端)。
// 障碍物标志位是最后一个字段(fields[23]),若不先去掉 CRC,
// 这 2 字节会粘到障碍物字段上导致乱码。此处先剥离尾部 2 字节 CRC。
final Uint8List body =
data.length > 2 ? Uint8List.sublistView(data, 0, data.length - 2) : data;
try {
// allowMalformed 兜底:非法字节不再抛异常、不再落入 latin1 造成整段乱码
var text = utf8.decode(body, allowMalformed: true);
// 去掉控制字符(\0 \r \n 等)与替换符(U+FFFD):固件常在串尾补 \0,
// 或残留 CRC 被解码成非法字符,粘在最后一个字段(障碍物)上造成乱码
text = text.replaceAll(RegExp(r'[\u0000-\u001F\uFFFD]'), '');
if (text.contains(',')) {
final parts = text.split(',');
// 诊断:仅当控制模式槽位(parts[20])出现协议外码(如6)时,打印全字段索引对齐,
// 用于判断是固件扩展码还是字段错位;正常包不打印,避免刷屏
if (parts.length > 20 &&
!const ['0', '1', '2', '3', '4'].contains(parts[20].trim())) {
developer.log(
'[BLE-0x02] 字段对齐诊断: ' +
List.generate(parts.length, (i) => '[$i]=${parts[i]}')
.join(' '),
name: 'BleProtocolDecoder',
);
}
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
}
} catch (_) {}
if (body.isNotEmpty) {
final status = body[0];
fields.add(
BleField(
label: '状态码',
value: '0x${status.toRadixString(16).toUpperCase().padLeft(2, '0')}',
),
);
}
if (body.length >= 2) {
final mode = body[1];
const modes = {0x00: '待机', 0x01: '遥控', 0x02: '自动', 0x03: '急停'};
fields.add(
BleField(
label: '控制模式',
value: modes[mode] ?? '未知(0x${mode.toRadixString(16)})',
),
);
}
if (body.length >= 3)
fields.add(BleField(label: '电量', value: '${body[2]}%'));
if (body.length >= 4) {
final fault = body[3];
fields.add(
BleField(
label: '故障状态',
value: fault == 0
? '无故障'
: '故障码: 0x${fault.toRadixString(16).toUpperCase().padLeft(2, '0')}',
),
);
}
if (body.length >= 6) {
final speed = (body[5] << 8) | body[4];
fields.add(BleField(label: '速度', value: '$speed'));
}
if (body.length > 8) {
try {
final text = String.fromCharCodes(body.sublist(8));
if (text.isNotEmpty && !text.contains('\x00')) {
fields.add(BleField(label: '附加', value: text));
}
} catch (_) {}
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
/// 解析逗号分隔的 24 字段状态数据(与 RunningStatusEntity.fromFields 完全一致)
static List<BleField> _parseStatusFields(List<String> parts) {
while (parts.length < 24) {
parts.add('');
}
// 协议定义 控制模式: 0无控制 1本地遥控 2蓝牙 3TCP 4其他接口;
// 先按整数解析(容忍前后空格),有映射的一律显示对应文字;
// 超出协议范围的码(如固件扩展值)显示为 未知(n),避免被误当成有效模式
String controlModeText(String v) {
final n = int.tryParse(v.trim());
if (n == null) return v.isEmpty ? '--' : v;
return switch (n) {
0 => '无控制',
1 => '本地遥控',
2 => '蓝牙',
3 => 'TCP',
4 => '其他接口',
_ => '未知($n)',
};
}
// 协议定义 定位质量: 0无效 1GPS单点 2DGPS差分/SBAS 4RTK固定解 5RTK浮点解 7手动输入
String qualText(String v) {
final q = int.tryParse(v) ?? -1;
return switch (q) {
0 => '无效',
1 => 'GPS 单点定位',
2 => 'DGPS 差分/SBAS',
4 => 'RTK 固定解',
5 => 'RTK 浮点解',
7 => '手动输入模式',
_ => v.isNotEmpty ? v : '--',
};
}
String headingText(String v) {
final h = int.tryParse(v) ?? -1;
return switch (h) {
0 => '未初始化',
1 => '已初始化',
_ => v.isNotEmpty ? v : '--',
};
}
String obstacleText(String v) {
// 障碍物标志位为单个数字 0/1;字段尾部可能粘有 CRC/\0 等杂质,
// 取第一个数字字符作为标志,无数字则显示 '--',杜绝杂质乱码
final d = RegExp(r'[0-9]').firstMatch(v);
if (d == null) return '--';
return switch (int.parse(d.group(0)!)) {
0 => '无障碍',
1 => '有障碍',
_ => v,
};
}
return [
BleField(label: '电压', value: '${_tryParseDouble(parts[0])} V'),
BleField(label: '左目标速度', value: _tryParseDouble(parts[1])),
BleField(label: '右目标速度', value: _tryParseDouble(parts[2])),
BleField(label: '左实测速度', value: _tryParseDouble(parts[3])),
BleField(label: '右实测速度', value: _tryParseDouble(parts[4])),
BleField(label: '左电流', value: '${_tryParseDouble(parts[5])} A'),
BleField(label: '右电流', value: '${_tryParseDouble(parts[6])} A'),
BleField(label: '左电机温度', value: '${_tryParseDouble(parts[7])} ℃'),
BleField(label: '右电机温度', value: '${_tryParseDouble(parts[8])} ℃'),
BleField(label: '芯片温度', value: '${_tryParseDouble(parts[9])} ℃'),
BleField(label: '偏航角', value: '${_tryParseDouble(parts[10])} °'),
BleField(label: '俯仰角', value: '${_tryParseDouble(parts[11])} °'),
BleField(label: '翻滚角', value: '${_tryParseDouble(parts[12])} °'),
BleField(label: '卫星数量', value: parts[13].isNotEmpty ? parts[13] : '--'),
BleField(label: '定位质量', value: qualText(parts[14])),
BleField(label: '航向状态', value: headingText(parts[15])),
BleField(label: '纬度', value: parts[16].isNotEmpty ? parts[16] : '--'),
BleField(label: '经度', value: parts[17].isNotEmpty ? parts[17] : '--'),
BleField(label: '时间戳', value: parts[18].isNotEmpty ? parts[18] : '--'),
BleField(
label: '割刀速度',
value: parts[19].isNotEmpty ? '${parts[19]} rpm' : '--',
),
BleField(label: '控制模式', value: controlModeText(parts[20])),
BleField(
label: '电量',
value: parts[21].isNotEmpty ? '${parts[21]}%' : '--',
),
BleField(label: '工作面积', value: parts[22].isNotEmpty ? parts[22] : '--'),
BleField(label: '障碍物', value: obstacleText(parts[23])),
];
}
static String _tryParseDouble(String s) {
if (s.isEmpty) return '--';
final v = double.tryParse(s);
if (v == null) return s;
if (v == v.roundToDouble() && v.abs() < 10000) return v.toInt().toString();
return v.toStringAsFixed(2);
}
// ─── 0x00 远程遥控 ───
static BleDecodedResult _decodeRemoteControl(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
if (text.contains(',')) {
final parts = text.split(',');
while (parts.length < 5) parts.add('');
fields.add(
BleField(label: '左右速度', value: parts[0].isNotEmpty ? parts[0] : '--'),
);
fields.add(
BleField(label: '前后速度', value: parts[1].isNotEmpty ? parts[1] : '--'),
);
final modeStr = switch (parts[2]) {
'0' => '停止',
'1' => '遥控',
'2' => '自动',
_ => parts[2].isNotEmpty ? parts[2] : '--',
};
fields.add(BleField(label: '控制模式', value: modeStr));
if (parts.length > 3 && parts[3].isNotEmpty)
fields.add(BleField(label: '参数4', value: parts[3]));
if (parts.length > 4 && parts[4].isNotEmpty)
fields.add(BleField(label: '参数5', value: parts[4]));
return BleDecodedResult(fields: fields, rawHex: hex);
}
} catch (_) {}
if (data.length >= 5) {
fields.add(BleField(label: '左右速度', value: '${(data[1] << 8) | data[0]}'));
fields.add(BleField(label: '前后速度', value: '${(data[3] << 8) | data[2]}'));
const modes = {0: '停止', 1: '遥控', 2: '自动'};
fields.add(
BleField(label: '控制模式', value: modes[data[4]] ?? '未知(${data[4]})'),
);
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x03 查询ID ───
static BleDecodedResult _decodeGetId(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
if (text.replaceAll('\x00', '').trim().isNotEmpty) {
fields.add(
BleField(label: '设备ID', value: text.replaceAll('\x00', '').trim()),
);
return BleDecodedResult(fields: fields, rawHex: hex);
}
} catch (_) {}
try {
final ascii = String.fromCharCodes(data);
if (RegExp(r'^[\x20-\x7E]+$').hasMatch(ascii)) {
fields.add(BleField(label: '设备ID', value: ascii));
} else {
fields.add(BleField(label: '设备ID(Hex)', value: hex));
}
} catch (_) {
fields.add(BleField(label: '设备ID(Hex)', value: hex));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x04 获取授权 ───
static BleDecodedResult _decodeGetAuth(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
final trimmed = text.replaceAll('\x00', '').trim();
if (trimmed == '1' || trimmed.toLowerCase() == 'true') {
fields.add(const BleField(label: '授权状态', value: '已授权'));
} else if (trimmed == '0' || trimmed.toLowerCase() == 'false') {
fields.add(const BleField(label: '授权状态', value: '未授权'));
} else if (trimmed.isNotEmpty) {
fields.add(BleField(label: '授权状态', value: trimmed));
}
if (fields.isNotEmpty)
return BleDecodedResult(fields: fields, rawHex: hex);
} catch (_) {}
if (data.isNotEmpty) {
if (data[0] == 0x01) {
fields.add(const BleField(label: '授权状态', value: '已授权'));
} else if (data[0] == 0x00) {
fields.add(const BleField(label: '授权状态', value: '未授权'));
} else {
fields.add(
BleField(label: '授权状态(Hex)', value: '0x${data[0].toRadixString(16)}'),
);
}
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x05 读配置(按嵌入式协议规范精确解析,171字节) ───
static BleDecodedResult _decodeReadConfig(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isEmpty) {
fields.add(const BleField(label: '状态', value: '空数据'));
return BleDecodedResult(fields: fields, rawHex: hex);
}
final bd = ByteData.sublistView(data);
final len = data.length;
// 1. UID固化标志 @payload[0] (帧字节3)
if (len >= 1) {
fields.add(
BleField(
label: 'UID固化标志',
value: data[0] == 1 ? '已固化' : '未固化(${data[0]})',
),
);
}
// 2. 芯片UID @payload[1..45] (帧字节4-48, 45字节)
if (len >= 46) {
fields.add(
BleField(label: '芯片UID', value: _readNullTermString(data, 1, 45)),
);
}
// 3. 遥控器通道配置 @payload[46..64] (帧字节49-67, 19字节)
if (len >= 65) {
fields.add(
BleField(label: '遥控器通道配置', value: _bytesToHex(data.sublist(46, 65))),
);
}
// 4. 字节68位域 @payload[65] (帧字节68)
if (len >= 66) {
final b = data[65];
fields.add(
BleField(
label: '割刀电机模式',
value: (b & 0x03) == 0 ? '纯电' : '油电(${b & 0x03})',
),
);
fields.add(
BleField(
label: '行走电机模式',
value: ((b >> 2) & 0x03) == 0 ? '轮式' : '履带(${(b >> 2) & 0x03})',
),
);
fields.add(
BleField(label: '左轮电机极性', value: (b & 0x10) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '右轮电机极性', value: (b & 0x20) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '左右轮通道交换', value: (b & 0x40) == 0 ? '不交换' : '通道互换'),
);
fields.add(
BleField(label: '联网目标', value: (b & 0x80) == 0 ? 'WiFi' : '4G'),
);
}
// 5. 前进速度限制 @payload[66..67] (帧字节69-70, uint16 LE)
if (len >= 68) {
fields.add(
BleField(label: '前进速度限制', value: '${bd.getUint16(66, Endian.little)}'),
);
}
// 6. 转向速度限制 @payload[68..69] (帧字节71-72, uint16 LE)
if (len >= 70) {
fields.add(
BleField(label: '转向速度限制', value: '${bd.getUint16(68, Endian.little)}'),
);
}
// 7. 字节73位域 @payload[70] (帧字节73)
if (len >= 71) {
final b = data[70];
fields.add(
BleField(label: '割刀通道极性', value: (b & 0x01) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '风门通道极性', value: (b & 0x02) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(
BleField(label: '油门通道极性', value: (b & 0x04) == 0 ? '高极性' : '低极性(反取)'),
);
fields.add(BleField(label: '底盘升降保护时间', value: '${(b >> 3) & 0x1F} 秒'));
fields.add(
BleField(label: 'RTK配置', value: (b & 0x80) == 0 ? '单天线' : '双天线'),
);
}
// 8. 字节74位域 @payload[71] (帧字节74)
if (len >= 72) {
final b = data[71];
fields.add(BleField(label: '割刀通道配置', value: '${b & 0x0F}'));
fields.add(BleField(label: '风门通道配置', value: '${(b >> 4) & 0x0F}'));
}
// 9. 字节75位域 @payload[72] (帧字节75)
if (len >= 73) {
final b = data[72];
fields.add(BleField(label: '油门通道配置', value: '${b & 0x0F}'));
fields.add(
BleField(
label: '遥控器类型',
value: (b >> 4) & 0x07 == 0 ? '飞控' : '自定义遥控器(${(b >> 4) & 0x07})',
),
);
fields.add(
BleField(label: '是否搭载继电器板', value: (b & 0x80) == 0 ? '未搭载' : '搭载'),
);
}
// 10. 字节76位域 @payload[73] (帧字节76)
if (len >= 74) {
final b = data[73];
fields.add(BleField(label: '底盘升降通道配置', value: '${b & 0x0F}'));
fields.add(BleField(label: '底盘通道配置', value: '${(b >> 4) & 0x0F}'));
}
// 11. 字节77位域 @payload[74] (帧字节77)
if (len >= 75) {
final b = data[74];
fields.add(BleField(label: '机械臂通道配置', value: '${b & 0x0F}'));
fields.add(BleField(label: '燃油泵通道配置', value: '${(b >> 4) & 0x0F}'));
}
// 12. WiFi名称 @payload[75..94] (帧字节78-97, 20字节)
if (len >= 95) {
fields.add(
BleField(label: 'WiFi名称', value: _readNullTermString(data, 75, 20)),
);
}
// 13. WiFi密码 @payload[95..114] (帧字节98-117, 20字节)
if (len >= 115) {
fields.add(
BleField(label: 'WiFi密码', value: _readNullTermString(data, 95, 20)),
);
}
// 14. 字节118位域 @payload[115] (帧字节118)
if (len >= 116) {
final b = data[115];
fields.add(
BleField(
label: '电池类型',
value: (b & 0x03) == 0 ? '铅酸' : '锂电(${b & 0x03})',
),
);
fields.add(
BleField(
label: '行走驱动配置',
value: switch ((b >> 2) & 0x03) {
0 => '风德控',
1 => '山东神澜BLDC',
2 => '山东神澜FOC',
_ => '未知(${(b >> 2) & 0x03})',
},
),
);
}
// 15. 转速比 @payload[116..119] (帧字节119-122, float LE)
if (len >= 120) {
final v = bd.getFloat32(116, Endian.little);
fields.add(BleField(label: '转速比', value: v.toStringAsFixed(2)));
}
// 16. 机器人长度 @payload[120..123] (帧字节123-126, float LE)
if (len >= 124) {
final v = bd.getFloat32(120, Endian.little);
fields.add(BleField(label: '机器人长度', value: v.toStringAsFixed(2)));
}
// 17. 机器人宽度 @payload[124..127] (帧字节127-130, float LE)
if (len >= 128) {
final v = bd.getFloat32(124, Endian.little);
fields.add(BleField(label: '机器人宽度', value: v.toStringAsFixed(2)));
}
// 18. 机器人高度 @payload[128..131] (帧字节131-134, float LE)
if (len >= 132) {
final v = bd.getFloat32(128, Endian.little);
fields.add(BleField(label: '机器人高度', value: v.toStringAsFixed(2)));
}
// 19. 割刀宽度 @payload[132..135] (帧字节135-138, float LE)
if (len >= 136) {
final v = bd.getFloat32(132, Endian.little);
fields.add(BleField(label: '割刀宽度', value: v.toStringAsFixed(2)));
}
// 20. 轮胎尺寸 @payload[136..139] (帧字节139-142, float LE)
if (len >= 140) {
final v = bd.getFloat32(136, Endian.little);
fields.add(BleField(label: '轮胎尺寸', value: v.toStringAsFixed(2)));
}
// 21. 左轮前进增益 @payload[140..143] (帧字节143-146, float LE)
if (len >= 144) {
final v = bd.getFloat32(140, Endian.little);
fields.add(BleField(label: '左轮前进增益', value: v.toStringAsFixed(2)));
}
// 22. 左轮后退增益 @payload[144..147] (帧字节147-150, float LE)
if (len >= 148) {
final v = bd.getFloat32(144, Endian.little);
fields.add(BleField(label: '左轮后退增益', value: v.toStringAsFixed(2)));
}
// 23. 右轮前进增益 @payload[148..151] (帧字节151-154, float LE)
if (len >= 152) {
final v = bd.getFloat32(148, Endian.little);
fields.add(BleField(label: '右轮前进增益', value: v.toStringAsFixed(2)));
}
// 24. 右轮后退增益 @payload[152..155] (帧字节155-158, float LE)
if (len >= 156) {
final v = bd.getFloat32(152, Endian.little);
fields.add(BleField(label: '右轮后退增益', value: v.toStringAsFixed(2)));
}
// 25. 固件版本 @payload[156..170] (帧字节159-173, 15字节)
if (len >= 171) {
fields.add(
BleField(label: '固件版本', value: _readNullTermString(data, 156, 15)),
);
}
// 构建配置实体(用于后续编辑和回写)
Mc700DeviceConfig? configEntity;
if (data.length >= 171) {
try {
configEntity = Mc700DeviceConfig.fromBytes(data);
} catch (e) {
developer.log('[BLE] 配置实体解析失败: $e', name: 'BleProtocolDecoder');
}
}
return BleDecodedResult(
fields: fields,
rawHex: hex,
configEntity: configEntity,
);
}
/// 读取 null 终止字符串(最多读取 [maxLen] 字节)
static String _readNullTermString(Uint8List data, int offset, int maxLen) {
final buffer = StringBuffer();
for (int i = 0; i < maxLen && offset + i < data.length; i++) {
final b = data[offset + i];
if (b == 0x00) break;
if (b >= 0x20 && b <= 0x7E) {
buffer.writeCharCode(b);
} else {
buffer.writeCharCode(0x2E);
}
}
return buffer.toString();
}
// ─── 0x06 写配置 ───
static BleDecodedResult _decodeWriteConfig(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isNotEmpty) {
final ok = data[0];
fields.add(
BleField(
label: '写入结果',
value: ok == 0x01
? '成功'
: ok == 0x00
? '失败'
: '0x${ok.toRadixString(16)}',
),
);
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0xFF 心跳包 ───
static BleDecodedResult _decodeHeartbeat(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isEmpty) {
fields.add(const BleField(label: '心跳', value: '正常'));
} else if (data.length >= 2) {
final interval = (data[1] << 8) | data[0];
fields.add(BleField(label: '心跳间隔', value: '${interval}ms'));
} else {
fields.add(BleField(label: '心跳数据', value: hex));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x01 路径规划 ───
static BleDecodedResult _decodePathPlanning(Uint8List data, String hex) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
if (text.contains(',')) {
final parts = text.split(',');
if (parts.length >= 2) {
fields.add(BleField(label: '点编号', value: parts[0]));
fields.add(
BleField(
label: '状态',
value: switch (parts[1]) {
'1' => '已到达',
'2' => '收到指令',
_ => parts[1],
},
),
);
}
if (parts.length >= 4) {
fields.add(BleField(label: '纬度', value: parts[2]));
fields.add(BleField(label: '经度', value: parts[3]));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
} catch (_) {}
if (data.length >= 3) {
fields.add(BleField(label: '点编号', value: '${data[0]}'));
final status = data.length > 1 ? data[1] : -1;
fields.add(
BleField(
label: '状态',
value: status == 0x01
? '已到达'
: status == 0x02
? '收到指令'
: '0x${status.toRadixString(16)}',
),
);
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 0x07 避障 ───
static BleDecodedResult _decodeObstacleAvoid(Uint8List data, String hex) {
final fields = <BleField>[];
if (data.isNotEmpty) {
final flag = data[0];
fields.add(
BleField(
label: '避障状态',
value: flag == 0x01
? '触发'
: flag == 0x00
? '正常'
: '0x${flag.toRadixString(16)}',
),
);
}
if (data.length >= 3) {
fields.add(BleField(label: '距离', value: '${(data[2] << 8) | data[1]}mm'));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 通用/未知指令 ───
static BleDecodedResult _decodeGeneric(
int command,
Uint8List data,
String hex,
) {
final fields = <BleField>[];
try {
final text = utf8.decode(data, allowMalformed: true);
final trimmed = text.replaceAll('\x00', '').trim();
if (trimmed.isNotEmpty) {
fields.add(
BleField(
label: '文本内容',
value: trimmed.length > 50
? '${trimmed.substring(0, 50)}...'
: trimmed,
),
);
}
} catch (_) {}
if (fields.isEmpty) {
fields.add(BleField(label: '数据长度', value: '${data.length}B'));
}
return BleDecodedResult(fields: fields, rawHex: hex);
}
// ─── 工具方法 ───
static String _bytesToHex(List<int> bytes) {
if (bytes.isEmpty) return '';
return bytes
.map((b) => b.toRadixString(16).toUpperCase().padLeft(2, '0'))
.join(' ');
}
}

View File

@@ -0,0 +1,38 @@
import 'dart:convert';
import 'dart:typed_data';
class BytesUtil {
static String bytesToHex(List<int> bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join();
}
static Uint8List hexToBytes(String hex) {
hex = hex.replaceAll(' ', '').replaceAll('-', '');
if (hex.length % 2 != 0) hex = '0' + hex;
final bytes = Uint8List(hex.length ~/ 2);
for (int i = 0; i < hex.length ~/ 2; i++) {
bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return bytes;
}
static String bytesToString(List<int> bytes) {
try {
return utf8.decode(bytes);
} catch (_) {
return String.fromCharCodes(bytes);
}
}
static Uint8List stringToBytes(String str) {
return Uint8List.fromList(utf8.encode(str));
}
static List<int> toByteList(Uint8List data) {
return List<int>.from(data);
}
static String bytesToHexSpaced(List<int> bytes) {
return bytes.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ');
}
}

View File

@@ -0,0 +1,402 @@
import 'dart:convert';
import 'dart:typed_data';
/// MC700设备配置实体类(与嵌入式协议规范一一对应)
/// 所有偏移均为 payload 内偏移(ProtocolParser 已剥离帧头/命令/校验/帧尾)
/// payload 共 171 字节,索引 0~170
class Mc700DeviceConfig {
// ====== 字段定义(按嵌入式协议规范) ======
// --- 基础标识 ---
late int uidSolidifiedFlag; // payload[0] UID固化标志 (0=未固化, 1=已固化)
late String chipUid; // payload[1..45] 芯片UID (45字节)
late List<int> remoteChannelConfig; // payload[46..64] 遥控器通道配置 (19字节)
// --- 字节68位域 (payload[65]) ---
late int knifeMotorMode; // bit0-1 割刀电机模式 (0=纯电, 1=油电)
late int walkMotorMode; // bit2-3 行走电机模式 (0=轮式, 1=履带)
late bool leftWheelPolarity; // bit4 左轮极性 (false=高, true=低)
late bool rightWheelPolarity; // bit5 右轮极性 (false=高, true=低)
late bool channelSwap; // bit6 左右轮通道交换
late bool use4g; // bit7 联网目标 (false=WiFi, true=4G)
// --- 速度限制 ---
late int forwardSpeedLimit; // payload[66..67] 前进速度限制 (uint16 LE)
late int turnSpeedLimit; // payload[68..69] 转向速度限制 (uint16 LE)
// --- 字节73位域 (payload[70]) ---
late bool knifeChannelPolarity; // bit0 割刀通道极性
late bool fanChannelPolarity; // bit1 风门通道极性
late bool throttleChannelPolarity; // bit2 油门通道极性
late int liftProtectTime; // bit3-6 底盘升降保护时间 (秒)
late bool dualRtk; // bit7 RTK配置 (false=单天线, true=双天线)
// --- 字节74位域 (payload[71]) ---
late int knifeChannelConfig; // bit0-3 割刀通道配置
late int fanChannelConfig; // bit4-7 风门通道配置
// --- 字节75位域 (payload[72]) ---
late int throttleChannelConfig; // bit0-3 油门通道配置
late int remoteType; // bit4-6 遥控器类型 (0=飞控, 1=自定义1)
late bool relayBoard; // bit7 是否搭载继电器板
// --- 字节76位域 (payload[73]) ---
late int chassisLiftChannel; // bit0-3 底盘升降通道配置
late int chassisChannel; // bit4-7 底盘通道配置
// --- 字节77位域 (payload[74]) ---
late int armChannel; // bit0-3 机械臂通道配置
late int fuelPumpChannel; // bit4-7 燃油泵通道配置
// --- WiFi ---
late String wifiName; // payload[75..94] WiFi名称 (20字节)
late String wifiPassword; // payload[95..114] WiFi密码 (20字节)
// --- 字节118位域 (payload[115]) ---
late int batteryType; // bit0-1 电池类型 (0=铅酸, 1=锂电)
late int walkDriveConfig; // bit2-3 行走驱动配置
// --- 尺寸参数 (float LE) ---
late double gearRatio; // payload[116..119] 转速比
late double robotLength; // payload[120..123] 机器人长度
late double robotWidth; // payload[124..127] 机器人宽度
late double robotHeight; // payload[128..131] 机器人高度
late double knifeWidth; // payload[132..135] 割刀宽度
late double tireSize; // payload[136..139] 轮胎尺寸
// --- 增益 (float LE) ---
late double leftForwardGain; // payload[140..143] 左轮前进增益
late double leftBackwardGain; // payload[144..147] 左轮后退增益
late double rightForwardGain; // payload[148..151] 右轮前进增益
late double rightBackwardGain; // payload[152..155] 右轮后退增益
// --- 固件版本 ---
late String firmwareVersion; // payload[156..170] 固件版本 (15字节)
Mc700DeviceConfig();
// ====== 从二进制数据解析(171字节 payload) ======
factory Mc700DeviceConfig.fromBytes(Uint8List data) {
if (data.length < 171) {
throw Exception('数据长度不足171字节,实际${data.length}字节');
}
final bd = ByteData.sublistView(data);
final obj = Mc700DeviceConfig();
// 1. UID固化标志 @0
obj.uidSolidifiedFlag = bd.getUint8(0);
// 2. 芯片UID @1..45 (45字节)
obj.chipUid = _readNullTerminatedString(data, 1, 45);
// 3. 遥控器通道配置 @46..64 (19字节)
obj.remoteChannelConfig = List.unmodifiable(data.sublist(46, 65));
// 4. 字节68位域 @65
final b68 = bd.getUint8(65);
obj.knifeMotorMode = b68 & 0x03;
obj.walkMotorMode = (b68 >> 2) & 0x03;
obj.leftWheelPolarity = (b68 & 0x10) != 0;
obj.rightWheelPolarity = (b68 & 0x20) != 0;
obj.channelSwap = (b68 & 0x40) != 0;
obj.use4g = (b68 & 0x80) != 0;
// 5. 前进速度限制 @66..67
obj.forwardSpeedLimit = bd.getUint16(66, Endian.little);
// 6. 转向速度限制 @68..69
obj.turnSpeedLimit = bd.getUint16(68, Endian.little);
// 7. 字节73位域 @70
final b73 = bd.getUint8(70);
obj.knifeChannelPolarity = (b73 & 0x01) != 0;
obj.fanChannelPolarity = (b73 & 0x02) != 0;
obj.throttleChannelPolarity = (b73 & 0x04) != 0;
obj.liftProtectTime = (b73 >> 3) & 0x1F;
obj.dualRtk = (b73 & 0x80) != 0;
// 8. 字节74位域 @71
final b74 = bd.getUint8(71);
obj.knifeChannelConfig = b74 & 0x0F;
obj.fanChannelConfig = (b74 >> 4) & 0x0F;
// 9. 字节75位域 @72
final b75 = bd.getUint8(72);
obj.throttleChannelConfig = b75 & 0x0F;
obj.remoteType = (b75 >> 4) & 0x07;
obj.relayBoard = (b75 & 0x80) != 0;
// 10. 字节76位域 @73
final b76 = bd.getUint8(73);
obj.chassisLiftChannel = b76 & 0x0F;
obj.chassisChannel = (b76 >> 4) & 0x0F;
// 11. 字节77位域 @74
final b77 = bd.getUint8(74);
obj.armChannel = b77 & 0x0F;
obj.fuelPumpChannel = (b77 >> 4) & 0x0F;
// 12. WiFi名称 @75..94 (20字节)
obj.wifiName = _readNullTerminatedString(data, 75, 20);
// 13. WiFi密码 @95..114 (20字节)
obj.wifiPassword = _readNullTerminatedString(data, 95, 20);
// 14. 字节118位域 @115
final b118 = bd.getUint8(115);
obj.batteryType = b118 & 0x03;
obj.walkDriveConfig = (b118 >> 2) & 0x03;
// 15. 转速比 @116..119
obj.gearRatio = bd.getFloat32(116, Endian.little);
// 16. 机器人长度 @120..123
obj.robotLength = bd.getFloat32(120, Endian.little);
// 17. 机器人宽度 @124..127
obj.robotWidth = bd.getFloat32(124, Endian.little);
// 18. 机器人高度 @128..131
obj.robotHeight = bd.getFloat32(128, Endian.little);
// 19. 割刀宽度 @132..135
obj.knifeWidth = bd.getFloat32(132, Endian.little);
// 20. 轮胎尺寸 @136..139
obj.tireSize = bd.getFloat32(136, Endian.little);
// 21. 左轮前进增益 @140..143
obj.leftForwardGain = bd.getFloat32(140, Endian.little);
// 22. 左轮后退增益 @144..147
obj.leftBackwardGain = bd.getFloat32(144, Endian.little);
// 23. 右轮前进增益 @148..151
obj.rightForwardGain = bd.getFloat32(148, Endian.little);
// 24. 右轮后退增益 @152..155
obj.rightBackwardGain = bd.getFloat32(152, Endian.little);
// 25. 固件版本 @156..170 (15字节)
obj.firmwareVersion = _readNullTerminatedString(data, 156, 15);
return obj;
}
// ====== 转换回二进制字节(用于写配置下发) ======
/// [originalPayload] 是上次读取的 171 字节 payload,用于保留未修改字段
Uint8List toBytes(Uint8List originalPayload) {
final config = Uint8List.fromList(originalPayload);
final bd = ByteData.sublistView(config);
// UID固化标志 @0(独立字段,不属于字节68)
bd.setUint8(0, uidSolidifiedFlag & 0x01);
// 字节68位域 @65
int b68 = 0;
b68 |= (knifeMotorMode & 0x03);
b68 |= (walkMotorMode & 0x03) << 2;
if (leftWheelPolarity) b68 |= 0x10;
if (rightWheelPolarity) b68 |= 0x20;
if (channelSwap) b68 |= 0x40;
if (use4g) b68 |= 0x80;
bd.setUint8(65, b68);
// 速度限制
bd.setUint16(66, forwardSpeedLimit, Endian.little);
bd.setUint16(68, turnSpeedLimit, Endian.little);
// 字节73位域 @70
int b73 = (liftProtectTime & 0x1F) << 3;
if (knifeChannelPolarity) b73 |= 0x01;
if (fanChannelPolarity) b73 |= 0x02;
if (throttleChannelPolarity) b73 |= 0x04;
if (dualRtk) b73 |= 0x80;
bd.setUint8(70, b73);
// 字节74位域 @71
int b74 = knifeChannelConfig & 0x0F;
b74 |= (fanChannelConfig & 0x0F) << 4;
bd.setUint8(71, b74);
// 字节75位域 @72
int b75 = throttleChannelConfig & 0x0F;
b75 |= (remoteType & 0x07) << 4;
if (relayBoard) b75 |= 0x80;
bd.setUint8(72, b75);
// 字节76位域 @73
int b76 = chassisLiftChannel & 0x0F;
b76 |= (chassisChannel & 0x0F) << 4;
bd.setUint8(73, b76);
// 字节77位域 @74
int b77 = armChannel & 0x0F;
b77 |= (fuelPumpChannel & 0x0F) << 4;
bd.setUint8(74, b77);
// WiFi
_writeNullTerminatedString(config, 75, wifiName, 20);
_writeNullTerminatedString(config, 95, wifiPassword, 20);
// 字节118位域 @115
int b118 = batteryType & 0x03;
b118 |= (walkDriveConfig & 0x03) << 2;
bd.setUint8(115, b118);
// 尺寸参数
bd.setFloat32(116, gearRatio, Endian.little);
bd.setFloat32(120, robotLength, Endian.little);
bd.setFloat32(124, robotWidth, Endian.little);
bd.setFloat32(128, robotHeight, Endian.little);
bd.setFloat32(132, knifeWidth, Endian.little);
bd.setFloat32(136, tireSize, Endian.little);
// 增益
bd.setFloat32(140, leftForwardGain, Endian.little);
bd.setFloat32(144, leftBackwardGain, Endian.little);
bd.setFloat32(148, rightForwardGain, Endian.little);
bd.setFloat32(152, rightBackwardGain, Endian.little);
// 固件版本
_writeNullTerminatedString(config, 156, firmwareVersion, 15);
return config;
}
/// 获取所有字段的 label→value 映射
Map<String, String> toFieldMap() {
return {
'UID固化标志': '$uidSolidifiedFlag',
'芯片UID': chipUid,
'割刀电机模式': knifeMotorMode == 0 ? '纯电' : '油电($knifeMotorMode)',
'行走电机模式': walkMotorMode == 0 ? '轮式' : '履带($walkMotorMode)',
'左轮极性': leftWheelPolarity ? '低' : '高',
'右轮极性': rightWheelPolarity ? '低' : '高',
'通道交换': channelSwap ? '是' : '否',
'联网目标': use4g ? '4G' : 'WiFi',
'前进速度限制': '$forwardSpeedLimit',
'转向速度限制': '$turnSpeedLimit',
'割刀通道极性': knifeChannelPolarity ? '低' : '高',
'风门通道极性': fanChannelPolarity ? '低' : '高',
'油门通道极性': throttleChannelPolarity ? '低' : '高',
'升降保护时间': '$liftProtectTime 秒',
'RTK配置': dualRtk ? '双天线' : '单天线',
'割刀通道配置': '$knifeChannelConfig',
'风门通道配置': '$fanChannelConfig',
'油门通道配置': '$throttleChannelConfig',
'遥控器类型': '$remoteType',
'搭载继电器板': relayBoard ? '是' : '否',
'底盘升降通道': '$chassisLiftChannel',
'底盘通道': '$chassisChannel',
'机械臂通道': '$armChannel',
'燃油泵通道': '$fuelPumpChannel',
'WiFi名称': wifiName,
'WiFi密码': wifiPassword,
'电池类型': batteryType == 0 ? '铅酸' : '锂电',
'行走驱动': walkDriveConfig.toString(),
'转速比': gearRatio.toStringAsFixed(2),
'机器人长度': robotLength.toStringAsFixed(2),
'机器人宽度': robotWidth.toStringAsFixed(2),
'机器人高度': robotHeight.toStringAsFixed(2),
'割刀宽度': knifeWidth.toStringAsFixed(2),
'轮胎尺寸': tireSize.toStringAsFixed(2),
'左轮前进增益': leftForwardGain.toStringAsFixed(2),
'左轮后退增益': leftBackwardGain.toStringAsFixed(2),
'右轮前进增益': rightForwardGain.toStringAsFixed(2),
'右轮后退增益': rightBackwardGain.toStringAsFixed(2),
'固件版本': firmwareVersion,
};
}
/// 根据字段标签设置新值(返回 null 表示成功,返回错误原因字符串)
String? setField(String label, String value) {
final intVal = int.tryParse(value);
final doubleVal = double.tryParse(value);
switch (label) {
case 'UID固化标志':
if (intVal == null || intVal < 0 || intVal > 1) return '值范围: 0~1';
uidSolidifiedFlag = intVal;
return null;
case '前进速度限制':
if (intVal == null || intVal < 0 || intVal > 65535)
return '值范围: 0~65535';
forwardSpeedLimit = intVal;
return null;
case '转向速度限制':
if (intVal == null || intVal < 0 || intVal > 65535)
return '值范围: 0~65535';
turnSpeedLimit = intVal;
return null;
case 'WiFi名称':
if (value.length > 20) return '最多20字符';
wifiName = value;
return null;
case 'WiFi密码':
if (value.length > 20) return '最多20字符';
wifiPassword = value;
return null;
case '转速比':
if (doubleVal == null) return '请输入有效数字';
gearRatio = doubleVal;
return null;
case '机器人长度':
if (doubleVal == null) return '请输入有效数字';
robotLength = doubleVal;
return null;
case '机器人宽度':
if (doubleVal == null) return '请输入有效数字';
robotWidth = doubleVal;
return null;
case '机器人高度':
if (doubleVal == null) return '请输入有效数字';
robotHeight = doubleVal;
return null;
case '割刀宽度':
if (doubleVal == null) return '请输入有效数字';
knifeWidth = doubleVal;
return null;
case '轮胎尺寸':
if (doubleVal == null) return '请输入有效数字';
tireSize = doubleVal;
return null;
case '左轮前进增益':
if (doubleVal == null) return '请输入有效数字';
leftForwardGain = doubleVal;
return null;
case '左轮后退增益':
if (doubleVal == null) return '请输入有效数字';
leftBackwardGain = doubleVal;
return null;
case '右轮前进增益':
if (doubleVal == null) return '请输入有效数字';
rightForwardGain = doubleVal;
return null;
case '右轮后退增益':
if (doubleVal == null) return '请输入有效数字';
rightBackwardGain = doubleVal;
return null;
case '固件版本':
if (value.length > 15) return '最多15字符';
firmwareVersion = value;
return null;
default:
return '未知字段: $label';
}
}
// 工具:读取0结尾的ASCII字符串
static String _readNullTerminatedString(
Uint8List data,
int offset,
int maxLen,
) {
final end = data.indexOf(0, offset);
final realEnd = (end == -1 || end > offset + maxLen)
? offset + maxLen
: end;
return ascii.decode(data.sublist(offset, realEnd));
}
// 工具:写入ASCII字符串,不足补0
static void _writeNullTerminatedString(
Uint8List data,
int offset,
String str,
int maxLen,
) {
final bytes = ascii.encode(str);
for (int i = 0; i < maxLen; i++) {
data[offset + i] = i < bytes.length ? bytes[i] : 0x00;
}
}
}

View File

@@ -0,0 +1,122 @@
import 'dart:typed_data';
class BlePacket {
final int command;
final Uint8List payload;
const BlePacket({required this.command, required this.payload});
}
/// 有状态的 BLE 协议解析器
/// 支持分片接收:多次调用 [append] 累积数据,[parse] 提取完整帧
class ProtocolParser {
static const int _header1 = 0xAB;
static const int _header2 = 0xAA;
static const int _tail1 = 0xAA;
static const int _tail2 = 0xAB;
final List<int> _buffer = [];
/// 清空缓冲区
void clear() {
_buffer.clear();
}
/// 获取缓冲区长度
int get bufferLength => _buffer.length;
/// 打包命令为字节帧(用于发送)
/// CRC16 覆盖 command + payload 确保整帧完整性
static Uint8List pack(int command, List<int> payload) {
final dataToCheck = [command, ...payload];
final crc = _crc16(dataToCheck);
final builder = BytesBuilder()
..addByte(_header1)
..addByte(_header2)
..addByte(command)
..add(payload)
..addByte(crc & 0xFF)
..addByte((crc >> 8) & 0xFF)
..addByte(_tail1)
..addByte(_tail2);
return builder.takeBytes();
}
/// 追加新接收的数据到缓冲区
void append(List<int> data) {
_buffer.addAll(data);
}
/// 从缓冲区解析所有完整的数据包
/// 未完成的帧保留在缓冲区等待后续数据
List<BlePacket> parse() {
final List<BlePacket> packets = [];
while (_buffer.length >= 6) {
// 1. 查找帧头
int headIdx = -1;
for (int i = 0; i < _buffer.length - 1; i++) {
if (_buffer[i] == _header1 && _buffer[i + 1] == _header2) {
headIdx = i;
break;
}
}
if (headIdx == -1) {
// 无有效帧头,清空缓冲区
_buffer.clear();
break;
}
// 移除帧头前的垃圾数据
if (headIdx > 0) _buffer.removeRange(0, headIdx);
// 2. 查找帧尾
int tailIdx = -1;
for (int i = 2; i < _buffer.length - 1; i++) {
if (_buffer[i] == _tail1 && _buffer[i + 1] == _tail2) {
tailIdx = i;
break;
}
}
if (tailIdx == -1) {
// 帧尾未找到,可能是分片数据,等待更多数据
break;
}
// 3. 提取数据包
if (_buffer.length >= 5) {
final cmd = _buffer[2];
final payload = Uint8List.fromList(_buffer.sublist(3, tailIdx));
packets.add(BlePacket(command: cmd, payload: payload));
}
// 4. 移除已处理的帧
_buffer.removeRange(0, tailIdx + 2);
}
return packets;
}
/// 便捷方法:追加数据并立即解析
List<BlePacket> appendAndParse(List<int> data) {
append(data);
return parse();
}
/// CRC16-Modbus: polynomial=0x8005, init=0xFFFF, refIn/refOut=true
static int _crc16(List<int> data) {
int crc = 0xFFFF;
for (final b in data) {
crc ^= b;
for (int j = 0; j < 8; j++) {
if ((crc & 0x0001) != 0) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return crc;
}
}

View File

@@ -1,24 +1,129 @@
import '../env/env_config.dart';
class HttpApiConsts {
static const String baseUrl = "http://1.95.137.212:8081";
static String get baseUrl => EnvConfig.baseUrl;
/// 账号相关
// 登录
static const String loginUrl = "$baseUrl/login";
static String get loginUrl => "$baseUrl/login";
///设备相关
// 获取设备列表
static const String getUserDevicesList = "$baseUrl/iot/device/list";
static String get getUserDevicesList => "$baseUrl/iot/device/list";
// 绑定设备
static const String bindDevice = "$baseUrl/forward/device/bind";
static String get bindDevice => "$baseUrl/iot/device/bind";
// 解绑设备
static const String unbindDevice = "$baseUrl/forward/device/unbind";
static String get unbindDevice => "$baseUrl/forward/device/unbind";
// 切换设备
static const String switchDevice = "$baseUrl/forward/device/switchDevice";
static String get switchDevice => "$baseUrl/forward/device/switchDevice";
// 获取光伏电站列表
static String get getSiteList => "$baseUrl/system/site/selectByUserId";
// 获取场站下的设备列表
static String get getSiteDeviceList => "$baseUrl/iot/device/getSiteList";
// 获取场站下的无人机机场列表
static String get getSiteUAVList => "$baseUrl/iot/UAV/getSiteUAVList";
// 获取UAV状态详情
static String get getUAVState => "$baseUrl/iot/UAV/getUAVState";
// 切换摄像头获取视频流
static String get changeCamera => "$baseUrl/iot/UAV/changeCamera";
// 获取设备位置
static const String getDeviceLocation = "$baseUrl/iot/device/userDevice";
static String get getDeviceLocation => "$baseUrl/iot/device/userDevice";
// 获取全部设备列表(用于蓝牙绑定匹配)
static String get getDeviceList => "$baseUrl/iot/device/getDeviceList";
// 根据拆分码(蓝牙设备名/时间戳)精确查询设备(用于蓝牙绑定匹配)
static String get getDeviceBySpiltCode =>
"$baseUrl/iot/device/getDeviceBySpiltCode";
// 获取机器人列表
static String get getRobotList => "$baseUrl/iot/device/getSiteList";
// 获取飞行任务列表
static String get getFlightTask => "$baseUrl/iot/UAV/getFlightTask";
// 获取飞行任务详情
static String get getFlightTaskDetail =>
"$baseUrl/iot/UAV/getFlightTaskDetail";
// 获取航线列表
static String get getWayline => "$baseUrl/iot/UAV/getWayline";
// 创建飞行任务
static String get createFlightTask => "$baseUrl/iot/UAV/createFlightTask";
// 更新飞行任务状态
static String get updateFlightTaskStatus =>
"$baseUrl/iot/UAV/updateFlightTaskStatus";
// 切换无人机镜头获取视频流
static String get changeUAVLens => "$baseUrl/iot/UAV/changeLens";
// 获取无人机详情
static String get getUAVDetail => "$baseUrl/iot/UAV/getUAVDetail";
// 飞行任务命令控制(暂停、返航等)
static String get flightTaskCommand => "$baseUrl/iot/UAV/flightTaskCommand";
/// 告警相关
static String get alarmBaseUrl => baseUrl;
// 获取告警工单配置列表
static String get alarmOrderConfigList =>
"$alarmBaseUrl/iot/alarmOrderConfig/list";
// 获取告警列表
static String get alarmList => "$baseUrl/iot/alarm/list";
// 获取告警详情
static String get alarmDetail => "$baseUrl/iot/alarm";
// 处理告警(确认/关闭等)
static String get alarmHandle => "$baseUrl/iot/alarm/handle";
/// 工单相关
// 获取工单模型配置列表
static String get orderModelList => "$baseUrl/iot/orderModel/list";
// 添加工单(上报)
static String get workOrderAdd => "$baseUrl/iot/ioTworkOrder/add";
// 获取工单列表
static String get workOrderList => "$baseUrl/iot/ioTworkOrder/list";
// 获取工单详情
static String get workOrderDetail => "$baseUrl/iot/ioTworkOrder";
// 派发工单
static String get workOrderDispat => "$baseUrl/iot/ioTworkOrder/dispatch";
// 挂起工单
static String get workOrderSuspend => "$baseUrl/iot/ioTworkOrder/suspend";
// 完成工单
static String get workOrderComplete => "$baseUrl/iot/ioTworkOrder/complete";
// 开始执行工单
static String get workOrderStart => "$baseUrl/iot/ioTworkOrder/start";
// 获取工单统计
static String get workOrderCount => "$baseUrl/iot/ioTworkOrder/count";
// 设备运行参数查询
static String get deviceRunParamSelect =>
"$baseUrl/iot/deviceRunParam/selectByDeviceId";
// 设备运行参数保存
static String get deviceRunParamSave => "$baseUrl/iot/deviceRunParam/save";
// 设备操作权限校验
static String get hasPermission => "$baseUrl/iot/device/hasPermission";
// 释放远程控制权限(请求体:platform/type/deviceId/token)
static String get releaseControlUrl =>
"$baseUrl/forward/device/releaseControl";
/// 用户相关
// 获取用户列表
static String get systemUserList => "$baseUrl/system/user/list";
/// 组织与场站相关
// 获取组织列表
static String get orgList => "$baseUrl/system/org/list";
// 根据组织ID获取场站列表
static String get siteListByOrgId => "$baseUrl/system/site/selectByOrgId";
// 根据场站ID获取用户列表
static String get userListBySiteId => "$baseUrl/system/user/list";
}

View File

@@ -1,4 +1,6 @@
import '../env/env_config.dart';
class TCPConsts {
static const String TCP_IP = "1.95.137.212";
static const int TCP_PORT = 9001;
static String get TCP_IP => EnvConfig.tcpIp;
static int get TCP_PORT => EnvConfig.tcpPort;
}

View File

@@ -0,0 +1,44 @@
/// 工单模块尺寸常量
class WorkOrderDimensions {
WorkOrderDimensions._();
/// 通用圆角
static const double borderRadius = 16.0;
/// 水平边距
static const double horizontalPadding = 16.0;
/// 模块间距
static const double moduleSpacing = 20.0;
/// 导航栏高度
static const double navigationBarHeight = 44.0;
/// 标签栏高度
static const double tabBarHeight = 44.0;
}
/// 全局应用常量 (供 UI 组件使用)
class AppConstants {
AppConstants._();
static const double borderRadius = WorkOrderDimensions.borderRadius;
static const double horizontalPadding = WorkOrderDimensions.horizontalPadding;
static const double moduleSpacing = WorkOrderDimensions.moduleSpacing;
static const double navigationBarHeight = WorkOrderDimensions.navigationBarHeight;
}
/// 工单状态枚举
enum WorkOrderStatus {
pending, // 待处理
executing, // 执行中
completed, // 已完成
all, // 全部
}
/// 工单优先级枚举
enum WorkOrderPriority {
high, // 高
medium, // 中
low, // 低
}

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:flutter/widgets.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/infrastructure/logging/sentry_logger_impl.dart';
@@ -16,9 +17,12 @@ import 'package:maibu_satabot_v2/features/my/presentation/bloc/my_cubit.dart';
import 'package:maibu_satabot_v2/features/my/repository/my_repository.dart';
import 'package:maibu_satabot_v2/features/my/repository/my_repository_impl.dart';
import 'package:maibu_satabot_v2/features/my/usecases/updatename_usecase.dart';
import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart';
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart';
import 'package:maibu_satabot_v2/features/remote_control/data/repositories/remote_control_repository_impl.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/repositories/remote_control_repository.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/diff_steer_usecase.dart';
import 'package:maibu_satabot_v2/features/remote_control/domain/usecase/request_control_permission_usecase.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../features/auth/data/datasources/auth_http_datasource.dart';
@@ -46,21 +50,116 @@ import '../../features/devices/domain/usecases/device_work_hostrirty_usecase.dar
import '../../features/devices/domain/usecases/generate_path_usecase.dart';
import '../../features/devices/domain/usecases/get_device_location_usecase.dart';
import '../../features/devices/domain/usecases/get_work_record_usecase.dart';
import '../../features/devices/domain/usecases/get_work_records_by_site_id_usecase.dart';
import '../../features/devices/domain/usecases/create_device_task_usecase.dart';
import '../../features/devices/domain/usecases/route_planning_usecase.dart';
import '../../features/devices/domain/usecases/save_work_record_usecase.dart';
import '../../features/devices/domain/usecases/select_work_record_usecase.dart';
import '../../features/devices/domain/usecases/switch_device_usecase.dart';
import '../../features/devices/presentation/bloc/device_status_bloc.dart';
import '../../features/remote_control/data/datasources/remote_http_datasource.dart';
import '../../features/remote_control/data/datasources/remote_tcp_datasource.dart';
import '../../features/remote_control/domain/usecase/remote_control_usecase.dart';
import '../../features/remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../features/v2/home/data/datasources/home_remote_datasource.dart';
import '../../features/v2/home/data/datasources/site_datasource.dart';
import '../../features/v2/home/data/datasources/site_datasource_impl.dart';
import '../../features/v2/home/data/repositories/home_repository_impl.dart';
import '../../features/v2/home/data/repositories/site_repository_impl.dart';
import '../../features/v2/home/domain/repositories/home_repository.dart';
import '../../features/v2/home/domain/repositories/site_repository.dart';
import '../../features/v2/home/domain/usecases/get_home_data_usecase.dart';
import '../../features/v2/home/domain/usecases/get_site_list_usecase.dart';
import '../../features/v2/home/presentation/bloc/home_v2_bloc.dart';
import '../../features/v2/site/presentation/cubit/site_cubit.dart';
import '../../features/v2/device_list/data/datasources/device_remote_datasource.dart'
as device_v2;
import '../../features/v2/device_list/data/repositories/device_repository_impl.dart'
as device_v2_repo;
import '../../features/v2/device_list/domain/repositories/device_repository.dart'
as device_v2_domain;
import '../../features/v2/device_list/domain/usecases/get_device_status_data_usecase.dart'
as device_v2_usecase;
import '../../features/v2/device_list/domain/usecases/get_all_devices_usecase.dart';
import '../../features/v2/device_list/domain/usecases/get_device_by_spilt_code_usecase.dart';
import '../../features/v2/device_list/presentation/bloc/device_status_bloc.dart'
as device_v2_bloc;
import '../../features/v2/device_list/data/datasources/drone_station_datasource.dart';
import '../../features/v2/device_list/data/datasources/drone_station_datasource_impl.dart';
import '../../features/v2/device_list/data/repositories/drone_station_repository_impl.dart';
import '../../features/v2/device_list/domain/repositories/drone_station_repository.dart';
import '../../features/v2/device_list/domain/usecases/get_drone_station_list_usecase.dart';
import '../../features/v2/device_list/domain/usecases/get_video_stream_usecase.dart';
import '../../features/v2/device_list/domain/usecases/get_uav_video_stream_usecase.dart';
import '../../features/v2/device_list/domain/usecases/update_flight_task_status_usecase.dart';
import '../../features/v2/device_list/domain/usecases/pause_flight_task_usecase.dart';
import '../../features/v2/device_list/domain/usecases/return_home_usecase.dart';
import '../../features/v2/device_list/presentation/bloc/drone_station_bloc.dart';
import '../../features/v2/device_list/presentation/bloc/robot_list_bloc.dart';
import '../../features/v2/device_list/presentation/bloc/device_realtime_bloc.dart';
import '../../features/v2/device_list/data/datasources/bind_device_datasource.dart';
import '../../features/v2/device_list/data/datasources/impl/bind_device_datasource_impl.dart';
import '../../features/v2/device_list/data/repositories/bind_device_repository_impl.dart';
import '../../features/v2/device_list/domain/repositories/bind_device_repository.dart';
import '../../features/v2/device_list/domain/usecases/bind_device_usecases.dart';
import '../../features/v2/device_list/presentation/cubit/bind_device_cubit.dart';
import '../../features/v2/waring_center/data/datasources/alarm_remote_datasource.dart';
import '../../features/v2/waring_center/data/datasources/impl/alarm_remote_datasource_impl.dart';
import '../../features/v2/waring_center/data/repositories/alarm_repository_impl.dart';
import '../../features/v2/waring_center/domain/repositories/alarm_repository.dart';
import '../../features/v2/waring_center/domain/usecases/get_alarm_count_usecase.dart';
import '../../features/v2/waring_center/domain/usecases/get_alarm_list_usecase.dart';
import '../../features/v2/waring_center/presentation/bloc/alarm_cubit.dart';
import '../../features/v2/waring_center/data/datasources/alarm_detail_remote_datasource.dart';
import '../../features/v2/waring_center/data/datasources/impl/alarm_detail_remote_datasource_impl.dart';
import '../../features/v2/waring_center/data/repositories/alarm_detail_repository_impl.dart';
import '../../features/v2/waring_center/domain/repositories/alarm_detail_repository.dart';
import '../../features/v2/waring_center/domain/usecases/get_alarm_detail_usecase.dart';
import '../../features/v2/waring_center/domain/usecases/alarm_actions_usecase.dart';
import '../../features/v2/waring_center/presentation/bloc/alarm_detail_cubit.dart';
import '../../features/v2/message_center/data/datasources/message_center_remote_datasource.dart';
import '../../features/v2/message_center/data/datasources/impl/message_center_remote_datasource_impl.dart';
import '../../features/v2/message_center/data/repositories/message_center_repository_impl.dart';
import '../../features/v2/message_center/domain/repositories/message_center_repository.dart';
import '../../features/v2/message_center/domain/usecases/message_center_usecase.dart';
import '../../features/v2/message_center/presentation/bloc/message_center_cubit.dart';
import '../../features/v2/work_order/data/datasources/work_order_remote_datasource.dart';
import '../../features/v2/work_order/data/datasources/work_order_remote_datasource_impl.dart';
import '../../features/v2/work_order/data/repositories/work_order_repository_impl.dart';
import '../../features/v2/work_order/domain/repositories/work_order_repository.dart';
import '../../features/v2/work_order/domain/usecases/work_order_usecases.dart';
import '../../features/v2/work_order/presentation/cubit/work_order_cubit.dart';
import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource.dart';
import '../../features/v2/device_run_param/data/datasources/device_run_param_remote_datasource_impl.dart';
import '../../features/v2/device_run_param/data/repositories/device_run_param_repository_impl.dart';
import '../../features/v2/device_run_param/domain/repositories/device_run_param_repository.dart';
import '../../features/v2/device_run_param/domain/usecases/device_run_param_usecases.dart';
import '../services/device_permission_service.dart';
import '../../features/devices/data/datasources/device_task_datasource.dart';
import '../../features/devices/data/repositories/device_task_repository_impl.dart';
import '../../features/devices/domain/repositories/device_task_repository.dart';
import '../../features/devices/domain/usecases/get_device_task_pool_usecase.dart';
import '../../features/devices/domain/usecases/cancel_task_usecase.dart';
import '../../features/devices/domain/usecases/pause_task_usecase.dart';
import '../../features/devices/domain/usecases/recovery_task_usecase.dart';
import '../../features/devices/presentation/bloc/device_task_cubit.dart';
import '../app/app_user_cubit.dart';
import '../localization/locale_cubit.dart';
import '../theme/theme_cubit.dart';
import '../../features/home/presentation/bloc/permission_request_bloc.dart';
import '../network/dio_client.dart';
import '../network/net_message_dispatcher.dart';
import '../network/tcp/tcp_client.dart';
import '../network/tcp/tcp_status_cubit.dart';
import '../network/mqtt/domain/interfaces/mqtt_client.dart';
import '../network/mqtt/data/infrastructure/mqtt_client_impl.dart';
import '../network/mqtt/data/datasources/drone_osd_datasource.dart';
import '../network/mqtt/data/datasources/task_message_datasource.dart';
import '../network/mqtt/data/datasources/mower_realtime_datasource.dart';
import '../network/mqtt/domain/repositories/drone_osd_repository.dart';
import '../network/mqtt/domain/repositories/task_message_repository.dart';
import '../network/mqtt/domain/repositories/mower_realtime_repository.dart';
import '../network/mqtt/data/repositories/drone_osd_repository_impl.dart';
import '../network/mqtt/data/repositories/task_message_repository_impl.dart';
import '../network/mqtt/data/repositories/mower_realtime_repository_impl.dart';
import '../router/app_router.dart';
import '../storage/impl/user_storage_impl.dart';
import '../storage/user_storage.dart';
@@ -75,17 +174,38 @@ Future<void> init() async {
/// 1.1.2 TcpClient:TCP客户端
sl.registerLazySingleton(
() => TcpClient(sl<UserStorage>(), getUserDeviceUseCase: sl<GetUserDeviceUseCase>(), switchDeviceUseCase: sl<SwitchDeviceUseCase>()),
() => TcpClient(
sl<UserStorage>(),
getUserDeviceUseCase: sl<GetUserDeviceUseCase>(),
switchDeviceUseCase: sl<SwitchDeviceUseCase>(),
),
);
sl.registerLazySingleton(() => PathPlanningService());
/// 1.1.3 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它
sl.registerLazySingleton(() => NetMessageDispatcher(
sl<TcpClient>(),
sl<RoutePlanningRepository>(),
sl<PathPlanningService>(),
getAppState: () => sl<DevicesCubit>().state.appState,
));
/// 1.1.3 MQTT Clients - 三个独立的 MQTT 客户端实例
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(clientName: 'droneOsdClient'),
instanceName: 'droneOsdClient',
);
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(clientName: 'taskMessageClient'),
instanceName: 'taskMessageClient',
);
// 🔥 割草机机器状态专用 MQTT 客户端(1.95.137.212:59007)
sl.registerLazySingleton<MqttClient>(
() => MqttClientImpl(clientName: 'mowerRealtimeClient'),
instanceName: 'mowerRealtimeClient',
);
/// 1.1.4 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它
sl.registerLazySingleton(
() => NetMessageDispatcher(
sl<TcpClient>(),
sl<RoutePlanningRepository>(),
sl<PathPlanningService>(),
getAppState: () => sl<DevicesCubit>().state.appState,
),
);
/// 1.2 --- 本地存储 (LocalStorage) ---
/// 1.2.1 SharedPreferences:本地存储库
@@ -101,17 +221,55 @@ Future<void> init() async {
/// 1.3 Log工具Sentry
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
/// 1.4 --- Route Observer (路由监听器) ---
sl.registerLazySingleton<RouteObserver>(
() => RouteObserver<ModalRoute<void>>(),
);
/// 1.5 --- MQTT Data Sources ---
/// 改为 LazySingleton:列表页预启动订阅和详情页共享同一实例,stream 互通
sl.registerLazySingleton<DroneOsdDataSource>(
() =>
DroneOsdDataSourceImpl(sl<MqttClient>(instanceName: 'droneOsdClient')),
);
sl.registerFactory<TaskMessageDataSource>(
() => TaskMessageDataSourceImpl(
sl<MqttClient>(instanceName: 'taskMessageClient'),
),
);
/// 割草机机器状态(MQTT):车辆实时+定位,LazySingleton 跨页面共享同一订阅
/// 🔥 使用独立 MQTT 连接 mowerRealtimeClient(1.95.137.212:59007)
sl.registerLazySingleton<MowerRealtimeDataSource>(
() => MowerRealtimeDataSourceImpl(
sl<MqttClient>(instanceName: 'mowerRealtimeClient'),
),
);
/// 1.5 --- MQTT Repositories ---
sl.registerLazySingleton<DroneOsdRepository>(
() => DroneOsdRepositoryImpl(sl<DroneOsdDataSource>()),
);
sl.registerLazySingleton<TaskMessageRepository>(
() => TaskMessageRepositoryImpl(sl<TaskMessageDataSource>()),
);
sl.registerLazySingleton<MowerRealtimeRepository>(
() => MowerRealtimeRepositoryImpl(sl<MowerRealtimeDataSource>()),
);
/// 2. 数据源 (DataSource)
sl.registerLazySingleton<AuthHttpDataSource>(() => AuthHttpDataSourceImpl(sl()));
sl.registerLazySingleton<AuthHttpDataSource>(
() => AuthHttpDataSourceImpl(sl()),
);
sl.registerLazySingleton<DeviceHttpDatasource>(
() => DeviceHttpDatasourceImpl(sl(), sl<UserStorage>()), //
);
sl.registerLazySingleton<RemoteHttpDatasource>(
() => RemoteHttpDatasource(sl(), sl<UserStorage>()),
() => RemoteHttpDatasource(sl(), sl<UserStorage>()),
);
sl.registerLazySingleton<RemoteTcpDatasource>(
() => RemoteTcpDatasource(sl<TcpClient>(), sl<UserStorage>()),
() => RemoteTcpDatasource(sl<TcpClient>(), sl<UserStorage>()),
);
/// 3. 仓库 (Repository)
@@ -157,19 +315,27 @@ Future<void> init() async {
);
sl.registerLazySingleton<DeviceRepository>(() => DeviceRepositoryImpl(sl()));
sl.registerLazySingleton<RemoteControlRepository>(() => RemoteControlRepositoryImpl(sl(), sl() ,sl(),sl()));
sl.registerLazySingleton<RemoteControlRepository>(
() => RemoteControlRepositoryImpl(sl(), sl(), sl(), sl()),
);
/// 4. 用例 (UseCase)
sl.registerLazySingleton(() => LoginUseCase(sl()));
sl.registerLazySingleton(() => GetUserDeviceUseCase(sl()));
sl.registerLazySingleton(() => DiffSteerUseCase());
sl.registerLazySingleton(() => DeviceWorkHostrirty(sl()));
sl.registerLazySingleton<SelectWorkRecordUseCase>(() => SelectWorkRecordUseCase(sl<PathRepository>()));
sl.registerLazySingleton<SelectWorkRecordUseCase>(
() => SelectWorkRecordUseCase(sl<PathRepository>()),
);
sl.registerLazySingleton(() => UnbindDeviceUseCase(sl()));
sl.registerLazySingleton(() => BindDeviceUseCase(sl()));
sl.registerLazySingleton(() => UpdateDevicenameUsecase(sl()));
sl.registerLazySingleton<SaveWorkRecordUseCase>(() => SaveWorkRecordUseCase(sl<PathRepository>()));
sl.registerLazySingleton<SwitchDeviceUseCase>(() => SwitchDeviceUseCase(sl()));
sl.registerLazySingleton<SaveWorkRecordUseCase>(
() => SaveWorkRecordUseCase(sl<PathRepository>()),
);
sl.registerLazySingleton<SwitchDeviceUseCase>(
() => SwitchDeviceUseCase(sl()),
);
sl.registerLazySingleton<AuthTcpDatasource>(
() => AuthTcpDatasourceImpl(
@@ -180,32 +346,195 @@ Future<void> init() async {
),
);
sl.registerLazySingleton<RequestControlPermissionUseCase>(() => RequestControlPermissionUseCase(sl()));
sl.registerLazySingleton<RequestControlPermissionUseCase>(
() => RequestControlPermissionUseCase(sl()),
);
/// Home V2
sl.registerLazySingleton<HomeRemoteDataSource>(
() => HomeRemoteDataSourceImpl(),
);
sl.registerLazySingleton<HomeRepository>(() => HomeRepositoryImpl(sl()));
sl.registerLazySingleton<GetHomeDataUseCase>(() => GetHomeDataUseCase(sl()));
// Site (场站)
sl.registerLazySingleton<SiteDataSource>(
() => SiteDataSourceImpl(sl<Dio>(), sl<UserStorage>(), sl<AppUserCubit>()),
);
sl.registerLazySingleton<SiteRepository>(() => SiteRepositoryImpl(sl()));
sl.registerLazySingleton<GetSiteListUseCase>(() => GetSiteListUseCase(sl()));
sl.registerFactory<HomeV2Bloc>(
() => HomeV2Bloc(sl(), sl(), sl<AppUserCubit>(), sl<SiteCubit>()),
);
/// Device Status V2
sl.registerLazySingleton<device_v2.DeviceRemoteDataSource>(
() => device_v2.DeviceRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<device_v2_domain.DeviceRepository>(
() => device_v2_repo.DeviceRepositoryImpl(remoteDataSource: sl()),
);
sl.registerLazySingleton<device_v2_usecase.GetDeviceStatusDataUseCase>(
() => device_v2_usecase.GetDeviceStatusDataUseCase(repository: sl()),
);
sl.registerLazySingleton<GetAllDevicesUseCase>(
() => GetAllDevicesUseCase(repository: sl()),
);
sl.registerLazySingleton<GetDeviceBySpiltCodeUseCase>(
() => GetDeviceBySpiltCodeUseCase(repository: sl()),
);
sl.registerFactory<device_v2_bloc.DeviceStatusBloc>(
() => device_v2_bloc.DeviceStatusBloc(sl()),
);
/// Drone Station V2
sl.registerLazySingleton<DroneStationDataSource>(
() => DroneStationDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<DroneStationRepository>(
() => DroneStationRepositoryImpl(sl()),
);
sl.registerLazySingleton<GetDroneStationListUseCase>(
() => GetDroneStationListUseCase(sl()),
);
sl.registerLazySingleton<GetUAVDetailUseCase>(
() => GetUAVDetailUseCase(sl()),
);
sl.registerLazySingleton<GetVideoStreamUseCase>(
() => GetVideoStreamUseCase(sl()),
);
sl.registerLazySingleton<GetUavVideoStreamUseCase>(
() => GetUavVideoStreamUseCase(sl()),
);
sl.registerLazySingleton<UpdateFlightTaskStatusUseCase>(
() => UpdateFlightTaskStatusUseCase(sl()),
);
sl.registerLazySingleton<PauseFlightTaskUseCase>(
() => PauseFlightTaskUseCase(sl()),
);
sl.registerLazySingleton<ReturnHomeUseCase>(() => ReturnHomeUseCase(sl()));
sl.registerFactory<DroneStationBloc>(
() => DroneStationBloc(sl(), sl(), sl(), sl()),
);
/// Robot List V2
sl.registerFactory<RobotListBloc>(() => RobotListBloc(sl<Dio>()));
/// Device Realtime V2 (MQTT)
sl.registerFactory<DeviceRealtimeBloc>(
() => DeviceRealtimeBloc(sl<TaskMessageRepository>()),
);
/// Alarm Center V2
sl.registerLazySingleton<AlarmRemoteDataSource>(
() => AlarmRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<AlarmRepository>(() => AlarmRepositoryImpl(sl()));
sl.registerLazySingleton<GetAlarmListUseCase>(
() => GetAlarmListUseCase(sl()),
);
sl.registerLazySingleton<GetAlarmCountUseCase>(
() => GetAlarmCountUseCase(sl()),
);
sl.registerFactory<AlarmCubit>(() => AlarmCubit(getAlarmListUseCase: sl()));
/// Alarm Detail V2
sl.registerLazySingleton<AlarmDetailRemoteDataSource>(
() => AlarmDetailRemoteDataSourceImpl(sl()),
);
sl.registerLazySingleton<AlarmDetailRepository>(
() => AlarmDetailRepositoryImpl(sl()),
);
sl.registerLazySingleton<GetAlarmDetailUseCase>(
() => GetAlarmDetailUseCase(sl()),
);
sl.registerLazySingleton<ConfirmAlarmUseCase>(
() => ConfirmAlarmUseCase(sl()),
);
sl.registerLazySingleton<HandleAlarmUseCase>(() => HandleAlarmUseCase(sl()));
sl.registerLazySingleton<AIDiagnosisUseCase>(() => AIDiagnosisUseCase(sl()));
sl.registerFactory<AlarmDetailCubit>(
() => AlarmDetailCubit(
getAlarmDetailUseCase: sl(),
confirmAlarmUseCase: sl(),
handleAlarmUseCase: sl(),
aiDiagnosisUseCase: sl(),
),
);
/// 5. 状态管理 (Cubit/Bloc)
sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册
// TCP状态管理 Cubit
sl.registerLazySingleton(() => TcpStatusCubit());
// 🔥 SiteCubit (全局共享,持久化选中场站)
sl.registerLazySingleton(() => SiteCubit(sl<SharedPreferences>()));
// 🔥 FloatBarSettingService (全局共享,控制悬浮条显示)
sl.registerLazySingleton(
() => FloatBarSettingService(sl<SharedPreferences>()),
);
// 🔥 语言管理 Cubit (单例)
sl.registerLazySingleton(() => LocaleCubit());
// 🔥 主题管理 Cubit (单例,管理亮/暗/跟随系统)
sl.registerLazySingleton(() => ThemeCubit());
// Tab 配置 Cubit (单例)
sl.registerLazySingleton(() => TabConfigCubit(sl<SharedPreferences>()));
sl.registerLazySingleton(() => GetDeviceLocationUseCase(sl()));
sl.registerLazySingleton(() => DevicesCubit(sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(),sl(),sl(),sl()));
// 🔥 DeviceStatusBloc 必须在 RemoteControlCubit 之前注册(单例)
sl.registerLazySingleton(() => DeviceStatusBloc(
sl<NetMessageDispatcher>(),
client: sl<TcpClient>(), // 🔥 传入 TcpClient
));
// 🔥 RemoteControlCubit 注入 DeviceStatusBloc(工厂模式,每次新建)
sl.registerFactory(() => RemoteControlCubit(sl(), sl(), sl(), sl()));
sl.registerLazySingleton(
() => DevicesCubit(
sl(), // repository
sl(), // GetUserDeviceUseCase
sl(), // GetDeviceLocationUseCase
sl(), // GetWorkRecordUseCase
sl(), // GetWorkRecordsBySiteIdUseCase (NEW)
sl(), // DeleteWorkRecordUseCase
sl(), // UnbindDeviceUseCase
sl(), // UpdateDevicenameUsecase
sl(), // SelectWorkRecordUseCase
sl(), // SaveWorkRecordUseCase
sl(), // GeneratePathUseCase
sl(), // RoutePlanningUseCase
sl(), // BindDeviceUseCase
sl(), // DeviceStatusBloc
sl(), // TcpClient
sl(), // PathPlanningService
),
);
// DeviceStatusBloc 必须在 RemoteControlCubit 之前注册(单例)
sl.registerLazySingleton(
() => DeviceStatusBloc(
sl<NetMessageDispatcher>(),
client: sl<TcpClient>(), // 🔥 传入 TcpClient
),
);
// 🔥 RemoteControlCubit 注入 DeviceStatusBloc(单例模式,全局共享)
sl.registerLazySingleton(
() => RemoteControlCubit(
sl(), // RemoteControlRepository
sl(), // RequestControlPermissionUseCase
sl(), // DeviceRepository
sl<TcpClient>(), // TcpClient
sl(), // NetMessageDispatcher
sl(), // DeviceStatusBloc
),
);
// 🔥 PermissionRequestBloc 用于首页权限弹窗(单例,通过 NetMessageDispatcher 监听)
sl.registerLazySingleton(() => PermissionRequestBloc(
sl<NetMessageDispatcher>(),
sl<RemoteControlRepository>(),
));
sl.registerLazySingleton(
() => PermissionRequestBloc(
sl<NetMessageDispatcher>(),
sl<RemoteControlRepository>(),
),
);
/// . 路径生成
sl.registerLazySingleton<PathHttpDatasource>(
@@ -214,16 +543,36 @@ Future<void> init() async {
sl<UserStorage>(),
),
);
sl.registerLazySingleton<PathRepository>(() => PathRepositoryImpl(datasource: sl<PathHttpDatasource>()));
sl.registerLazySingleton<GeneratePathUseCase>(() => GeneratePathUseCaseImpl(sl<PathRepository>()));
sl.registerLazySingleton<PathRepository>(
() => PathRepositoryImpl(datasource: sl<PathHttpDatasource>()),
);
sl.registerLazySingleton<GeneratePathUseCase>(
() => GeneratePathUseCaseImpl(sl<PathRepository>()),
);
///.路径获取和删除
sl.registerLazySingleton(() => GetWorkRecordUseCase(sl<PathRepository>()));
sl.registerLazySingleton(() => DeleteWorkRecordUseCase(sl<PathRepository>()));
/// 根据场站ID获取工作记录(XML格式)
sl.registerLazySingleton(
() => GetWorkRecordsBySiteIdUseCase(sl<PathRepository>()),
);
/// 创建设备任务(通过接口执行作业)
sl.registerLazySingleton(() => CreateDeviceTaskUseCase(sl<PathRepository>()));
/// 6. 认证 (Auth)
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
sl.registerLazySingleton(() => AuthCubit(sl<UserStorage>(), sl<TcpClient>(), sl<AppUserCubit>(), sl<NetMessageDispatcher>(), sl<AuthTcpDatasource>()));
sl.registerLazySingleton(
() => AuthCubit(
sl<UserStorage>(),
sl<TcpClient>(),
sl<AppUserCubit>(),
sl<NetMessageDispatcher>(),
sl<AuthTcpDatasource>(),
),
);
/// 7. 路由 (Router)
sl.registerSingleton<GoRouter>(createRouter(sl<AuthCubit>()));
@@ -231,4 +580,116 @@ Future<void> init() async {
/// 8. 页面级 Bloc/Cubit (Factory)
sl.registerFactory(() => LoginBloc(sl()));
sl.registerFactory(() => LoginCubit(sl(), sl()));
/// 9. 消息中心 (Message Center)
sl.registerLazySingleton<MessageCenterRemoteDataSource>(
() => MessageCenterRemoteDataSourceImpl(),
);
sl.registerLazySingleton<MessageCenterRepository>(
() => MessageCenterRepositoryImpl(sl()),
);
sl.registerLazySingleton<GetMessageListUseCase>(
() => GetMessageListUseCase(sl()),
);
sl.registerLazySingleton<MarkAllAsReadUseCase>(
() => MarkAllAsReadUseCase(sl()),
);
sl.registerFactory<MessageCenterCubit>(
() => MessageCenterCubit(
getMessageListUseCase: sl(),
markAllAsReadUseCase: sl(),
),
);
/// 10. 设备任务管理 (Device Task)
sl.registerLazySingleton<DeviceTaskDatasource>(
() => DeviceTaskDatasourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<DeviceTaskRepository>(
() => DeviceTaskRepositoryImpl(datasource: sl<DeviceTaskDatasource>()),
);
sl.registerLazySingleton(() => GetDeviceTaskPoolUseCase(sl()));
sl.registerLazySingleton(() => CancelTaskUseCase(sl()));
sl.registerLazySingleton(() => PauseTaskUseCase(sl()));
sl.registerLazySingleton(() => RecoveryTaskUseCase(sl()));
sl.registerLazySingleton(() => DeviceTaskCubit(sl(), sl(), sl(), sl()));
/// 11. 工单管理 (Work Order)
sl.registerLazySingleton<WorkOrderRemoteDataSource>(
() => WorkOrderRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<WorkOrderRepository>(
() => WorkOrderRepositoryImpl(remoteDataSource: sl()),
);
sl.registerLazySingleton<GetWorkOrderListUseCase>(
() => GetWorkOrderListUseCase(sl()),
);
sl.registerLazySingleton<GetWorkOrderDetailUseCase>(
() => GetWorkOrderDetailUseCase(sl()),
);
sl.registerLazySingleton<DispatchWorkOrderUseCase>(
() => DispatchWorkOrderUseCase(sl()),
);
sl.registerLazySingleton<SuspendWorkOrderUseCase>(
() => SuspendWorkOrderUseCase(sl()),
);
sl.registerLazySingleton<CompleteWorkOrderUseCase>(
() => CompleteWorkOrderUseCase(sl()),
);
sl.registerLazySingleton<StartWorkOrderUseCase>(
() => StartWorkOrderUseCase(sl()),
);
sl.registerFactory<WorkOrderCubit>(
() => WorkOrderCubit(
getWorkOrderListUseCase: sl(),
getWorkOrderDetailUseCase: sl(),
dispatchWorkOrderUseCase: sl(),
suspendWorkOrderUseCase: sl(),
completeWorkOrderUseCase: sl(),
startWorkOrderUseCase: sl(),
),
);
/// 12. 设备运行参数管理 (Device Run Param)
sl.registerLazySingleton<DeviceRunParamRemoteDataSource>(
() => DeviceRunParamRemoteDataSourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<DeviceRunParamRepository>(
() => DeviceRunParamRepositoryImpl(remoteDataSource: sl()),
);
sl.registerLazySingleton<GetDeviceRunParamUseCase>(
() => GetDeviceRunParamUseCase(sl()),
);
sl.registerLazySingleton<SaveDeviceRunParamUseCase>(
() => SaveDeviceRunParamUseCase(sl()),
);
/// 13. 设备操作权限校验服务 (Device Permission Service)
sl.registerLazySingleton<DevicePermissionService>(
() => DevicePermissionService(sl<Dio>()),
);
/// 14. 绑定智能装备 (Bind Device)
sl.registerLazySingleton<BindDeviceDatasource>(
() => BindDeviceDatasourceImpl(sl<Dio>()),
);
sl.registerLazySingleton<BindDeviceRepository>(
() => BindDeviceRepositoryImpl(sl<BindDeviceDatasource>()),
);
sl.registerLazySingleton<GetOrgListUseCase>(() => GetOrgListUseCase(sl()));
sl.registerLazySingleton<GetSitesByOrgUseCase>(
() => GetSitesByOrgUseCase(sl()),
);
sl.registerLazySingleton<GetUsersBySiteUseCase>(
() => GetUsersBySiteUseCase(sl()),
);
sl.registerLazySingleton<BindDeviceV2UseCase>(
() => BindDeviceV2UseCase(sl()),
);
sl.registerLazySingleton<IsDeviceAtSiteUseCase>(
() => IsDeviceAtSiteUseCase(sl()),
);
sl.registerFactory<BindDeviceCubit>(
() => BindDeviceCubit(sl(), sl(), sl(), sl(), sl(), sl<AppUserCubit>()),
);
}

View File

@@ -5,18 +5,24 @@ class UserEntity extends Equatable {
final String username;
final String nickname;
final String token;
final int orgId;
final String? avatar;
final String? email;
final String? phone;
final String? roleKey;
final int? siteId;
UserEntity({
const UserEntity({
required this.userId,
required this.username,
required this.nickname,
required this.token,
required this.orgId,
this.avatar,
this.email,
this.phone,
this.roleKey,
this.siteId,
});
@override
@@ -26,7 +32,36 @@ class UserEntity extends Equatable {
nickname,
avatar,
token,
orgId,
email,
phone,
roleKey,
siteId,
];
}
UserEntity copyWith({
String? userId,
String? username,
String? nickname,
String? token,
int? orgId,
String? avatar,
String? email,
String? phone,
String? roleKey,
int? siteId,
}) {
return UserEntity(
userId: userId ?? this.userId,
username: username ?? this.username,
nickname: nickname ?? this.nickname,
token: token ?? this.token,
orgId: orgId ?? this.orgId,
avatar: avatar ?? this.avatar,
email: email ?? this.email,
phone: phone ?? this.phone,
roleKey: roleKey ?? this.roleKey,
siteId: siteId ?? this.siteId,
);
}
}

View File

@@ -1,7 +1,7 @@
class EnvConfig {
static const String environment = String.fromEnvironment(
'ENV',
defaultValue: 'dev',
defaultValue: 'prod',
);
static String get sentryDsn {
@@ -13,4 +13,19 @@ class EnvConfig {
}
static bool get isProduction => environment == 'prod';
// ─── 服务器 IP(测试/生产共用同一台机器) ───
static const String serverHost = '1.95.137.212';
// ─── TCP 控制长连接 ───
static String get tcpIp => serverHost;
static int get tcpPort => isProduction ? 9001 : 59004;
// ─── HTTP 接口 ───
static int get httpPort => isProduction ? 8081 : 59003;
static String get baseUrl => 'http://$serverHost:$httpPort';
// ─── MQTT(一方后端:任务消息 / 割草机实时状态,prod=1883 / test=59007)───
// 注:第三方无人机 OSD(droneOsd,WebSocket 8083)不纳入环境管理,保持硬编码
static int get mqttPort => isProduction ? 1883 : 59007;
}

View File

@@ -6,3 +6,7 @@ class Failure {
class NetworkFailure extends Failure {
NetworkFailure(super.message);
}
class ServerFailure extends Failure {
ServerFailure(super.message);
}

View File

@@ -0,0 +1,10 @@
import 'package:maibu_satabot_v2/core/error/failure.dart';
/// 工单模块特定的失败类型
class WorkOrderFailure extends Failure {
WorkOrderFailure(super.message);
}
class UnknownFailure extends WorkOrderFailure {
UnknownFailure({required String message}) : super(message);
}

View File

@@ -0,0 +1,14 @@
/// 链路日志统一时间戳工具
///
/// 用于打印"什么时间做了什么"的全链路日志,格式:[HH:mm:ss.SSS]
class LogTime {
LogTime._();
static String now() {
final now = DateTime.now();
return '[${now.hour.toString().padLeft(2, '0')}:'
'${now.minute.toString().padLeft(2, '0')}:'
'${now.second.toString().padLeft(2, '0')}.'
'${now.millisecond.toString().padLeft(3, '0')}]';
}
}

View File

@@ -0,0 +1,180 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../features/v2/device_list/domain/entities/drone_station_entity.dart';
/// 无人机轨迹点(轻量级,避免在核心层引入 latlong2 依赖)
class DroneTrajectoryPoint {
final double latitude;
final double longitude;
final double? heading;
const DroneTrajectoryPoint({
required this.latitude,
required this.longitude,
this.heading,
});
@override
String toString() =>
'DroneTrajectoryPoint(lat=$latitude, lng=$longitude, heading=$heading)';
}
/// 无人机任务信息
class DroneTaskInfo {
final String droneSn;
final String gatewaySn;
final List<CameraInfo>? cameraList;
final bool isOnline; // 无人机是否在线
final String stationName; // 机场名称/呼号
final String droneName; // 无人机名称/呼号
DroneTaskInfo({
required this.droneSn,
required this.gatewaySn,
this.cameraList,
this.isOnline = false,
this.stationName = '',
this.droneName = '',
});
}
/// 无人机任务状态管理器
/// 使用 ValueNotifier 实现响应式状态管理
/// 支持在机场页面点击无人机卡片时实时同步到 FloatBarWidget
class DroneTaskStateManager {
static final DroneTaskStateManager _instance =
DroneTaskStateManager._internal();
factory DroneTaskStateManager() => _instance;
DroneTaskStateManager._internal();
/// 当前无人机任务信息(响应式状态)
final ValueNotifier<DroneTaskInfo?> _currentTaskInfo =
ValueNotifier<DroneTaskInfo?>(null);
/// 任务下发成功后,等待无人机 OSD 推送数据(实时信息)
final ValueNotifier<bool> _isWaitingForOsdPush = ValueNotifier<bool>(false);
/// 任务下发成功后,等待无人机视频流
final ValueNotifier<bool> _isWaitingForVideo = ValueNotifier<bool>(false);
/// 无人机飞行轨迹点(跨页面持久化,退出视频页后不丢失)
final ValueNotifier<List<DroneTrajectoryPoint>> _trajectoryPoints =
ValueNotifier<List<DroneTrajectoryPoint>>([]);
/// 超时定时器,避免一直转圈(推送/视频迟迟不到)
Timer? _osdWaitTimeoutTimer;
Timer? _videoWaitTimeoutTimer;
/// 监听无人机任务信息变化
ValueListenable<DroneTaskInfo?> get currentTaskInfo => _currentTaskInfo;
/// 监听“等待 OSD 推送”状态
ValueListenable<bool> get isWaitingForOsdPush => _isWaitingForOsdPush;
/// 监听“等待视频流”状态
ValueListenable<bool> get isWaitingForVideo => _isWaitingForVideo;
/// 监听无人机轨迹点
ValueListenable<List<DroneTrajectoryPoint>> get trajectoryPoints =>
_trajectoryPoints;
/// 获取当前无人机任务信息
DroneTaskInfo? get currentInfo => _currentTaskInfo.value;
/// 设置当前无人机任务信息
/// 支持两种触发方式:
/// 1. 任务下发时自动触发
/// 2. 点击机场页面无人机卡片时触发
void setDroneTaskInfo(DroneTaskInfo info) {
debugPrint('🛸 [DroneTaskStateManager] 设置无人机任务信息');
debugPrint(' droneSn: ${info.droneSn}');
debugPrint(' gatewaySn: ${info.gatewaySn}');
_currentTaskInfo.value = info;
}
/// 任务下发成功后调用:开始监测无人机实时推送与视频流
/// 在接口返回成功之后调用
void markTaskIssued() {
debugPrint('🚀 [DroneTaskStateManager] 任务已下发,开始监测推送数据与视频流');
_isWaitingForOsdPush.value = true;
_isWaitingForVideo.value = true;
_osdWaitTimeoutTimer?.cancel();
_videoWaitTimeoutTimer?.cancel();
// 超时兜底:90 秒后仍未收到 OSD 推送,自动停止转圈
_osdWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
if (_isWaitingForOsdPush.value) {
debugPrint('⚠️ [DroneTaskStateManager] 等待 OSD 推送超时,自动停止');
_isWaitingForOsdPush.value = false;
}
});
// 超时兜底:90 秒后仍未收到视频,自动停止 toast
_videoWaitTimeoutTimer = Timer(const Duration(seconds: 90), () {
if (_isWaitingForVideo.value) {
debugPrint('⚠️ [DroneTaskStateManager] 等待视频流超时,自动停止');
_isWaitingForVideo.value = false;
}
});
}
/// 收到无人机 OSD 推送数据后调用:停止转圈,恢复绿点
void markOsdPushReceived() {
if (_isWaitingForOsdPush.value) {
debugPrint('✅ [DroneTaskStateManager] 收到 OSD 推送数据,停止监测');
_isWaitingForOsdPush.value = false;
_osdWaitTimeoutTimer?.cancel();
_osdWaitTimeoutTimer = null;
}
}
/// 收到无人机视频流后调用:隐藏"视频获取中" toast
void markVideoReceived() {
if (_isWaitingForVideo.value) {
debugPrint('✅ [DroneTaskStateManager] 收到视频流,停止监测');
_isWaitingForVideo.value = false;
_videoWaitTimeoutTimer?.cancel();
_videoWaitTimeoutTimer = null;
}
}
/// 添加无人机轨迹点
void addTrajectoryPoint(DroneTrajectoryPoint point) {
final list = List<DroneTrajectoryPoint>.from(_trajectoryPoints.value);
list.add(point);
// 性能优化:只保留最近 1000 个点
if (list.length > 1000) {
list.removeAt(0);
}
_trajectoryPoints.value = list;
}
/// 批量设置轨迹点(恢复历史轨迹时使用)
void setTrajectoryPoints(List<DroneTrajectoryPoint> points) {
_trajectoryPoints.value = List<DroneTrajectoryPoint>.from(points);
}
/// 清空轨迹
void clearTrajectory() {
_trajectoryPoints.value = [];
}
/// 清除无人机任务信息
void clearDroneTaskInfo() {
debugPrint('🛸 [DroneTaskStateManager] 清除无人机任务信息');
_currentTaskInfo.value = null;
_osdWaitTimeoutTimer?.cancel();
_videoWaitTimeoutTimer?.cancel();
_osdWaitTimeoutTimer = null;
_videoWaitTimeoutTimer = null;
_isWaitingForOsdPush.value = false;
_isWaitingForVideo.value = false;
}
/// 检查是否有当前任务
bool get hasTask => _currentTaskInfo.value != null;
}
/// 全局管理器实例
final droneTaskStateManager = DroneTaskStateManager();

View File

@@ -15,7 +15,13 @@ class DioClient {
),
);
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
dio.interceptors.add(
LogInterceptor(
requestBody: true,
responseBody: true,
requestHeader: true,
),
);
dio.interceptors.add(
InterceptorsWrapper(
@@ -30,22 +36,29 @@ class DioClient {
return handler.next(options);
},
// ======================
// ✅ 关键:401 自动拦截
// ======================
onResponse: (response, handler) {
if (response.data is Map<String, dynamic>) {
final code = response.data['code'];
if (code == 401 || code == 403) {
print(
'>>> [DIO] 🚨🚨🚨 收到业务错误码 $code,触发 Token 过期处理!URL: ${response.requestOptions.uri},时间: ${DateTime.now()}',
);
try {
sl<AuthCubit>().tokenExpired();
} catch (ex) {}
}
}
return handler.next(response);
},
onError: (DioException e, handler) async {
// 401 = token 过期 / 未授权
if (e.response?.statusCode == 401) {
if (e.response?.statusCode == 401 || e.response?.statusCode == 403) {
print(
'>>> [DIO] 🚨🚨🚨 收到 HTTP ${e.response?.statusCode},触发 Token 过期处理!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}',
);
try {
// 调用 logout 清除本地缓存 + 跳登录
await sl<AuthCubit>().logout();
} catch (ex) {
// 防止报错
}
sl<AuthCubit>().tokenExpired();
} catch (ex) {}
}
return handler.next(e);

View File

@@ -0,0 +1,70 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
/// 统一错误处理器
/// 所有接口错误都应该通过这个类来处理,避免显示原始错误信息
class ErrorHandler {
/// 处理错误并显示友好提示
static void handleError(BuildContext context, Object error) {
String message = _getFriendlyErrorMessage(error);
// 显示友好提示(自动消失)
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
/// 获取友好的错误消息
static String _getFriendlyErrorMessage(Object error) {
if (error is DioException) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return '网络连接超时,请检查网络设置';
case DioExceptionType.connectionError:
return '网络连接失败,请检查网络';
case DioExceptionType.badResponse:
final statusCode = error.response?.statusCode;
if (statusCode == 401) {
return '登录已过期,请重新登录';
} else if (statusCode == 403) {
return '没有权限执行此操作';
} else if (statusCode == 404) {
return '请求的资源不存在';
} else if (statusCode == 500) {
return '服务器异常,请稍后重试';
} else {
return '服务器响应异常';
}
case DioExceptionType.cancel:
return '请求已取消';
case DioExceptionType.badCertificate:
return '证书验证失败';
default:
return '请求失败,请稍后重试';
}
} else if (error is Exception) {
// 业务逻辑异常
return '操作失败,请稍后重试';
} else {
// 未知错误
return '未知错误,请稍后重试';
}
}
/// 在 Cubit/Bloc 中使用的静态方法(不需要 context)
static String getErrorMessage(Object error) {
return _getFriendlyErrorMessage(error);
}
}

View File

@@ -0,0 +1,166 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_message.dart';
import '../../domain/entities/drone_osd_entity.dart';
abstract class DroneOsdDataSource {
Stream<DroneOsdEntity> get droneOsdStream;
Stream<DroneOsdEntity> get stationOsdStream;
Future<void> startListening({
required String deviceSn,
required String gatewaySn,
});
Future<void> stopListening();
void dispose();
}
class DroneOsdDataSourceImpl implements DroneOsdDataSource {
final MqttClient mqttClient;
final _droneOsdController = StreamController<DroneOsdEntity>.broadcast();
final _stationOsdController = StreamController<DroneOsdEntity>.broadcast();
StreamSubscription<MqttMessage>? _subscription;
String? _deviceSn;
String? _gatewaySn;
int _listenerCount = 0;
bool _isDisposed = false;
DroneOsdDataSourceImpl(this.mqttClient);
@override
Stream<DroneOsdEntity> get droneOsdStream => _droneOsdController.stream;
@override
Stream<DroneOsdEntity> get stationOsdStream => _stationOsdController.stream;
@override
Future<void> startListening({
required String deviceSn,
required String gatewaySn,
}) async {
if (_isDisposed) return;
// 🔥 同一设备(SN 相同):只增加引用计数,不重新订阅
if (_listenerCount > 0 &&
_deviceSn == deviceSn &&
_gatewaySn == gatewaySn) {
_listenerCount++;
debugPrint('[DroneOsdDataSource] 引用计数+1: $_listenerCount (设备相同,不重新订阅)');
return;
}
// 🔥 切换到不同设备或首次订阅时才清理旧订阅
// 重要:只有在设备不同的情况下才调用 stopListening,避免误取消当前订阅
final needCleanup = _deviceSn != null || _gatewaySn != null;
if (needCleanup) {
// 重置引用计数为 1,让 stopListening 能真正取消 MQTT 订阅
_listenerCount = 1;
await stopListening();
}
_deviceSn = deviceSn;
_gatewaySn = gatewaySn;
final droneTopic = 'thing/product/$deviceSn/osd';
final stationTopic = 'thing/product/$gatewaySn/osd';
debugPrint('🛸 [DroneOsdDataSource] 开始监听:');
debugPrint(' 无人机: $droneTopic');
debugPrint(' 机场: $stationTopic');
if (deviceSn.isNotEmpty) {
await mqttClient.subscribe(droneTopic);
}
if (gatewaySn.isNotEmpty) {
await mqttClient.subscribe(stationTopic);
}
_subscription = mqttClient.messageStream?.listen((message) {
_handleMessage(message);
});
_listenerCount = 1;
}
@override
Future<void> stopListening() async {
_listenerCount--;
if (_listenerCount > 0) {
debugPrint('[DroneOsdDataSource] 引用计数-1: $_listenerCount (保留订阅)');
return;
}
_listenerCount = 0;
await _subscription?.cancel();
_subscription = null;
if (_deviceSn != null && _deviceSn!.isNotEmpty) {
final deviceSn = _deviceSn!;
await mqttClient.unsubscribe('thing/product/$deviceSn/osd');
}
if (_gatewaySn != null && _gatewaySn!.isNotEmpty) {
final gatewaySn = _gatewaySn!;
await mqttClient.unsubscribe('thing/product/$gatewaySn/osd');
}
_deviceSn = null;
_gatewaySn = null;
}
void _handleMessage(MqttMessage message) {
if (_isDisposed) return;
try {
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
if (_gatewaySn != null &&
_gatewaySn!.isNotEmpty &&
message.topic.contains(_gatewaySn!)) {
// 注入来源 SN,供下游解析层验证消息归属
jsonData['_sourceSn'] = _gatewaySn;
final osdData = DroneOsdEntity.fromJson(jsonData);
_stationOsdController.add(osdData);
} else if (_deviceSn != null &&
_deviceSn!.isNotEmpty &&
message.topic.contains(_deviceSn!)) {
jsonData['_sourceSn'] = _deviceSn;
final osdData = DroneOsdEntity.fromJson(jsonData);
_droneOsdController.add(osdData);
}
} catch (e) {
debugPrint('❌ [DroneOsdDataSource] 解析 OSD 数据失败: $e');
}
}
void dispose() {
if (_isDisposed) return;
_isDisposed = true;
// 强制清理:不受引用计数限制,确保 MQTT 订阅被真正取消
// 避免切换机场时旧订阅残留导致数据串台
_listenerCount = 0;
_subscription?.cancel();
_subscription = null;
if (_deviceSn != null && _deviceSn!.isNotEmpty) {
final deviceSn = _deviceSn!;
mqttClient.unsubscribe('thing/product/$deviceSn/osd');
}
if (_gatewaySn != null && _gatewaySn!.isNotEmpty) {
final gatewaySn = _gatewaySn!;
mqttClient.unsubscribe('thing/product/$gatewaySn/osd');
}
_deviceSn = null;
_gatewaySn = null;
_droneOsdController.close();
_stationOsdController.close();
}
}

View File

@@ -0,0 +1,178 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../../../logging/log_time.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_message.dart';
import '../../domain/entities/mower_location_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
import '../../domain/entities/route_status_entity.dart';
/// 割草机实时状态 MQTT 数据源(替代原 TCP 0x02 机器状态推送)
///
/// 订阅主题:
/// - 车辆实时状态 mower/{sn}/property/realtime/post(1Hz,不含定位)
/// - 高频定位 mower/{sn}/property/location/post(5-10Hz)
/// - 路径规划 device/{deviceId}/route/post(任务执行后推送覆盖清扫路线)
abstract class MowerRealtimeDataSource {
Stream<RealTimeMessageEntity> get vehicleStream;
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
/// 按设备 SN 启动订阅:自动退订旧设备主题、同 SN 去重
Future<void> startListening({required String deviceSn});
Future<void> stopListening();
}
class MowerRealtimeDataSourceImpl implements MowerRealtimeDataSource {
final MqttClient mqttClient;
static const String _realtimeSuffix = 'property/realtime/post';
static const String _locationSuffix = 'property/location/post';
static const String _routeSuffix = 'route/post';
final _vehicleController =
StreamController<RealTimeMessageEntity>.broadcast();
final _locationController = StreamController<MowerLocationEntity>.broadcast();
final _routeController = StreamController<RouteStatusEntity>.broadcast();
StreamSubscription<MqttMessage>? _subscription;
/// 当前监听的设备 SN
String? _deviceSn;
/// 收帧计数(用于标记首帧)
int _vehicleMsgCount = 0;
int _locationMsgCount = 0;
int _routeMsgCount = 0;
MowerRealtimeDataSourceImpl(this.mqttClient);
@override
Stream<RealTimeMessageEntity> get vehicleStream => _vehicleController.stream;
@override
Stream<MowerLocationEntity> get locationStream => _locationController.stream;
@override
Stream<RouteStatusEntity> get routeStatusStream => _routeController.stream;
String _realtimeTopic(String sn) => 'mower/$sn/$_realtimeSuffix';
String _locationTopic(String sn) => 'mower/$sn/$_locationSuffix';
String _routeTopic(String sn) => 'device/$sn/$_routeSuffix';
@override
Future<void> startListening({required String deviceSn}) async {
if (deviceSn.isEmpty) {
debugPrint('${LogTime.now()} ⚠️ [MowerRealtimeDataSource] 订阅跳过:设备SN为空');
return;
}
// 🔥 同一设备重复调用:去重,不重复订阅(参照 DroneOsdDataSource 已验证的模式)
if (_deviceSn == deviceSn && _subscription != null) {
debugPrint('${LogTime.now()} [MowerRealtimeDataSource] 已在监听该设备,跳过: $deviceSn');
return;
}
// 🔥 切换设备:先退订旧设备的主题,防止数据串台
if (_deviceSn != null && _deviceSn!.isNotEmpty) {
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 已退订旧设备: $_deviceSn');
}
_deviceSn = deviceSn;
_vehicleMsgCount = 0;
_locationMsgCount = 0;
_routeMsgCount = 0;
await mqttClient.subscribe(_realtimeTopic(deviceSn));
await mqttClient.subscribe(_locationTopic(deviceSn));
await mqttClient.subscribe(_routeTopic(deviceSn));
// 消息流监听只建立一次,按 _deviceSn 过滤
_subscription ??= mqttClient.messageStream?.listen(_handleMessage);
debugPrint('${LogTime.now()} ✅ [MowerRealtimeDataSource] 开始监听设备: $deviceSn');
debugPrint(' 车辆实时: ${_realtimeTopic(deviceSn)}');
debugPrint(' 定位: ${_locationTopic(deviceSn)}');
debugPrint(' 路径规划: ${_routeTopic(deviceSn)}');
}
@override
Future<void> stopListening() async {
if (_deviceSn == null || _deviceSn!.isEmpty) return;
await mqttClient.unsubscribe(_realtimeTopic(_deviceSn!));
await mqttClient.unsubscribe(_locationTopic(_deviceSn!));
await mqttClient.unsubscribe(_routeTopic(_deviceSn!));
debugPrint('${LogTime.now()} 🔕 [MowerRealtimeDataSource] 停止监听设备: $_deviceSn');
_deviceSn = null;
}
void _handleMessage(MqttMessage message) {
final sn = _deviceSn;
if (sn == null || sn.isEmpty) return;
// 🔥 防串台:只处理当前监听设备的主题(mower/ 和 device/ 前缀都要匹配)
if (!message.topic.contains('mower/$sn/') &&
!message.topic.contains('device/$sn/')) return;
// 🔥 第一阶段:打印原始报文,确认格式后再完善解析
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] ──────── 收到消息 ────────');
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] topic: ${message.topic}');
debugPrint('${LogTime.now()} 📥 [MQTT-机器状态] payload: ${message.payload}');
try {
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
if (message.topic.endsWith(_realtimeSuffix)) {
// {name, value, unit} 数据点格式与 RealTimeMessageEntity 吻合,防御性解析
final vehicle = RealTimeMessageEntity.fromJson(jsonData);
if (vehicle.data.isNotEmpty) {
_vehicleMsgCount++;
if (_vehicleMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 车辆实时首帧到达: $sn');
}
debugPrint('${LogTime.now()} 📊 [MQTT-机器状态] 车辆实时第$_vehicleMsgCount帧 解析后: type=${vehicle.type}, 数据点=${vehicle.data.length}个');
for (final dp in vehicle.data) {
debugPrint('${LogTime.now()} ├ ${dp.name} = ${dp.value}${dp.unit}');
}
_vehicleController.add(vehicle);
}
} else if (message.topic.endsWith(_locationSuffix)) {
_locationMsgCount++;
if (_locationMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 定位首帧到达: $sn');
}
// 🔥 关键修复:坐标在 data 子对象中,不是顶层
final dataObj = jsonData['data'] as Map<String, dynamic>? ?? jsonData;
final location = MowerLocationEntity.fromJson(dataObj);
debugPrint('${LogTime.now()} 📍 [MQTT-机器状态] 定位第$_locationMsgCount帧 解析后: lat=${location.latitude}, lng=${location.longitude}, alt=${location.altitude}, heading=${location.heading}, speed=${location.speed}');
_locationController.add(location);
} else if (message.topic.endsWith(_routeSuffix)) {
_routeMsgCount++;
if (_routeMsgCount == 1) {
debugPrint('${LogTime.now()} 🎉 [MQTT-机器状态] 路径规划首帧到达: $sn');
}
final routeMsg = RouteStatusEntity.fromJson(jsonData);
debugPrint('${LogTime.now()} 🛤️ [MQTT-机器状态] 路径规划第$_routeMsgCount帧 - 设备: ${routeMsg.deviceId}, 任务: ${routeMsg.taskId}, 点数: ${routeMsg.data.length}');
_routeController.add(routeMsg);
}
} catch (e) {
debugPrint('${LogTime.now()} ❌ [MowerRealtimeDataSource] 解析消息失败: $e');
}
}
void dispose() {
stopListening();
_subscription?.cancel();
_subscription = null;
_vehicleController.close();
_locationController.close();
_routeController.close();
}
}

View File

@@ -0,0 +1,128 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_message.dart';
import '../../domain/entities/task_arrive_entity.dart';
import '../../domain/entities/task_status_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
abstract class TaskMessageDataSource {
Stream<TaskStatusEntity> get taskStatusStream;
Stream<TaskArriveEntity> get taskArriveStream;
Stream<RealTimeMessageEntity> get realTimeMessageStream;
Future<void> startListening({required String deviceId, int? taskId});
Future<void> stopListening();
}
class TaskMessageDataSourceImpl implements TaskMessageDataSource {
final MqttClient mqttClient;
final _taskStatusController = StreamController<TaskStatusEntity>.broadcast();
final _taskArriveController = StreamController<TaskArriveEntity>.broadcast();
final _realTimeMessageController =
StreamController<RealTimeMessageEntity>.broadcast();
StreamSubscription<MqttMessage>? _subscription;
String? _deviceId;
int? _taskId;
TaskMessageDataSourceImpl(this.mqttClient);
@override
Stream<TaskStatusEntity> get taskStatusStream => _taskStatusController.stream;
@override
Stream<TaskArriveEntity> get taskArriveStream => _taskArriveController.stream;
@override
Stream<RealTimeMessageEntity> get realTimeMessageStream =>
_realTimeMessageController.stream;
@override
Future<void> startListening({required String deviceId, int? taskId}) async {
if (_deviceId == deviceId && _taskId == taskId && _subscription != null) {
debugPrint('[TaskMessageDataSource] already listening: deviceId=$deviceId, taskId=$taskId');
return;
}
if (_deviceId != null && _deviceId != deviceId) {
await stopListening();
} else {
await _subscription?.cancel();
_subscription = null;
}
_deviceId = deviceId;
_taskId = taskId;
// 🔥 status/arrive 话题使用 taskId,realTimeMessage 使用 deviceId
final taskStatusTopic = taskId != null ? 'task/$taskId/status' : null;
final taskArriveTopic = taskId != null ? 'task/$taskId/arrive' : null;
final realTimeTopic = 'device/$deviceId/realTimeMessage';
debugPrint('📋 [TaskMessageDataSource] 开始监听:');
if (taskStatusTopic != null) debugPrint(' 任务状态: $taskStatusTopic');
if (taskArriveTopic != null) debugPrint(' 到达通知: $taskArriveTopic');
debugPrint(' 实时消息: $realTimeTopic');
if (taskStatusTopic != null) await mqttClient.subscribe(taskStatusTopic);
if (taskArriveTopic != null) await mqttClient.subscribe(taskArriveTopic);
await mqttClient.subscribe(realTimeTopic);
_subscription = mqttClient.messageStream?.listen((message) {
_handleMessage(message);
});
}
@override
Future<void> stopListening() async {
await _subscription?.cancel();
_subscription = null;
if (_deviceId != null) {
final deviceId = _deviceId!;
// 🔥 status/arrive 用 taskId 取消订阅
if (_taskId != null) {
await mqttClient.unsubscribe('task/$_taskId/status');
await mqttClient.unsubscribe('task/$_taskId/arrive');
}
await mqttClient.unsubscribe('device/$deviceId/realTimeMessage');
}
_deviceId = null;
_taskId = null;
}
void _handleMessage(MqttMessage message) {
try {
final jsonData = jsonDecode(message.payload) as Map<String, dynamic>;
if (message.topic.contains('/status')) {
final taskStatus = TaskStatusEntity.fromJson(jsonData);
debugPrint('📋 [TaskMessageDataSource] 任务状态更新: ${taskStatus.status}');
_taskStatusController.add(taskStatus);
} else if (message.topic.contains('/arrive')) {
final arriveInfo = TaskArriveEntity.fromJson(jsonData);
debugPrint('📍 [TaskMessageDataSource] 到达通知: 任务${arriveInfo.taskId}');
_taskArriveController.add(arriveInfo);
} else if (message.topic.contains('/realTimeMessage')) {
final realTimeMsg = RealTimeMessageEntity.fromJson(jsonData);
debugPrint(
'💬 [TaskMessageDataSource] 实时消息 - 类型: ${realTimeMsg.type}, 数据点数: ${realTimeMsg.data.length}',
);
_realTimeMessageController.add(realTimeMsg);
}
} catch (e) {
debugPrint('❌ [TaskMessageDataSource] 解析任务消息失败: $e');
}
}
void dispose() {
stopListening();
_taskStatusController.close();
_taskArriveController.close();
_realTimeMessageController.close();
}
}

View File

@@ -0,0 +1,272 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:mqtt_client/mqtt_client.dart' as mqtt;
import 'package:mqtt_client/mqtt_server_client.dart' as mqtt_server;
import '../../../../logging/log_time.dart';
import '../../domain/interfaces/mqtt_client.dart';
import '../../domain/models/mqtt_config.dart';
import '../../domain/models/mqtt_message.dart';
class MqttClientImpl implements MqttClient {
mqtt_server.MqttServerClient? _client;
final _messageController = StreamController<MqttMessage>.broadcast();
final Map<String, int> _subscriptionRefs = <String, int>{};
/// 客户端名称,用于日志区分(如 droneOsdClient / taskMessageClient / mowerRealtimeClient)
final String clientName;
MqttConfig? _currentConfig;
StreamSubscription? _updatesSubscription;
Timer? _reconnectTimer;
bool _isConnected = false;
bool _isConnecting = false;
bool _manualDisconnect = false;
MqttClientImpl({this.clientName = 'MqttClient'});
@override
Stream<MqttMessage>? get messageStream => _messageController.stream;
@override
bool get isConnected => _isConnected;
@override
Future<void> connect(MqttConfig config) async {
if (_isConnecting) {
debugPrint('[MqttClient] connect ignored, already connecting');
return;
}
if (_isConnected) {
debugPrint('[MqttClient] already connected, disconnect first');
await disconnect();
}
_manualDisconnect = false;
_currentConfig = config;
_reconnectTimer?.cancel();
_reconnectTimer = null;
_isConnecting = true;
try {
await _updatesSubscription?.cancel();
_updatesSubscription = null;
_client = _createClient(config);
_configureClient(_client!, config);
debugPrint('${LogTime.now()} 🔌 [$clientName] 正在连接 ${config.connectionAddress}:${config.port} (clientId=${config.clientId})');
await _client!.connect(config.username, config.password);
if (_client!.connectionStatus?.state ==
mqtt.MqttConnectionState.connected) {
_isConnected = true;
debugPrint('${LogTime.now()} ✅ [$clientName] 连接成功 ${config.connectionAddress}:${config.port}');
_listenToMessages();
_resubscribeAll();
} else {
throw Exception(
'MQTT connect failed: ${_client!.connectionStatus?.returnCode}',
);
}
} catch (e) {
debugPrint('${LogTime.now()} ❌ [$clientName] 连接失败: $e');
_isConnected = false;
_isConnecting = false;
_scheduleReconnect();
rethrow;
} finally {
_isConnecting = false;
}
}
mqtt_server.MqttServerClient _createClient(MqttConfig config) {
final clientId =
'${config.clientId}_${DateTime.now().millisecondsSinceEpoch}';
switch (config.protocol) {
case MqttProtocol.websocket:
case MqttProtocol.wss:
final client = mqtt_server.MqttServerClient.withPort(
config.connectionAddress,
clientId,
config.port,
);
client.useWebSocket = true;
client.websocketProtocols = ['mqtt'];
return client;
case MqttProtocol.tcp:
return mqtt_server.MqttServerClient.withPort(
config.host,
clientId,
config.port,
);
}
}
void _configureClient(
mqtt_server.MqttServerClient client,
MqttConfig config,
) {
client.logging(on: false);
client.keepAlivePeriod = config.keepAlivePeriod;
client.autoReconnect = false;
client.resubscribeOnAutoReconnect = false;
client.onDisconnected = _onDisconnected;
client.onConnected = _onConnected;
client.onSubscribed = _onSubscribed;
var connMessage = mqtt.MqttConnectMessage()
.withClientIdentifier(client.clientIdentifier)
.withWillQos(mqtt.MqttQos.atLeastOnce);
if (config.cleanSession) {
connMessage = connMessage.startClean();
}
client.connectionMessage = connMessage;
}
@override
Future<void> disconnect() async {
debugPrint('[MqttClient] disconnect');
_manualDisconnect = true;
_reconnectTimer?.cancel();
_reconnectTimer = null;
await _updatesSubscription?.cancel();
_updatesSubscription = null;
_client?.disconnect();
_client = null;
_isConnected = false;
_isConnecting = false;
_currentConfig = null;
}
@override
Future<void> subscribe(String topic) async {
final previousRefs = _subscriptionRefs[topic] ?? 0;
_subscriptionRefs[topic] = previousRefs + 1;
if (!_isConnected || _client == null) {
debugPrint('[MqttClient] queued subscription while disconnected: $topic');
_scheduleReconnect();
return;
}
if (previousRefs > 0) {
debugPrint('[MqttClient] subscription already active: $topic');
return;
}
debugPrint('${LogTime.now()} 📮 [$clientName] 发起订阅: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
@override
Future<void> unsubscribe(String topic) async {
final currentRefs = _subscriptionRefs[topic] ?? 0;
if (currentRefs <= 1) {
_subscriptionRefs.remove(topic);
} else {
_subscriptionRefs[topic] = currentRefs - 1;
debugPrint('[MqttClient] subscription still in use: $topic');
return;
}
if (!_isConnected || _client == null) {
debugPrint('[MqttClient] removed queued subscription: $topic');
return;
}
debugPrint('${LogTime.now()} 🔕 [$clientName] 退订: $topic');
_client!.unsubscribe(topic);
}
@override
Future<void> publish(String topic, String message) async {
if (!_isConnected || _client == null) {
throw Exception('MQTT is not connected');
}
debugPrint('[MqttClient] publish: $topic');
final builder = mqtt.MqttClientPayloadBuilder();
builder.addString(message);
_client!.publishMessage(topic, mqtt.MqttQos.atLeastOnce, builder.payload!);
}
void _listenToMessages() {
_updatesSubscription = _client!.updates!.listen(
(List<mqtt.MqttReceivedMessage<mqtt.MqttMessage>> messages) {
for (final msg in messages) {
final topic = msg.topic;
final payload = mqtt.MqttPublishPayload.bytesToStringAsString(
(msg.payload as mqtt.MqttPublishMessage).payload.message,
);
debugPrint('${LogTime.now()} 📥 [$clientName] 收到消息 [$topic]: $payload');
_messageController.add(MqttMessage(topic: topic, payload: payload));
}
},
onError: (Object error) {
debugPrint('[MqttClient] updates stream error: $error');
_isConnected = false;
_scheduleReconnect();
},
cancelOnError: false,
);
}
void _resubscribeAll() {
if (_subscriptionRefs.isEmpty || _client == null) return;
for (final topic in _subscriptionRefs.keys) {
debugPrint('${LogTime.now()} 📮 [$clientName] 重连后重新订阅: $topic');
_client!.subscribe(topic, mqtt.MqttQos.atLeastOnce);
}
}
void _onConnected() {
debugPrint('[MqttClient] connected callback');
_isConnected = true;
}
void _onDisconnected() {
debugPrint('${LogTime.now()} ⚠️ [$clientName] 连接断开');
_isConnected = false;
if (!_manualDisconnect) {
_scheduleReconnect();
}
}
void _onSubscribed(String topic) {
debugPrint('${LogTime.now()} ✅ [$clientName] 订阅确认成功: $topic');
}
void _scheduleReconnect() {
final config = _currentConfig;
if (_manualDisconnect || config == null || _isConnected || _isConnecting) {
return;
}
if (_reconnectTimer?.isActive ?? false) return;
final delay = Duration(milliseconds: config.reconnectDelayMs);
debugPrint('${LogTime.now()} ⏳ [$clientName] ${delay.inMilliseconds}ms 后尝试重连');
_reconnectTimer = Timer(delay, () async {
if (_manualDisconnect || _isConnected || _isConnecting) return;
try {
debugPrint('${LogTime.now()} 🔄 [$clientName] 开始重连...');
await connect(config);
} catch (e) {
debugPrint('[MqttClient] reconnect failed: $e');
}
});
}
void dispose() {
disconnect();
_messageController.close();
}
}

View File

@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../datasources/drone_osd_datasource.dart';
import '../../domain/repositories/drone_osd_repository.dart';
import '../../domain/entities/drone_osd_entity.dart';
class DroneOsdRepositoryImpl implements DroneOsdRepository {
final DroneOsdDataSource dataSource;
DroneOsdRepositoryImpl(this.dataSource);
@override
Stream<DroneOsdEntity> get droneOsdStream => dataSource.droneOsdStream;
@override
Stream<DroneOsdEntity> get stationOsdStream => dataSource.stationOsdStream;
@override
Future<Either<Failure, void>> startListening({
required String deviceSn,
required String gatewaySn,
}) async {
try {
await dataSource.startListening(
deviceSn: deviceSn,
gatewaySn: gatewaySn,
);
return right(null);
} catch (e) {
return left(Failure(e.toString()));
}
}
@override
Future<void> stopListening() async {
await dataSource.stopListening();
}
}

View File

@@ -0,0 +1,42 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../datasources/mower_realtime_datasource.dart';
import '../../domain/repositories/mower_realtime_repository.dart';
import '../../domain/entities/mower_location_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
import '../../domain/entities/route_status_entity.dart';
class MowerRealtimeRepositoryImpl implements MowerRealtimeRepository {
final MowerRealtimeDataSource dataSource;
MowerRealtimeRepositoryImpl(this.dataSource);
@override
Stream<RealTimeMessageEntity> get vehicleStream => dataSource.vehicleStream;
@override
Stream<MowerLocationEntity> get locationStream => dataSource.locationStream;
@override
Stream<RouteStatusEntity> get routeStatusStream =>
dataSource.routeStatusStream;
@override
Future<Either<Failure, void>> startListening({
required String deviceSn,
}) async {
try {
await dataSource.startListening(deviceSn: deviceSn);
return right(null);
} catch (e) {
return left(Failure(e.toString()));
}
}
@override
Future<void> stopListening() async {
await dataSource.stopListening();
}
}

View File

@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../datasources/task_message_datasource.dart';
import '../../domain/repositories/task_message_repository.dart';
import '../../domain/entities/task_arrive_entity.dart';
import '../../domain/entities/task_status_entity.dart';
import '../../domain/entities/real_time_message_entity.dart';
class TaskMessageRepositoryImpl implements TaskMessageRepository {
final TaskMessageDataSource dataSource;
TaskMessageRepositoryImpl(this.dataSource);
@override
Stream<RealTimeMessageEntity> get realTimeMessageStream =>
dataSource.realTimeMessageStream;
@override
Stream<TaskArriveEntity> get taskArriveStream => dataSource.taskArriveStream;
@override
Stream<TaskStatusEntity> get taskStatusStream => dataSource.taskStatusStream;
@override
Future<Either<Failure, void>> startListening({required String deviceId, int? taskId}) async {
try {
await dataSource.startListening(deviceId: deviceId, taskId: taskId);
return right(null);
} catch (e) {
return left(Failure(e.toString()));
}
}
@override
Future<void> stopListening() async {
await dataSource.stopListening();
}
}

View File

@@ -0,0 +1,18 @@
import 'package:equatable/equatable.dart';
class DroneOsdEntity extends Equatable {
final Map<String, dynamic> rawData;
const DroneOsdEntity({required this.rawData});
factory DroneOsdEntity.fromJson(Map<String, dynamic> json) {
return DroneOsdEntity(rawData: json);
}
Map<String, dynamic> toJson() {
return rawData;
}
@override
List<Object?> get props => [rawData];
}

View File

@@ -0,0 +1,60 @@
import 'package:equatable/equatable.dart';
/// 割草机定位消息实体(MQTT 主题 mower/{sn}/property/location/post,5-10Hz)
///
/// 报文格式尚未最终确认(第一阶段仅打印),fromLen 对常见字段名做宽容解析,
/// 确认后如与实际不符在此调整即可,不影响上层。
class MowerLocationEntity extends Equatable {
final double? latitude;
final double? longitude;
final double? altitude;
/// 航向角
final double? heading;
/// 速度
final double? speed;
/// 定位质量(fix_status)
final String? fixStatus;
final String? timestamp;
/// 原始报文,便于解析格式确认后补充字段
final Map<String, dynamic> rawData;
const MowerLocationEntity({
this.latitude,
this.longitude,
this.altitude,
this.heading,
this.speed,
this.fixStatus,
this.timestamp,
this.rawData = const {},
});
bool get isValid => latitude != null && longitude != null;
factory MowerLocationEntity.fromJson(Map<String, dynamic> json) {
return MowerLocationEntity(
latitude: _toDouble(json['latitude'] ?? json['lat']),
longitude: _toDouble(json['longitude'] ?? json['lng'] ?? json['lon']),
altitude: _toDouble(json['altitude'] ?? json['height']),
heading: _toDouble(json['heading'] ?? json['yaw']),
speed: _toDouble(json['speed']),
fixStatus: json['fix_status']?.toString(),
timestamp: json['timestamp']?.toString(),
rawData: json,
);
}
static double? _toDouble(dynamic value) {
if (value == null) return null;
if (value is num) return value.toDouble();
return double.tryParse(value.toString());
}
@override
List<Object?> get props => [latitude, longitude, altitude, heading, speed, fixStatus, timestamp];
}

View File

@@ -0,0 +1,69 @@
import 'package:equatable/equatable.dart';
class RealTimeMessageEntity extends Equatable {
final List<DeviceDataPoint> data;
final String type;
const RealTimeMessageEntity({
required this.data,
required this.type,
});
factory RealTimeMessageEntity.fromJson(Map<String, dynamic> json) {
final dataList = json['data'] as List<dynamic>? ?? [];
return RealTimeMessageEntity(
data: dataList.map((item) => DeviceDataPoint.fromJson(item)).toList(),
type: json['type'] as String? ?? '',
);
}
double? getValueByName(String name) {
final point = data.firstWhere(
(p) => p.name == name,
orElse: () => DeviceDataPoint(name: '', value: '', unit: ''),
);
if (point.value.isEmpty) return null;
return double.tryParse(point.value);
}
Map<String, dynamic> toJson() {
return {
'data': data.map((e) => e.toJson()).toList(),
'type': type,
};
}
@override
List<Object?> get props => [data, type];
}
class DeviceDataPoint extends Equatable {
final String name;
final String value;
final String unit;
const DeviceDataPoint({
required this.name,
required this.value,
required this.unit,
});
factory DeviceDataPoint.fromJson(Map<String, dynamic> json) {
return DeviceDataPoint(
name: json['name'] as String? ?? '',
value: json['value'] as String? ?? '',
unit: json['unit'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'value': value,
'unit': unit,
};
}
@override
List<Object?> get props => [name, value, unit];
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
/// MQTT 路径规划消息实体
/// 对应后端 Java DTO: WebRouteStatusMessageDTO
/// Topic: device/{deviceId}/route/post
class RouteStatusEntity extends Equatable {
final List<LatAndLngEntity> data;
final String deviceId;
final int taskId;
final String type;
const RouteStatusEntity({
required this.data,
required this.deviceId,
required this.taskId,
required this.type,
});
factory RouteStatusEntity.fromJson(Map<String, dynamic> json) {
final dataList = json['data'] as List<dynamic>? ?? [];
return RouteStatusEntity(
data: dataList.map((item) => LatAndLngEntity.fromJson(item)).toList(),
deviceId: json['deviceId'] as String? ?? '',
taskId: (json['taskId'] as num?)?.toInt() ?? 0,
type: json['type'] as String? ?? '',
);
}
@override
List<Object?> get props => [data, deviceId, taskId, type];
}
/// 经纬度坐标实体
/// 对应后端 Java DTO: LatAndLngEntity
class LatAndLngEntity extends Equatable {
final double lat;
final double lng;
const LatAndLngEntity({required this.lat, required this.lng});
factory LatAndLngEntity.fromJson(Map<String, dynamic> json) {
return LatAndLngEntity(
lat: (json['lat'] as num?)?.toDouble() ?? 0.0,
lng: (json['lng'] as num?)?.toDouble() ?? 0.0,
);
}
@override
List<Object?> get props => [lat, lng];
}

View File

@@ -0,0 +1,62 @@
import 'package:equatable/equatable.dart';
class TaskArriveEntity extends Equatable {
final String type;
final String deviceId;
final int? taskId;
final dynamic status;
final ArriveLocation? entity;
const TaskArriveEntity({
required this.type,
required this.deviceId,
this.taskId,
this.status,
this.entity,
});
factory TaskArriveEntity.fromJson(Map<String, dynamic> json) {
final entityJson = json['entity'] as Map<String, dynamic>?;
return TaskArriveEntity(
type: json['type'] as String? ?? '',
deviceId: json['deviceId'] as String? ?? '',
taskId: json['taskId'] as int?,
status: json['status'],
entity: entityJson != null ? ArriveLocation.fromJson(entityJson) : null,
);
}
Map<String, dynamic> toJson() {
return {
'type': type,
'deviceId': deviceId,
'taskId': taskId,
'status': status,
'entity': entity?.toJson(),
};
}
@override
List<Object?> get props => [type, deviceId, taskId, status, entity];
}
class ArriveLocation extends Equatable {
final double lat;
final double lng;
const ArriveLocation({required this.lat, required this.lng});
factory ArriveLocation.fromJson(Map<String, dynamic> json) {
return ArriveLocation(
lat: (json['lat'] as num?)?.toDouble() ?? 0.0,
lng: (json['lng'] as num?)?.toDouble() ?? 0.0,
);
}
Map<String, dynamic> toJson() {
return {'lat': lat, 'lng': lng};
}
@override
List<Object?> get props => [lat, lng];
}

View File

@@ -0,0 +1,40 @@
import 'package:equatable/equatable.dart';
class TaskStatusEntity extends Equatable {
final String type;
final String deviceId;
final int taskId;
final String? status;
final Map<String, dynamic>? extraData;
const TaskStatusEntity({
required this.type,
required this.deviceId,
required this.taskId,
this.status,
this.extraData,
});
factory TaskStatusEntity.fromJson(Map<String, dynamic> json) {
return TaskStatusEntity(
type: json['type'] as String? ?? '',
deviceId: json['deviceId'] as String? ?? '',
taskId: json['taskId'] as int? ?? 0,
status: json['status'] as String?,
extraData: json,
);
}
Map<String, dynamic> toJson() {
return {
'type': type,
'deviceId': deviceId,
'taskId': taskId,
'status': status,
if (extraData != null) ...extraData!,
};
}
@override
List<Object?> get props => [type, deviceId, taskId, status, extraData];
}

View File

@@ -0,0 +1,14 @@
import 'dart:async';
import '../models/mqtt_config.dart';
import '../models/mqtt_message.dart';
abstract class MqttClient {
Stream<MqttMessage>? get messageStream;
Future<void> connect(MqttConfig config);
Future<void> disconnect();
Future<void> subscribe(String topic);
Future<void> unsubscribe(String topic);
Future<void> publish(String topic, String message);
bool get isConnected;
}

View File

@@ -0,0 +1,140 @@
import 'package:equatable/equatable.dart';
import '../../../../env/env_config.dart';
/// MQTT 传输协议类型
enum MqttProtocol {
/// 纯 TCP 连接(原生 MQTT)
tcp,
/// WebSocket 连接(MQTT over WebSocket)
websocket,
/// 加密的 WebSocket 连接(MQTT over WSS)
wss,
}
class MqttConfig extends Equatable {
final String host;
final int port;
final String username;
final String password;
final MqttProtocol protocol;
final String clientId;
final bool cleanSession;
final int keepAlivePeriod;
final int reconnectDelayMs;
/// WebSocket 路径(仅 WebSocket 模式使用)
final String? wsPath;
const MqttConfig({
required this.host,
required this.port,
required this.username,
required this.password,
required this.protocol,
required this.clientId,
this.cleanSession = true,
this.keepAlivePeriod = 60,
this.reconnectDelayMs = 3000,
this.wsPath,
});
/// 获取完整的连接地址
String get connectionAddress {
switch (protocol) {
case MqttProtocol.websocket:
return 'ws://$host:$port${wsPath ?? '/mqtt'}';
case MqttProtocol.wss:
return 'wss://$host:$port${wsPath ?? '/mqtt'}';
case MqttProtocol.tcp:
default:
return host;
}
}
/// 无人机/机场 OSD 数据(WebSocket MQTT)
factory MqttConfig.droneOsd() {
return const MqttConfig(
host: '1.95.137.212',
port: 8083,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.websocket,
clientId: 'drone_osd_client',
wsPath: '/mqtt',
cleanSession: true,
reconnectDelayMs: 3000,
);
}
/// 任务状态消息(TCP MQTT)——端口随环境切换(prod=1883 / test=59007)
factory MqttConfig.taskMessage() {
return MqttConfig(
host: EnvConfig.serverHost,
port: EnvConfig.mqttPort,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.tcp,
clientId: 'task_message_client',
cleanSession: true,
reconnectDelayMs: 3000,
);
}
/// 🔥 割草机机器状态(TCP MQTT)——独立连接,端口随环境切换(prod=1883 / test=59007)
/// 仅用于 mower/{sn}/property/realtime/post 和 mower/{sn}/property/location/post
factory MqttConfig.mowerRealtime() {
return MqttConfig(
host: EnvConfig.serverHost,
port: EnvConfig.mqttPort,
username: 'maibu',
password: 'jsmbzn520',
protocol: MqttProtocol.tcp,
clientId: 'mower_realtime_client',
cleanSession: true,
reconnectDelayMs: 3000,
);
}
MqttConfig copyWith({
String? host,
int? port,
String? username,
String? password,
MqttProtocol? protocol,
String? clientId,
bool? cleanSession,
int? keepAlivePeriod,
int? reconnectDelayMs,
String? wsPath,
}) {
return MqttConfig(
host: host ?? this.host,
port: port ?? this.port,
username: username ?? this.username,
password: password ?? this.password,
protocol: protocol ?? this.protocol,
clientId: clientId ?? this.clientId,
cleanSession: cleanSession ?? this.cleanSession,
keepAlivePeriod: keepAlivePeriod ?? this.keepAlivePeriod,
reconnectDelayMs: reconnectDelayMs ?? this.reconnectDelayMs,
wsPath: wsPath ?? this.wsPath,
);
}
@override
List<Object?> get props => [
host,
port,
username,
password,
protocol,
clientId,
cleanSession,
keepAlivePeriod,
reconnectDelayMs,
wsPath,
];
}

View File

@@ -0,0 +1,24 @@
import 'package:equatable/equatable.dart';
class MqttMessage extends Equatable {
final String topic;
final String payload;
final DateTime timestamp;
MqttMessage({
required this.topic,
required this.payload,
DateTime? timestamp,
}) : timestamp = timestamp ?? DateTime.now();
Map<String, dynamic> toJson() {
return {
'topic': topic,
'payload': payload,
'timestamp': timestamp.toIso8601String(),
};
}
@override
List<Object?> get props => [topic, payload, timestamp];
}

View File

@@ -0,0 +1,17 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/drone_osd_entity.dart';
abstract class DroneOsdRepository {
Stream<DroneOsdEntity> get droneOsdStream;
Stream<DroneOsdEntity> get stationOsdStream;
Future<Either<Failure, void>> startListening({
required String deviceSn,
required String gatewaySn,
});
Future<void> stopListening();
}

View File

@@ -0,0 +1,19 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/mower_location_entity.dart';
import '../entities/real_time_message_entity.dart';
import '../entities/route_status_entity.dart';
abstract class MowerRealtimeRepository {
Stream<RealTimeMessageEntity> get vehicleStream;
Stream<MowerLocationEntity> get locationStream;
Stream<RouteStatusEntity> get routeStatusStream;
Future<Either<Failure, void>> startListening({
required String deviceSn,
});
Future<void> stopListening();
}

View File

@@ -0,0 +1,16 @@
import 'dart:async';
import 'package:fpdart/fpdart.dart';
import '../../../../../core/error/failure.dart';
import '../entities/task_arrive_entity.dart';
import '../entities/task_status_entity.dart';
import '../entities/real_time_message_entity.dart';
abstract class TaskMessageRepository {
Stream<RealTimeMessageEntity> get realTimeMessageStream;
Stream<TaskArriveEntity> get taskArriveStream;
Stream<TaskStatusEntity> get taskStatusStream;
Future<Either<Failure, void>> startListening({required String deviceId, int? taskId});
Future<void> stopListening();
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/foundation.dart';
import 'package:get_it/get_it.dart';
import '../../di/injection.dart';
import 'data/datasources/drone_osd_datasource.dart';
import 'data/datasources/task_message_datasource.dart';
import 'domain/interfaces/mqtt_client.dart';
import 'domain/models/mqtt_config.dart';
class MqttManager {
static final MqttManager _instance = MqttManager._internal();
factory MqttManager() => _instance;
MqttManager._internal();
bool _isInitialized = false;
Future<void> initialize() async {
if (_isInitialized) {
debugPrint('⚠️ [MqttManager] 已初始化,跳过');
return;
}
debugPrint('🔧 [MqttManager] 开始初始化 MQTT 连接...');
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
final mowerRealtimeClient = sl<MqttClient>(instanceName: 'mowerRealtimeClient');
// 独立连接三个 MQTT 客户端,互不影响
await _connectClient(droneOsdClient, MqttConfig.droneOsd(), 'droneOsdClient');
await _connectClient(taskMessageClient, MqttConfig.taskMessage(), 'taskMessageClient');
await _connectClient(mowerRealtimeClient, MqttConfig.mowerRealtime(), 'mowerRealtimeClient');
_isInitialized = true;
debugPrint('✅ [MqttManager] MQTT 初始化完成');
}
Future<void> _connectClient(
MqttClient client,
MqttConfig config,
String clientName,
) async {
try {
debugPrint('🔌 [MqttManager] 连接 $clientName...');
await client.connect(config);
debugPrint('✅ [MqttManager] $clientName 连接成功');
} catch (e) {
debugPrint('❌ [MqttManager] $clientName 连接失败: $e');
// 不抛出异常,允许其他客户端继续连接
}
}
Future<void> dispose() async {
debugPrint('🔌 [MqttManager] 断开所有 MQTT 连接...');
final droneOsdClient = sl<MqttClient>(instanceName: 'droneOsdClient');
final taskMessageClient = sl<MqttClient>(instanceName: 'taskMessageClient');
final mowerRealtimeClient = sl<MqttClient>(instanceName: 'mowerRealtimeClient');
await droneOsdClient.disconnect();
await taskMessageClient.disconnect();
await mowerRealtimeClient.disconnect();
_isInitialized = false;
debugPrint('✅ [MqttManager] 所有 MQTT 连接已断开');
}
bool get isInitialized => _isInitialized;
}

View File

@@ -91,7 +91,7 @@ class NetMessageDispatcher {
//机器状态页面专用
Stream<String> onStringMessageOfdeviceStatus() {
//print("0x02--TCP拦截推送解析开始");
_logger.log('0x02--TCP拦截推送解析开始');
_logger.logWithLevel('0x02--TCP拦截推送解析开始' ,shouldLog: true);
// 🔥 关键修复:使用asBroadcastStream()确保多个监听者都能收到数据
return onCommand(0x02).map((p) {
try {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart';
enum TcpConnectionStatus {
disconnected, // 未连接
connecting, // 连接中
connected, // 已连接
error, // 连接错误
}
class TcpStatusState {
final TcpConnectionStatus status;
final String? errorMessage;
const TcpStatusState({
this.status = TcpConnectionStatus.disconnected,
this.errorMessage,
});
TcpStatusState copyWith({
TcpConnectionStatus? status,
String? errorMessage,
}) {
return TcpStatusState(
status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage,
);
}
}
class TcpStatusCubit extends Cubit<TcpStatusState> {
TcpStatusCubit() : super(const TcpStatusState());
void setConnecting() {
emit(const TcpStatusState(status: TcpConnectionStatus.connecting));
}
void setConnected() {
emit(const TcpStatusState(status: TcpConnectionStatus.connected));
}
void setDisconnected() {
emit(const TcpStatusState(status: TcpConnectionStatus.disconnected));
}
void setError(String message) {
emit(TcpStatusState(
status: TcpConnectionStatus.error,
errorMessage: message,
));
}
}

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
import 'package:maibu_satabot_v2/features/ai/presentation/routes/ai_routes.dart';
@@ -5,26 +6,49 @@ import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dar
import 'package:maibu_satabot_v2/features/auth/presentation/routes/auth_routes.dart';
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.dart';
import 'package:maibu_satabot_v2/features/home/presentation/routes/home_routes.dart';
import 'package:maibu_satabot_v2/features/main_container/presentation/pages/tab_settings_page.dart';
import 'package:maibu_satabot_v2/features/my/presentation/routes/my_routes.dart';
import 'package:maibu_satabot_v2/features/v2/waring_center/presentation/routes/alarm_center_routes.dart';
import 'package:maibu_satabot_v2/features/v2/workorder/presentation/routes/workorder_routes.dart';
import '../../features/auth/presentation/bloc/auth_cubit.dart';
import '../../features/auth/presentation/bloc/auth_state.dart';
import '../../features/machine_details/presentation/pages/machine_details_page.dart';
import '../../features/main_container/presentation/main_wrapper.dart';
import '../../main.dart'; // 🔥 导入 navigatorKey
import 'go_router_refresh_stream.dart';
GoRouter createRouter(AuthCubit authCubit) {
// 调试:打印所有路由
debugPrint('🔍 [Router] 开始创建路由配置...');
debugPrint(
'🔍 [Router] AlarmCenterRoutes.routes 数量: ${AlarmCenterRoutes.routes.length}',
);
for (var route in AlarmCenterRoutes.routes) {
debugPrint('🔍 [Router] 路由: ${route.toString()}');
}
return GoRouter(
initialLocation: RoutePaths.login,
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
initialLocation: RoutePaths.splash,
refreshListenable: GoRouterRefreshStream(authCubit.stream),
redirect: (context, state) {
final loggedIn = authCubit.state is AuthAuthenticated;
final onSplash = state.matchedLocation == RoutePaths.splash;
final loggingIn = state.matchedLocation == RoutePaths.login;
final registering = state.matchedLocation == RoutePaths.register;
if (!loggedIn && !loggingIn) {
// 启动屏:交给 SplashPage 自己根据就绪信号跳转,路由层不干预
if (onSplash) {
return null;
}
// 如果未登录,只允许访问 login 和 register 页面
if (!loggedIn && !loggingIn && !registering) {
return RoutePaths.login;
}
if (loggedIn && loggingIn) {
// 如果已登录,尝试访问 login 或 register,则跳转到首页
if (loggedIn && (loggingIn || registering)) {
return RoutePaths.home;
}
return null;
@@ -43,33 +67,23 @@ GoRouter createRouter(AuthCubit authCubit) {
},
),
// 注册登录注册 路由
GoRoute(
path: RoutePaths.machineDetails,
builder: (context, state) => const LoginPage(), // 替换为实际页面
path: RoutePaths.home,
builder: (context, state) => const MainWrapper(),
routes: [
GoRoute(
path: 'settings',
builder: (context, state) => const TabSettingsPage(),
),
],
),
// 1. 外部路由(无底部导航栏)
...AuthRoutes.routes,
...HomeRoutes.routes,
...AiRoutes.routes,
...MyRoutes.routes,
// 2. 内部路由(带底部导航栏的容器)
StatefulShellRoute.indexedStack(
builder: (context, state, navigationShell) {
// 返回我们定义的 MainWrapper
return MainWrapper(navigationShell: navigationShell);
},
branches: [
// 拿一级页面
HomeRoutes.branch,
AiRoutes.branch,
MyRoutes.branch,
],
),
...AlarmCenterRoutes.routes,
...WorkOrderRoutes.routes,
],
);
}

View File

@@ -1,4 +1,7 @@
class RoutePaths {
/// 启动页(App 第一个路由)
static const splash = '/splash';
/// 账号相关页面
static const login = '/auth/login';
static const register = '/auth/register';
@@ -16,6 +19,12 @@ class RoutePaths {
static const djMap = '/home/dj_map';
static const productDesc = '/home/productDesc';
static const usage = '/home/usage';
static const warningCenter = '/home/warning_center';
static const settings = '/my/settings';
static const machineDetails = '/machine_details/machine_details';
/// 工单相关页面
static const workOrder = '/workorder';
static const workOrderDetail = '/workorder/detail/:orderId';
}

View File

@@ -0,0 +1,69 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../consts/http_api_consts.dart';
/// 设备操作权限校验服务
/// 接口: GET /iot/device/hasPermission?deviceId=xxx
/// 通过条件: code==200 且 data==true,缺一不可
/// 安全原则: 仅明确收到 code=200 && data=true 才放行,其余所有情况一律阻止
class DevicePermissionService {
final Dio _dio;
DevicePermissionService(this._dio);
/// 校验当前用户是否有权操作指定设备
/// 返回 true 表示有权限,false 表示无权限或校验失败
Future<bool> checkPermission(String deviceId) async {
debugPrint('🔐 [权限校验] 开始校验 - deviceId: $deviceId');
try {
final response = await _dio.get(
HttpApiConsts.hasPermission,
queryParameters: {'deviceId': deviceId},
);
debugPrint('🔐 [权限校验] HTTP响应 - statusCode: ${response.statusCode}');
// HTTP 层面必须 200
if (response.statusCode != 200) {
debugPrint('🔐 [权限校验] ❌ HTTP状态码非200: ${response.statusCode}');
return false;
}
final body = response.data;
debugPrint('🔐 [权限校验] 响应体: $body');
// body 必须是 Map
if (body is! Map<String, dynamic>) {
debugPrint('🔐 [权限校验] ❌ 响应体格式异常,非Map类型');
return false;
}
final code = body['code'];
final data = body['data'];
debugPrint('🔐 [权限校验] code=$code (type: ${code.runtimeType}), data=$data (type: ${data.runtimeType})');
// code 必须是 200(兼容 int 和 String)
final codeMatch = code == 200 || code.toString() == '200';
// data 必须是 true
final dataMatch = data == true || data.toString() == 'true';
if (codeMatch && dataMatch) {
debugPrint('🔐 [权限校验] ✅ 校验通过 - code=200, data=true');
return true;
}
debugPrint('🔐 [权限校验] ❌ 校验未通过 - codeMatch=$codeMatch, dataMatch=$dataMatch');
return false;
} on DioException catch (e) {
// 网络异常(断网、超时、服务器不可达等)
debugPrint('🔐 [权限校验] ❌ DioException: ${e.type} - ${e.message}');
return false;
} catch (e) {
// 任何未知异常
debugPrint('🔐 [权限校验] ❌ 未知异常: $e');
return false;
}
}
}

View File

@@ -2,12 +2,159 @@ import 'dart:io';
import 'package:flutter/material.dart';
/// 应用级语义色扩展(浅色/深色各一套,随主题自动切换)
///
/// 用法:`context.appColors.cardBackground`
/// 原理:颜色挂载在 ThemeData.extensions 上,主题切换时组件树自动重建,
/// 页面无需写 `isDark ? A : B` 判断
@immutable
class AppColors extends ThemeExtension<AppColors> {
const AppColors({
required this.pageBackground,
required this.cardBackground,
required this.fillBackground,
required this.textPrimary,
required this.textSecondary,
required this.textTertiary,
required this.divider,
required this.cardShadow,
required this.primary,
required this.danger,
required this.warning,
required this.success,
});
/// 页面背景色
final Color pageBackground;
/// 卡片背景色
final Color cardBackground;
/// 卡片内嵌块填充色(输入框、上传按钮等)
final Color fillBackground;
/// 主要文字色
final Color textPrimary;
/// 次要文字色
final Color textSecondary;
/// 辅助文字色(占位、说明)
final Color textTertiary;
/// 分割线/浅色边框
final Color divider;
/// 卡片阴影色
final Color cardShadow;
/// 品牌主色(两种模式下保持一致)
final Color primary;
/// 危险/错误色(两种模式下保持一致)
final Color danger;
/// 警告色(两种模式下保持一致)
final Color warning;
/// 成功/完成色(两种模式下保持一致)
final Color success;
/// 浅色语义色(取自设计稿)
static const AppColors light = AppColors(
pageBackground: Color(0xFFF5F7FA),
cardBackground: Color(0xFFFFFFFF),
fillBackground: Color(0xFFF8F9FA),
textPrimary: Color(0xFF1D2129),
textSecondary: Color(0xFF4E5969),
textTertiary: Color(0xFF86909C),
divider: Color(0xFFF0F0F0),
cardShadow: Color(0x0D000000),
primary: Color(0xFF165DFF),
danger: Color(0xFFF53F3F),
warning: Color(0xFFFF7D00),
success: Color(0xFF00B42A),
);
/// 深色语义色(与 AppTheme.darkTheme 的 surface 色保持一致)
static const AppColors dark = AppColors(
pageBackground: Color(0xFF1C1C1E),
cardBackground: Color(0xFF2C2C2E),
fillBackground: Color(0xFF3A3A3C),
textPrimary: Color(0xFFF2F3F5),
textSecondary: Color(0xFFC9CDD4),
textTertiary: Color(0xFF86909C),
divider: Color(0xFF3A3A3C),
cardShadow: Color(0x00000000), // 深色下无阴影,靠色差分层级
primary: Color(0xFF165DFF),
danger: Color(0xFFF53F3F),
warning: Color(0xFFFF7D00),
success: Color(0xFF00B42A),
);
@override
AppColors copyWith({
Color? pageBackground,
Color? cardBackground,
Color? fillBackground,
Color? textPrimary,
Color? textSecondary,
Color? textTertiary,
Color? divider,
Color? cardShadow,
Color? primary,
Color? danger,
Color? warning,
Color? success,
}) {
return AppColors(
pageBackground: pageBackground ?? this.pageBackground,
cardBackground: cardBackground ?? this.cardBackground,
fillBackground: fillBackground ?? this.fillBackground,
textPrimary: textPrimary ?? this.textPrimary,
textSecondary: textSecondary ?? this.textSecondary,
textTertiary: textTertiary ?? this.textTertiary,
divider: divider ?? this.divider,
cardShadow: cardShadow ?? this.cardShadow,
primary: primary ?? this.primary,
danger: danger ?? this.danger,
warning: warning ?? this.warning,
success: success ?? this.success,
);
}
@override
AppColors lerp(AppColors? other, double t) {
if (other == null) return this;
return AppColors(
pageBackground: Color.lerp(pageBackground, other.pageBackground, t)!,
cardBackground: Color.lerp(cardBackground, other.cardBackground, t)!,
fillBackground: Color.lerp(fillBackground, other.fillBackground, t)!,
textPrimary: Color.lerp(textPrimary, other.textPrimary, t)!,
textSecondary: Color.lerp(textSecondary, other.textSecondary, t)!,
textTertiary: Color.lerp(textTertiary, other.textTertiary, t)!,
divider: Color.lerp(divider, other.divider, t)!,
cardShadow: Color.lerp(cardShadow, other.cardShadow, t)!,
primary: Color.lerp(primary, other.primary, t)!,
danger: Color.lerp(danger, other.danger, t)!,
warning: Color.lerp(warning, other.warning, t)!,
success: Color.lerp(success, other.success, t)!,
);
}
}
/// 便捷访问扩展:`context.appColors`
extension AppColorsX on BuildContext {
AppColors get appColors => Theme.of(this).extension<AppColors>()!;
}
class AppTheme {
// 基础色值定义
static const Color _pureWhite = Color(0xFFFFFFFF);
static const Color _offWhite = Color(0xFFfafafc); // 稍微带点灰的白,用于背景增加层次感
static const Color _pureBlack = Color(0xFF000000);
static const Color _darkGrey = Color(0xFF1C1C1E); // iOS 风格的深灰黑色
static const Color _surfaceDark = Color(0xFF2C2C2E); // 深色模式下卡片、弹窗表面色
static ThemeData get lightTheme {
return ThemeData(
@@ -15,6 +162,9 @@ class AppTheme {
brightness: Brightness.light,
fontFamily: (Platform.isWindows) ? 'Microsoft YaHei' : null,
// 语义色扩展
extensions: const [AppColors.light],
// 1. 整体背景色
scaffoldBackgroundColor: _offWhite,
@@ -85,4 +235,84 @@ class AppTheme {
),
);
}
/// 深色主题 (与浅色主题对称的黑白简约风格)
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
fontFamily: (Platform.isWindows) ? 'Microsoft YaHei' : null,
// 语义色扩展
extensions: const [AppColors.dark],
// 1. 整体背景色 (iOS 风格深灰黑)
scaffoldBackgroundColor: _darkGrey,
// 2. 核心颜色方案 (与浅色主题反色:主色白色)
colorScheme: const ColorScheme.dark(
primary: _pureWhite, // 主色设为白色
onPrimary: _pureBlack, // 白色背景上的文字设为黑色
surface: _surfaceDark, // 卡片、弹窗等表面颜色
onSurface: _pureWhite, // 表面上的文字色
),
// 3. 全局按钮主题 (白底黑字)
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _pureWhite,
foregroundColor: _pureBlack,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 24),
),
),
// 4. 填充按钮主题 (常用语 Material 3)
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: _pureWhite,
foregroundColor: _pureBlack,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
// 5. AppBar 主题 (深灰黑背景,白色文字)
appBarTheme: const AppBarTheme(
backgroundColor: _darkGrey,
surfaceTintColor: Colors.transparent,
elevation: 0,
centerTitle: true,
iconTheme: IconThemeData(color: _pureWhite),
titleTextStyle: TextStyle(
color: _pureWhite,
fontSize: 18,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
// 6. 输入框主题 (深色填充,白色边框)
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: _surfaceDark,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.white12),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Colors.white12),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: _pureWhite, width: 1.5),
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// 全局主题管理 Cubit(仿 LocaleCubit 模式)
/// 支持 light / dark / system 三态,持久化到 SharedPreferences
class ThemeCubit extends Cubit<ThemeMode> {
static const String _themeKey = 'app_theme_mode';
ThemeCubit() : super(ThemeMode.light) {
_loadSavedThemeMode();
}
/// 从本地存储恢复上次保存的主题模式
Future<void> _loadSavedThemeMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_themeKey);
emit(_parseThemeMode(saved));
} catch (e) {
emit(ThemeMode.light);
}
}
/// 设置主题模式并持久化
Future<void> setThemeMode(ThemeMode mode) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_themeKey, mode.name);
emit(mode);
} catch (e) {
debugPrint('❌ [ThemeCubit] 保存主题设置失败: $e');
}
}
/// 夜间模式开关(Switch 二态切换,不涉及跟随系统)
Future<void> toggleDarkMode(bool enabled) =>
setThemeMode(enabled ? ThemeMode.dark : ThemeMode.light);
/// 当前是否为深色模式
bool get isDarkMode => state == ThemeMode.dark;
/// 将持久化的字符串解析为 ThemeMode
ThemeMode _parseThemeMode(String? saved) {
switch (saved) {
case 'dark':
return ThemeMode.dark;
case 'system':
return ThemeMode.system;
default:
return ThemeMode.light;
}
}
}

View File

@@ -0,0 +1,218 @@
import 'dart:io';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter/services.dart';
import 'package:logger/logger.dart';
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'version_check_service.dart';
import 'update_state.dart';
/// 更新 Cubit
class UpdateCubit extends Cubit<UpdateState> {
final VersionCheckService _versionService;
final Logger _logger;
UpdateCubit(this._versionService)
: _logger = Logger(),
super(UpdateInitial());
/// 检查版本更新
Future<void> checkUpdate() async {
emit(UpdateChecking());
try {
// 从 pubspec.yaml 读取版本信息
final String versionString = await rootBundle.loadString('pubspec.yaml');
final versionMatch = RegExp(r'version:\s*(\d+\.\d+\.\d+)\+(\d+)').firstMatch(versionString);
final currentVersion = versionMatch?.group(1) ?? '1.0.0';
final currentVersionCode = int.tryParse(versionMatch?.group(2) ?? '1') ?? 1;
_logger.i('📱 当前应用版本: $currentVersion ($currentVersionCode)');
// 检查更新
final versionInfo = await _versionService.checkUpdate(
currentVersion: currentVersion,
currentVersionCode: currentVersionCode,
);
if (versionInfo == null) {
_logger.i('✅ 已是最新版本');
emit(UpdateUpToDate());
return;
}
_logger.i('🔄 发现新版本: ${versionInfo.version}');
_logger.i('📦 更新类型: ${versionInfo.updateType}');
_logger.i('⚠️ 强制更新: ${versionInfo.forceUpdate}');
emit(UpdateAvailable(versionInfo));
} catch (e) {
_logger.e('❌ 检查更新失败: $e');
emit(UpdateFailure('检查更新失败: $e'));
}
}
/// 应用差量补丁
Future<void> applyPatch(String patchUrl, String version, int targetVersionCode, {String? md5}) async {
emit(const UpdateDownloading(0.0, isPatch: true));
try {
_logger.i('⬇️ 开始下载并应用补丁');
final success = await _versionService.applyPatch(
patchUrl: patchUrl,
version: version,
targetVersionCode: targetVersionCode,
md5: md5,
onProgress: (progress) {
// 🔥 实时更新下载进度
emit(UpdateDownloading(progress, isPatch: true));
},
);
if (success) {
emit(const UpdateInstalling(isPatch: true));
emit(UpdateSuccess());
} else {
emit(const UpdateFailure('补丁应用失败'));
}
} catch (e) {
_logger.e('❌ 应用补丁异常: $e');
emit(UpdateFailure('应用补丁失败: $e'));
}
}
/// 自动下载并安装 APK(App 内下载)
Future<void> downloadAndInstallApk(String apkUrl) async {
emit(const UpdateDownloading(0.0, isPatch: false));
try {
_logger.i('⬇️ 开始下载完整 APK');
final apkPath = await _versionService.downloadApk(apkUrl, (progress) {
emit(UpdateDownloading(progress, isPatch: false));
});
if (apkPath != null) {
_logger.i('✅ APK 下载完成: $apkPath');
// 🔥 关键:先清理版本号记录,并等待用户看到提示
emit(const UpdateClearingVersion());
_logger.i('🗑️ 开始清除补丁版本记录');
await _versionService.clearPatchVersionInfo();
_logger.i('✅ 补丁版本记录已清除');
// 🔥 短暂延迟,让用户看到清理完成的提示
await Future.delayed(const Duration(milliseconds: 800));
// 🔥 直接调起系统安装页面,不再等待用户点击
_logger.i('📦 自动调起系统安装页面');
await triggerInstall(apkPath);
} else {
emit(const UpdateFailure('APK 下载失败'));
}
} catch (e) {
_logger.e('❌ 下载安装 APK 异常: $e');
emit(UpdateFailure('下载安装 APK 失败: $e'));
}
}
/// 触发系统安装页面
Future<void> triggerInstall(String apkPath) async {
emit(const UpdateInstalling(isPatch: false));
try {
_logger.i('📦 开始调起系统安装页面');
_logger.i('📁 APK 路径: $apkPath');
// 🔥 验证文件是否存在
final file = File(apkPath);
if (!await file.exists()) {
_logger.e('❌ APK 文件不存在: $apkPath');
emit(UpdateFailure('APK 文件不存在,请重新下载'));
return;
}
final fileSize = await file.length();
_logger.i('📏 APK 文件大小: ${fileSize / 1024 / 1024} MB');
// 🔥 关键修复:将APK复制到外部存储,避免某些Android版本的安装限制
final externalDir = await getExternalStorageDirectory();
String finalApkPath = apkPath;
if (externalDir != null) {
final publicApkPath = '${externalDir.path}/app-update-final.apk';
_logger.i('📋 复制APK到外部存储: $publicApkPath');
try {
await file.copy(publicApkPath);
finalApkPath = publicApkPath;
_logger.i('✅ APK复制成功');
} catch (e) {
_logger.w('⚠️ 复制APK失败,使用原路径: $e');
}
}
// 打开 APK 文件,触发系统安装界面
_logger.i('🚀 调用 OpenFile.open...');
_logger.i('🚀 最终APK路径: $finalApkPath');
final result = await OpenFile.open(finalApkPath);
_logger.i('📦 安装结果: ${result.message} (type: ${result.type})');
// 如果用户取消安装,显示提示信息并保留文件路径
if (result.type != ResultType.done) {
_logger.w('⚠️ 用户取消了安装或安装失败 (type: ${result.type})');
// 🔥 如果是权限问题,提供更友好的提示
String errorMsg = '您取消了安装';
if (result.type == ResultType.noAppToOpen) {
errorMsg = '没有找到可以安装 APK 的应用,请检查系统设置';
} else if (result.type == ResultType.permissionDenied) {
errorMsg = '没有安装权限,请在系统设置中允许"安装未知应用"';
} else if (result.type == ResultType.error) {
errorMsg = '安装出错: ${result.message}';
}
emit(UpdateFailure('$errorMsg,APK 文件位于: $finalApkPath'));
} else {
// 安装成功,关闭对话框
emit(UpdateSuccess());
}
} catch (e) {
_logger.e('❌ 调起安装页面异常: $e');
emit(UpdateFailure('调起安装页面失败: $e'));
}
}
/// 整包更新:浏览器下载 + 引导弹窗
Future<void> fullApkUpdateWithBrowser(String apkUrl) async {
try {
_logger.i('🌐 整包更新:打开浏览器下载');
final uri = Uri.parse(apkUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
_logger.i('✅ 已打开浏览器下载');
// 🔥 整包更新后,立即清除补丁版本记录(避免死循环)
await _versionService.clearPatchVersionInfo();
_logger.i('🗑️ 已清除补丁版本记录');
emit(UpdateSuccess());
} else {
_logger.e('❌ 无法打开浏览器');
emit(const UpdateFailure('无法打开浏览器'));
}
} catch (e) {
_logger.e('❌ 打开浏览器异常: $e');
emit(UpdateFailure('打开浏览器失败: $e'));
}
}
/// 取消更新
void cancelUpdate() {
_logger.i('❌ 用户取消更新');
emit(UpdateInitial());
}
}

View File

@@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'update_cubit.dart';
import 'update_state.dart';
import 'version_check_service.dart';
/// 更新对话框(带完整流程提示)
class UpdateDialog extends StatelessWidget {
final AppVersionInfo versionInfo;
const UpdateDialog({super.key, required this.versionInfo});
@override
Widget build(BuildContext context) {
return BlocListener<UpdateCubit, UpdateState>(
listener: (context, state) {
if (state is UpdateSuccess) {
Navigator.of(context).pop();
if (versionInfo.updateType == 'patch') {
_showRestartDialog(context);
}
} else if (state is UpdateFailure) {
Navigator.of(context).pop();
String errorMessage = state.error;
bool showApkPath = false;
String? apkPath;
if (errorMessage.contains('APK 文件位于:')) {
final parts = errorMessage.split('APK 文件位于:');
if (parts.length > 1) {
apkPath = parts[1].trim();
showApkPath = true;
errorMessage = '您取消了安装';
}
}
_showErrorDialog(context, errorMessage, showApkPath: showApkPath, apkPath: apkPath);
}
},
child: AlertDialog(
title: const Text('发现新版本'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('版本号: ${versionInfo.version}'),
const SizedBox(height: 8),
Text('更新类型: ${versionInfo.updateType == "patch" ? "差量更新" : "整包更新"}'),
const SizedBox(height: 8),
if (versionInfo.forceUpdate)
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(4),
),
child: const Text(
'⚠️ 强制更新',
style: TextStyle(color: Colors.red),
),
),
const SizedBox(height: 12),
const Text('更新内容:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(versionInfo.updateDesc.isEmpty ? '优化用户体验,修复已知问题' : versionInfo.updateDesc),
],
),
),
actions: [
if (!versionInfo.forceUpdate)
TextButton(
onPressed: () {
context.read<UpdateCubit>().cancelUpdate();
Navigator.of(context).pop();
},
child: const Text('稍后'),
),
BlocBuilder<UpdateCubit, UpdateState>(
builder: (context, state) {
if (state is UpdateDownloading) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
state.isPatch ? '⬇️ 正在下载补丁...' : '⬇️ 正在下载 APK...',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SizedBox(
width: 60,
height: 60,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: state.progress,
strokeWidth: 3,
),
Text('${(state.progress * 100).toInt()}%'),
],
),
),
],
);
} else if (state is UpdateClearingVersion) {
return const Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('✅ 版本号清理完成', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Text('正在调起安装页面...', style: TextStyle(fontSize: 12)),
SizedBox(height: 8),
CircularProgressIndicator(),
],
);
} else if (state is UpdateReadyToInstall) {
return ElevatedButton.icon(
onPressed: () {
context.read<UpdateCubit>().triggerInstall(state.apkPath);
},
icon: const Icon(Icons.install_mobile),
label: const Text('去安装'),
);
} else if (state is UpdateInstalling) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('📦 正在打开安装页面...', style: TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
const Text('请在系统安装界面确认安装', style: TextStyle(fontSize: 12)),
const SizedBox(height: 8),
const CircularProgressIndicator(),
],
);
} else {
return ElevatedButton(
onPressed: () {
if (versionInfo.updateType == 'patch' && versionInfo.patchUrl != null) {
context.read<UpdateCubit>().applyPatch(
versionInfo.patchUrl!,
versionInfo.version,
versionInfo.versionCode,
md5: versionInfo.patchMd5,
);
} else if (versionInfo.apkUrl != null) {
context.read<UpdateCubit>().downloadAndInstallApk(versionInfo.apkUrl!);
}
},
child: const Text('立即更新'),
);
}
},
),
],
),
);
}
void _showRestartDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('更新完成'),
content: const Text('差量更新已应用,需要重启应用才能生效。\n是否立即重启?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('稍后'),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
SystemNavigator.pop();
},
child: const Text('立即重启'),
),
],
),
);
}
void _showErrorDialog(BuildContext context, String message, {bool showApkPath = false, String? apkPath}) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('更新提示'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(message),
if (showApkPath && apkPath != null) ...[
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.blue.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'📁 APK 文件位置',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
),
const SizedBox(height: 6),
Text(
apkPath,
style: const TextStyle(fontSize: 11, fontFamily: 'monospace'),
),
const SizedBox(height: 6),
const Text(
'您可以到该目录手动点击 APK 文件进行安装',
style: TextStyle(fontSize: 11, color: Colors.grey),
),
],
),
),
],
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('确定'),
),
],
),
);
}
}
/// 显示整包更新提示(在浏览器中下载)
class FullApkUpdateDialog extends StatelessWidget {
final AppVersionInfo versionInfo;
const FullApkUpdateDialog({super.key, required this.versionInfo});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('发现新版本'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('版本号: ${versionInfo.version}'),
const SizedBox(height: 8),
const Text('更新类型: 整包更新'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'📱 整包更新步骤',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
SizedBox(height: 8),
Text(
'1️⃣ 点击"立即下载"打开浏览器\n'
'2️⃣ 等待 APK 下载完成\n'
'3️⃣ 手动安装新版本的 APK\n'
'4️⃣ 安装完成后,卸载旧版本 APP\n'
'5️⃣ 重新打开新版本 APP',
style: TextStyle(fontSize: 13, height: 1.6),
),
],
),
),
const SizedBox(height: 12),
if (versionInfo.updateDesc.isNotEmpty) ...[
const Text('更新内容:', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(versionInfo.updateDesc),
],
],
),
),
actions: [
if (!versionInfo.forceUpdate)
TextButton(
onPressed: () {
context.read<UpdateCubit>().cancelUpdate();
Navigator.of(context).pop();
},
child: const Text('稍后'),
),
ElevatedButton(
onPressed: () {
if (versionInfo.apkUrl != null) {
// 🔥 调用新方法:浏览器下载 + 清除补丁记录
context.read<UpdateCubit>().fullApkUpdateWithBrowser(versionInfo.apkUrl!);
Navigator.of(context).pop();
// 显示后续引导弹窗
_showManualInstallGuide(context);
}
},
child: const Text('立即下载'),
),
],
);
}
/// 显示手动安装引导弹窗
void _showManualInstallGuide(BuildContext context) {
Future.delayed(const Duration(milliseconds: 500), () {
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: const Text('🔔 重要提示'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'请在浏览器中完成以下操作:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
const Text('✅ 等待 APK 下载完成'),
const SizedBox(height: 8),
const Text('✅ 点击 APK 文件进行安装'),
const SizedBox(height: 8),
const Text('✅ 安装完成后,卸载当前旧版本'),
const SizedBox(height: 8),
const Text('✅ 重新打开新版本 APP'),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(6),
border: Border.all(color: Colors.red.shade200),
),
child: const Text(
'⚠️ 注意:必须先卸载旧版本,否则无法正常使用新功能!',
style: TextStyle(color: Colors.red, fontSize: 12),
),
),
],
),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
},
child: const Text('我知道了'),
),
],
),
);
});
}
}

View File

@@ -0,0 +1,67 @@
import 'version_check_service.dart';
/// 更新状态基类
abstract class UpdateState {
const UpdateState();
}
/// 初始状态
class UpdateInitial extends UpdateState {
const UpdateInitial();
}
/// 检查中
class UpdateChecking extends UpdateState {
const UpdateChecking();
}
/// 发现新版本
class UpdateAvailable extends UpdateState {
final AppVersionInfo versionInfo;
const UpdateAvailable(this.versionInfo);
}
/// 下载中
class UpdateDownloading extends UpdateState {
final double progress;
final bool isPatch; // true=差量补丁, false=完整APK
const UpdateDownloading(this.progress, {required this.isPatch});
}
/// 清理版本号中
class UpdateClearingVersion extends UpdateState {
const UpdateClearingVersion();
}
/// 准备安装
class UpdateReadyToInstall extends UpdateState {
final String apkPath;
const UpdateReadyToInstall(this.apkPath);
}
/// 安装中
class UpdateInstalling extends UpdateState {
final bool isPatch;
const UpdateInstalling({required this.isPatch});
}
/// 更新成功
class UpdateSuccess extends UpdateState {
const UpdateSuccess();
}
/// 已是最新版本
class UpdateUpToDate extends UpdateState {
const UpdateUpToDate();
}
/// 更新失败
class UpdateFailure extends UpdateState {
final String error;
const UpdateFailure(this.error);
}

View File

@@ -0,0 +1,327 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_patcher/flutter_patcher.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
/// 应用版本信息模型
class AppVersionInfo {
final String version;
final int versionCode;
final String updateType;
final bool forceUpdate;
final String updateDesc;
final String? patchUrl;
final String? patchMd5;
final String? apkUrl;
final String? apkMd5;
AppVersionInfo({
required this.version,
required this.versionCode,
required this.updateType,
required this.forceUpdate,
required this.updateDesc,
this.patchUrl,
this.patchMd5,
this.apkUrl,
this.apkMd5,
});
factory AppVersionInfo.fromJson(Map<String, dynamic> json) {
final patchData = json['patch'] as Map<String, dynamic>?;
final fullApkData = json['fullApk'] as Map<String, dynamic>?;
return AppVersionInfo(
version: json['version'] ?? '',
versionCode: json['versionCode'] ?? 0,
updateType: json['updateType'] ?? 'full',
forceUpdate: json['forceUpdate'] ?? false,
updateDesc: json['updateDesc'] ?? '',
patchUrl: patchData?['patchUrl'] as String?,
patchMd5: patchData?['md5'] as String?,
apkUrl: fullApkData?['apkUrl'] as String?,
apkMd5: fullApkData?['md5'] as String?,
);
}
}
/// 版本检查服务
class VersionCheckService {
static final VersionCheckService _instance = VersionCheckService._internal();
factory VersionCheckService() => _instance;
VersionCheckService._internal();
final Logger _logger = Logger();
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
validateStatus: (status) => true,
));
static const String apiUrl = 'http://8.159.134.0:8012/api/update/check';
static const String _patchVersionFileName = '.patch_version';
/// 检查版本更新
Future<AppVersionInfo?> checkUpdate({
required String currentVersion,
required int currentVersionCode,
}) async {
try {
debugPrint('');
debugPrint('========================================');
debugPrint(' UPDATE CHECK START');
debugPrint('========================================');
debugPrint('[1] APK version: $currentVersion ($currentVersionCode)');
// 从文件读取已应用的补丁版本
final patchInfo = await _loadPatchVersionInfo();
final appliedPatchVersion = patchInfo['version'] as String?;
final appliedPatchVersionCode = patchInfo['versionCode'] as int?;
debugPrint('[2] Patch file version: $appliedPatchVersion ($appliedPatchVersionCode)');
// 始终用 APK 基础版本请求服务端,让服务端返回最新版本
// 客户端自己判断本地是否已经是最新
final requestVersion = currentVersion;
final requestVersionCode = currentVersionCode;
debugPrint('[3] Request to server: version=$requestVersion, versionCode=$requestVersionCode');
debugPrint('[4] URL: $apiUrl?version=$requestVersion&versionCode=$requestVersionCode');
final response = await _dio.get(
apiUrl,
queryParameters: {
'version': requestVersion,
'versionCode': requestVersionCode,
},
);
debugPrint('[5] HTTP status: ${response.statusCode}');
debugPrint('[6] Response body: ${response.data}');
final respCode = response.data['code'];
final data = response.data['data'];
final message = response.data['message'] ?? '';
debugPrint('[7] respCode=$respCode, data=$data, message=$message');
if (respCode != 200 || data == null) {
debugPrint('[RESULT] No update - server returned code=$respCode, data=${data == null ? "NULL" : "NOT_NULL"}, message=$message');
debugPrint('========================================');
debugPrint('');
return null;
}
debugPrint('[7.1] data type: ${data.runtimeType}');
debugPrint('[7.2] data keys: ${data is Map ? data.keys.toList() : "NOT A MAP"}');
debugPrint('[7.3] hasUpdate = ${data['hasUpdate']} (type: ${data['hasUpdate']?.runtimeType})');
debugPrint('[7.4] version = ${data['version']}');
debugPrint('[7.5] versionCode = ${data['versionCode']}');
debugPrint('[7.6] updateType = ${data['updateType']}');
debugPrint('[7.7] patch = ${data['patch']}');
debugPrint('[7.8] fullApk = ${data['fullApk']}');
if (data['hasUpdate'] == false) {
debugPrint('[RESULT] No update - hasUpdate=false');
debugPrint('========================================');
debugPrint('');
return null;
}
final versionInfo = AppVersionInfo.fromJson(data);
debugPrint('[8] Server version: ${versionInfo.version} (${versionInfo.versionCode})');
debugPrint('[9] Update type: ${versionInfo.updateType}, force: ${versionInfo.forceUpdate}');
debugPrint('[10] patchUrl: ${versionInfo.patchUrl}');
debugPrint('[11] patchMd5: ${versionInfo.patchMd5}');
debugPrint('[12] apkUrl: ${versionInfo.apkUrl}');
// 只用版本字符串判断是否已更新:服务端 hasUpdate=true 说了算
if (appliedPatchVersion != null && appliedPatchVersion == versionInfo.version) {
debugPrint('[RESULT] No update - already at version ${versionInfo.version}');
debugPrint('========================================');
debugPrint('');
return null;
}
debugPrint('[8.1] Local patch version: ${appliedPatchVersion ?? "none"}');
debugPrint('[8.2] Server version: ${versionInfo.version}');
debugPrint('[8.3] Version different: ${appliedPatchVersion != versionInfo.version}');
debugPrint('[RESULT] NEW VERSION FOUND: ${versionInfo.version} (${versionInfo.versionCode})');
debugPrint('========================================');
debugPrint('');
return versionInfo;
} catch (e) {
debugPrint('[RESULT] ERROR: $e');
debugPrint('========================================');
debugPrint('');
return null;
}
}
/// 下载并应用差量补丁
Future<bool> applyPatch({
required String patchUrl,
required String version,
required int targetVersionCode,
String? md5,
Function(double)? onProgress,
}) async {
try {
_logger.i('⬇️ 开始下载补丁: $patchUrl');
_logger.i('🎯 目标版本: $version ($targetVersionCode)');
final tempDir = await getTemporaryDirectory();
final patchPath = '${tempDir.path}/update_patch.so';
await _dio.download(
patchUrl,
patchPath,
onReceiveProgress: (received, total) {
if (total != -1 && onProgress != null) {
onProgress(received / total);
}
},
);
_logger.i('✅ 补丁下载完成,开始应用...');
final result = await FlutterPatcher.applyPatch(
PatchInfo(
version: version,
patchUrl: 'file://$patchPath',
targetVersionCode: targetVersionCode,
md5: md5 ?? '',
),
);
if (result.ok) {
_logger.i('✅ 补丁应用成功');
// 保存补丁版本号到文件
await _savePatchVersionInfo(version, targetVersionCode);
// 验证保存
final verifyInfo = await _loadPatchVersionInfo();
_logger.i('💾 已保存并验证: ${verifyInfo['version']} (${verifyInfo['versionCode']})');
} else {
_logger.e('❌ 补丁应用失败: ${result.error}');
}
return result.ok;
} catch (e) {
_logger.e('❌ 应用补丁异常: $e');
return false;
}
}
/// 下载完整 APK
Future<String?> downloadApk(String apkUrl, Function(double) onProgress) async {
try {
_logger.i('⬇️ 开始下载 APK: $apkUrl');
final cacheDir = await getApplicationCacheDirectory();
final apkPath = '${cacheDir.path}/app-update.apk';
_logger.i('📁 APK 下载路径: $apkPath');
await _dio.download(
apkUrl,
apkPath,
onReceiveProgress: (received, total) {
if (total != -1) {
onProgress(received / total);
}
},
);
_logger.i('✅ APK 下载完成: $apkPath');
return apkPath;
} catch (e) {
_logger.e('❌ 下载 APK 失败: $e');
return null;
}
}
/// 回滚到内置版本
Future<void> rollback() async {
try {
_logger.i('🔄 执行回滚操作');
await FlutterPatcher.rollback();
_logger.i('✅ 回滚成功');
} catch (e) {
_logger.e('❌ 回滚失败: $e');
}
}
/// 清除补丁版本信息(整包更新后调用)
Future<void> clearPatchVersionInfo() async {
try {
final directory = await getApplicationSupportDirectory();
final file = File('${directory.path}/$_patchVersionFileName');
if (await file.exists()) {
await file.delete();
_logger.i('🗑️ 已清除补丁版本记录');
}
} catch (e) {
_logger.e('❌ 清除补丁版本信息失败: $e');
}
}
/// 保存补丁版本信息到文件
Future<void> _savePatchVersionInfo(String version, int versionCode) async {
try {
final directory = await getApplicationSupportDirectory();
final file = File('${directory.path}/$_patchVersionFileName');
final content = '$version|$versionCode';
await file.writeAsString(content);
// 🔥 立即验证写入
final verifyContent = await file.readAsString();
_logger.i('💾 补丁版本已保存到: ${file.path}');
_logger.i('💾 写入内容: $content');
_logger.i('💾 验证读取: $verifyContent');
_logger.i('💾 文件存在: ${await file.exists()}');
} catch (e) {
_logger.e('❌ 保存补丁版本信息失败: $e');
}
}
/// 从文件加载补丁版本信息
Future<Map<String, dynamic>> _loadPatchVersionInfo() async {
try {
final directory = await getApplicationSupportDirectory();
final file = File('${directory.path}/$_patchVersionFileName');
_logger.i('📂 尝试加载补丁版本文件: ${file.path}');
_logger.i('📂 文件存在: ${await file.exists()}');
if (await file.exists()) {
final content = await file.readAsString();
_logger.i('📂 文件内容: $content');
final parts = content.split('|');
if (parts.length == 2) {
final version = parts[0];
final versionCode = int.tryParse(parts[1]);
if (versionCode != null) {
_logger.i('📂 解析成功: version=$version, versionCode=$versionCode');
return {'version': version, 'versionCode': versionCode};
} else {
_logger.e('📂 versionCode 解析失败: ${parts[1]}');
}
} else {
_logger.e('📂 文件格式错误,parts.length=${parts.length}');
}
}
_logger.i('📂 未找到有效的补丁版本信息');
return {'version': null, 'versionCode': null};
} catch (e) {
_logger.e('❌ 加载补丁版本信息失败: $e');
return {'version': null, 'versionCode': null};
}
}
}

View File

@@ -0,0 +1,24 @@
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
/// 图片地址工具:后端返回的图片路径可能是相对路径(如 /profile/avatar/xxx.jpg),
/// 直接交给 Image.network 会因缺少 host 抛 "No host specified in URI" 异常。
/// 此工具负责将相对路径拼接成完整的服务器地址。
class ImageUrlUtil {
ImageUrlUtil._();
/// 补全图片地址
/// - 已是完整 http(s) 地址:原样返回
/// - 以 / 开头的相对路径:拼接 baseUrl
/// - 其他相对路径:拼接 baseUrl + /
/// - null 或空:返回 null
static String? resolve(String? path) {
if (path == null || path.isEmpty) return null;
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
if (path.startsWith('/')) {
return '${HttpApiConsts.baseUrl}$path';
}
return '${HttpApiConsts.baseUrl}/$path';
}
}

View File

@@ -60,48 +60,48 @@ class AuthTcpDatasourceImpl implements AuthTcpDatasource {
debugPrint('✅ sendAuthPacket[AuthTcp] 认证包已发送');
// 获取用户设备列表
try {
// 1. 获取 Either 结果
final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
// 2. 使用 fold 解包 Either
// left: 处理错误情况 (DeviceFailure)
// right: 处理成功情况 (List<DeviceEntity>)
await eitherResult.fold(
(failure) {
// 处理失败:打印日志或抛出异常
debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure');
throw Exception('获取设备列表失败:$failure');
},
(devices) async {
// 处理成功:devices 现在是真正的 List<DeviceEntity>
if (devices.isEmpty) {
debugPrint('⚠️ sendAuthPacket[AuthTcp] 当前用户无可用设备,跳过切换步骤');
return;
}
// 取第一个设备
final DeviceEntity targetDevice = devices.first;
debugPrint('📱 sendAuthPacket[AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
// 切换设备 (同样,如果 switchDeviceUseCase 也返回 Either,也需要 fold 处理)
final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
await switchResult.fold(
(failure) {
debugPrint('❌ sendAuthPacket[AuthTcp] 切换设备失败:$failure');
throw Exception('切换设备失败:$failure');
},
(success) {
debugPrint('✅ sendAuthPacket[AuthTcp] 设备切换成功,服务端应开始推送数据');
},
);
},
);
} catch (e) {
debugPrint('❌ sendAuthPacket[AuthTcp] 设备订阅流程异常:$e');
rethrow;
}
// try {
// // 1. 获取 Either 结果
// final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
//
// // 2. 使用 fold 解包 Either
// // left: 处理错误情况 (DeviceFailure)
// // right: 处理成功情况 (List<DeviceEntity>)
// await eitherResult.fold(
// (failure) {
// // 处理失败:打印日志或抛出异常
// debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure');
// throw Exception('获取设备列表失败:$failure');
// },
// (devices) async {
// // 处理成功:devices 现在是真正的 List<DeviceEntity>
// if (devices.isEmpty) {
// debugPrint('⚠️ sendAuthPacket[AuthTcp] 当前用户无可用设备,跳过切换步骤');
// return;
// }
//
// // 取第一个设备
// final DeviceEntity targetDevice = devices.first;
// debugPrint('📱 sendAuthPacket[AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
//
// // 切换设备 (同样,如果 switchDeviceUseCase 也返回 Either,也需要 fold 处理)
// final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
//
// await switchResult.fold(
// (failure) {
// debugPrint('❌ sendAuthPacket[AuthTcp] 切换设备失败:$failure');
// throw Exception('切换设备失败:$failure');
// },
// (success) {
// debugPrint('✅ sendAuthPacket[AuthTcp] 设备切换成功,服务端应开始推送数据');
// },
// );
// },
// );
// } catch (e) {
// debugPrint('❌ sendAuthPacket[AuthTcp] 设备订阅流程异常:$e');
// rethrow;
// }
}
}

View File

@@ -8,21 +8,26 @@ class UserModel extends UserEntity implements BaseModel {
required super.username,
required super.nickname,
required super.token,
required super.orgId,
super.avatar,
super.email,
super.phone,
super.roleKey,
super.siteId,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
print(json);
return UserModel(
userId: json['userId'],
username: json['username'],
nickname: json['nickName'],
token: json['token'],
userId: json['userId']?.toString() ?? '',
username: json['username'] ?? '',
nickname: json['nickName'] ?? '',
token: json['token'] ?? '',
orgId: (json['orgId'] as num?)?.toInt() ?? 0,
avatar: json['avatar'],
email: json['email'],
phone: json['phone'],
roleKey: json['roleKey'],
siteId: (json['siteId'] as num?)?.toInt(),
);
}
@@ -32,9 +37,12 @@ class UserModel extends UserEntity implements BaseModel {
'username': username,
'nickName': nickname,
'token': token,
'orgId': orgId,
'avatar': avatar,
'email': email,
'phone': phone,
'roleKey': roleKey,
'siteId': siteId,
};
}
@@ -44,9 +52,12 @@ class UserModel extends UserEntity implements BaseModel {
username: username,
nickname: nickname,
token: token,
orgId: orgId,
avatar: avatar,
email: email,
phone: phone,
roleKey: roleKey,
siteId: siteId,
);
}
@@ -56,9 +67,12 @@ class UserModel extends UserEntity implements BaseModel {
username: entity.username,
nickname: entity.nickname,
token: entity.token,
orgId: entity.orgId,
avatar: entity.avatar,
email: entity.email,
phone: entity.phone,
roleKey: entity.roleKey,
siteId: entity.siteId,
);
}
}
}

View File

@@ -1,10 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:get_it/get_it.dart';
import 'package:maibu_satabot_v2/core/consts/http_api_consts.dart';
import 'package:maibu_satabot_v2/core/consts/tcp_consts.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:maibu_satabot_v2/core/domain/entities/user_entity.dart';
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.dart';
@@ -19,9 +23,16 @@ import '../../../devices/presentation/bloc/devices_cubit.dart';
import '../../../devices/presentation/bloc/device_status_bloc.dart';
import '../../../devices/presentation/bloc/device_status_event.dart';
import '../../../devices/presentation/bloc/devices_state.dart';
import '../../../devices/presentation/bloc/device_task_cubit.dart';
import '../../../home/presentation/bloc/permission_request_bloc.dart';
import '../../../my/presentation/bloc/my_cubit.dart';
import '../../../remote_control/presentation/bloc/remote_control_cubit.dart';
import '../../../../core/network/mqtt/domain/interfaces/mqtt_client.dart';
import '../../../../features/v2/site/presentation/cubit/site_cubit.dart';
import '../../data/datasources/auth_tcp_datasource.dart';
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
import 'auth_state.dart';
import '../../../../main.dart'; // 🔥 导入 main.dart 以使用 navigatorKey
/// emit做的事情:
/// 1.修改状态标志:通过改变类的类型(从 Initial 变成 Authenticated)
@@ -37,74 +48,492 @@ class AuthCubit extends Cubit<AuthState> {
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
AuthCubit(this.storage, this.tcp, this.appCubit, this.dispatcher, this._authTcpDatasource) : super(AuthInitial()) {
// 🔥 登录验证 Completer:用于等待登录阶段的 have_logged_in 推送
Completer<bool>? _loginVerificationCompleter;
// 🔥 安全模式标志位:保护关键操作阶段不被异地登录踢下线
bool _isSafeMode = false;
Timer? _safeModeExitTimer;
AuthCubit(
this.storage,
this.tcp,
this.appCubit,
this.dispatcher,
this._authTcpDatasource,
) : super(AuthInitial()) {
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
_listenToAuthResponse();
// 🔥 设置重连耗尽回调:连续4次重连失败后弹窗提示用户退出登录
tcp.onReconnectExhausted = _showReconnectFailedDialog;
// 🔥 设置重连提示回调:重连中/重连成功 SnackBar
tcp.onReconnectStarted = _showReconnectingToast;
tcp.onReconnectSuccess = _showReconnectSuccessToast;
}
/// App 启动时检查本地缓存
Future<void> appStarted() async {
final logger = GetIt.I<ILoggerService>() as SentryLoggerImpl;
// print("App 启动时检查本地缓存");
final user = await storage.getUser();
logger.logWithLevel(
try {
final prefs = GetIt.I<SharedPreferences>();
// Step 1: 先生成新的 session_id,保存旧的
final oldSessionId = prefs.getString('current_session_id');
final newSessionId = DateTime.now().millisecondsSinceEpoch.toString();
await prefs.setString('current_session_id', newSessionId);
debugPrint('📱 [AUTH] 新会话: $newSessionId,旧会话: $oldSessionId');
// Step 2: 检查是否从后台被杀
final pendingKillLogout = prefs.getBool('pending_kill_logout') ?? false;
final savedSessionId = prefs.getString('saved_session_id');
if (pendingKillLogout && savedSessionId != null && oldSessionId != null) {
if (savedSessionId == oldSessionId) {
// saved == old_current → App 在后台被杀,从未恢复过
logger.logWithLevel('🔄 [AUTH] 检测到 APP 被后台杀死,执行退出登录', level: 'INFO');
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
await storage.deleteUser();
emit(AuthUnauthenticated());
return;
} else {
// saved != old_current → App 被杀前已恢复过,清除标记
logger.logWithLevel(
'📱 [AUTH] pending_kill_logout=true 但会话已恢复过,清除标记',
level: 'INFO',
);
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
}
} else if (pendingKillLogout) {
logger.logWithLevel(
'📱 [AUTH] pending_kill_logout=true 但无会话信息,清除标记',
level: 'INFO',
);
await prefs.setBool('pending_kill_logout', false);
await prefs.remove('saved_session_id');
}
} catch (e) {
logger.logWithLevel('❌ [AUTH] 检查杀后台标记失败: $e', level: 'ERROR');
}
try {
final user = await storage.getUser();
logger.logWithLevel(
'启动时检查本地缓存',
level: 'INFO',
data: {'user': user, 'data': user}
);
if (user != null) {
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
// await _authTcpDatasource.sendAuthPacket();//包括发送认证包和获取列表和切换函数
tcp.startHeartbeat(interval: const Duration(seconds: 4));
// 2. 同步全局 App 状态
appCubit.setAuth(user);
// 3. 进入已登录状态
emit(AuthAuthenticated(user));
} else {
// print("App 启动时检查本地缓存user=null) ");
data: {'user': user != null ? '找到用户: ${user.username}' : '未找到用户'},
);
if (user != null) {
final isValid = await _verifyToken(user);
if (isValid) {
logger.logWithLevel('✅ [AUTH] Token 有效,恢复登录状态', level: 'INFO');
appCubit.setAuth(user);
emit(AuthAuthenticated(user));
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
} else {
logger.logWithLevel('⚠️ [AUTH] Token 已过期,清除本地缓存', level: 'WARN');
await storage.deleteUser();
emit(AuthUnauthenticated());
logger.logWithLevel(
'⚠️ [AUTH] 应用启动 - Token 过期,进入未登录状态',
level: 'INFO',
);
}
} else {
logger.logWithLevel('⚠️ [AUTH] 应用启动 - 无本地缓存,进入未登录状态', level: 'INFO');
emit(AuthUnauthenticated());
}
} catch (e) {
logger.logWithLevel('❌ [AUTH] 应用启动检查失败: $e', level: 'ERROR');
emit(AuthUnauthenticated());
}
}
Future<bool> _verifyToken(UserEntity user) async {
try {
final verifyDio = Dio(
BaseOptions(
baseUrl: HttpApiConsts.baseUrl,
headers: {'Authorization': 'Bearer ${user.token}'},
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
),
);
final response = await verifyDio.get(
HttpApiConsts.getUserDevicesList,
queryParameters: {'tenantName': user.username},
);
if (response.statusCode == 200) {
final data = response.data;
if (data is Map<String, dynamic> && data['code'] == 401) {
return false;
}
return true;
}
return false;
} on DioException catch (e) {
final statusCode = e.response?.statusCode;
debugPrint('>>> [AUTH] Token 验证失败: HTTP $statusCode');
if (statusCode == 401 || statusCode == 403) {
return false;
}
return true;
} catch (e) {
debugPrint('>>> [AUTH] Token 验证异常: $e');
return true;
}
}
/// 当 HTTP 登录/注册成功后调用
Future<void> loginSuccess(UserEntity user) async {
await storage.saveUser(user);
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
// await _authTcpDatasource.sendAuthPacket(); //包括发送认证包和获取列表和切换函数
//tcp.startHeartbeat(interval: const Duration(seconds: 4)); //启动心跳
// 🔥 登录后立即创建TCP连接并验证
try {
debugPrint('>>> [AUTH] 登录成功,开始建立TCP连接...');
_logger.logWithLevel('>>> [AUTH] 登录成功,开始建立TCP连接...', shouldLog: true);
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
tcp.startHeartbeat(interval: const Duration(seconds: 4));
// 🔥 已注释:等待 2.5 秒看是否收到 have_logged_in 的验证逻辑
// bool isKicked = await _waitForLoginVerification();
//
// if (isKicked) {
// // 🔥 不能进入 APP,必须清除所有登录信息
// debugPrint('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息...');
// _logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,清除登录信息', shouldLog: true);
//
// // 1. 删除已保存的用户信息
// await storage.deleteUser();
// debugPrint('✅ [AUTH] 已删除用户信息');
//
// // 2. 断开 TCP 连接
// tcp.forceDisconnect();
// debugPrint('✅ [AUTH] 已断开 TCP 连接');
//
// // 3. 显示 Toast
// _showLoginFailedToast("账号已在其他设备登录");
// debugPrint('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页');
// _logger.logWithLevel('>>> [AUTH] ⚠️ 登录验证失败,停留在登录页', shouldLog: true);
// return; // 停留在登录页
// }
debugPrint('✅ [AUTH] TCP连接成功,验证通过(已跳过等待)');
_logger.logWithLevel('✅ [AUTH] TCP连接成功,验证通过(已跳过等待)', shouldLog: true);
} catch (e) {
debugPrint('❌ [AUTH] TCP连接失败:$e');
_logger.logWithLevel('❌ [AUTH] TCP连接失败:$e', level: 'ERROR');
// TCP 连接失败不影响登录流程,继续执行
}
appCubit.setAuth(user);
emit(AuthAuthenticated(user));
}
/// 退出登录 (主动或被动)
Future<void> logout() async {
//print("退出登录");
print('>>> [AUTH] 🚨🚨🚨 logout() 被调用!时间: ${DateTime.now()}');
print('>>> [AUTH] 调用栈:\n${StackTrace.current}');
// 🔥 关键修复:在断开TCP前,先清空所有业务状态,避免重连触发旧数据
_clearAllBusinessState();
await storage.deleteUser();
tcp.disconnect();
tcp.forceDisconnect(); // 🔥 修复:彻底断开连接,禁止自动重连
appCubit.clearAuth();
emit(AuthUnauthenticated());
}
/// 🔥 Token 过期处理:弹出提示后退出登录
Future<void> tokenExpired() async {
_showTokenExpiredDialog();
Future.delayed(const Duration(seconds: 2), () {
logout();
});
}
void _showTokenExpiredDialog() {
try {
final context = navigatorKey.currentContext;
if (context != null) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('登录已过期'),
content: const Text('账号登录状态已过期,请重新登录'),
actions: [
ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
},
child: const Text('确定'),
),
],
);
},
);
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示 Token 过期弹窗失败:$e');
}
}
/// 🔥 新增:显示登录失败 Toast(黑色背景,和登录错误提示一致)
void _showLoginFailedToast(String message) {
try {
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.black,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示Toast失败:$e');
}
}
/// 🔥 新增:显示异地登录提示弹窗
void _showKickOutDialog() {
try {
debugPrint('>>> [AUTH] 📢 准备显示异地登录提示弹窗');
_logger.logWithLevel('>>> [AUTH] 📢 准备显示异地登录提示弹窗', shouldLog: true);
// 使用全局 navigatorKey 显示弹窗
final context = navigatorKey.currentContext;
if (context != null) {
showDialog(
context: context,
barrierDismissible: false, // 不允许点击背景关闭
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('账号被顶下线'),
content: const Text('您的账号在其他设备登录,即将退出当前设备...'),
actions: [],
);
},
);
} else {
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出');
_logger.logWithLevel(
'>>> [AUTH] ⚠️ 无法获取 Navigator Context,将直接退出',
shouldLog: true,
);
}
// 🔥 延迟 2 秒后执行退出,给用户时间看到提示
Future.delayed(const Duration(seconds: 2), () {
debugPrint('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录');
_logger.logWithLevel('>>> [AUTH] ⏰ 延迟结束,开始执行退出登录', shouldLog: true);
logout();
});
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示弹窗失败:$e');
_logger.logWithLevel('>>> [AUTH] ❌ 显示弹窗失败:$e', level: 'ERROR');
// 即使显示弹窗失败,也要执行退出
logout();
}
}
/// 🔥 TCP 重连耗尽弹窗:连续4次重连失败后提示用户退出登录
void _showReconnectFailedDialog() {
try {
debugPrint('>>> [AUTH] 📢 重连耗尽,准备显示重连失败提示弹窗');
_logger.logWithLevel('>>> [AUTH] 📢 重连耗尽,显示重连失败弹窗', shouldLog: true);
final context = navigatorKey.currentContext;
if (context != null) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off_rounded, size: 48, color: Color(0xFFF53F3F)),
const SizedBox(height: 12),
const Text(
'连接异常',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: Color(0xFF1D2129),
),
),
const SizedBox(height: 8),
const Text(
'TCP重连认证无效请重新登陆',
style: TextStyle(fontSize: 14, color: Color(0xFF86909C)),
textAlign: TextAlign.center,
),
],
),
actions: [
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.of(dialogContext).pop(),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF86909C),
side: const BorderSide(color: Color(0xFFE5E6EB)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('取消', style: TextStyle(fontSize: 15)),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.of(dialogContext).pop();
logout();
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF165DFF),
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('退出登录', style: TextStyle(fontSize: 15)),
),
),
],
),
],
);
},
);
} else {
debugPrint('>>> [AUTH] ⚠️ 无法获取 Navigator Context,直接退出登录');
logout();
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示重连失败弹窗失败:$e');
logout();
}
}
/// 🔥 重连中提示 SnackBar
void _showReconnectingToast() {
try {
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('TCP重连中...'),
backgroundColor: Color(0xFFFF7D00),
duration: Duration(seconds: 4),
),
);
}
} catch (_) {}
}
/// 🔥 重连成功提示 SnackBar
void _showReconnectSuccessToast() {
try {
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('连接成功'),
backgroundColor: Color(0xFF00B42A),
duration: Duration(seconds: 2),
),
);
}
} catch (_) {}
}
/// 🔥 新增:清空所有业务状态,防止数据泄露到新账户
void _clearAllBusinessState() {
try {
// 1. 清空远程控制所有状态(targetDevice、权限、摇杆数据、电压电量等)
final remoteControlCubit = GetIt.I<RemoteControlCubit>();
remoteControlCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 RemoteControlCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 RemoteControlCubit 状态');
// 2. 清空设备列表和选中设备
final devicesCubit = GetIt.I<DevicesCubit>();
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
// 1. 清空设备列表和选中设备
devicesCubit.emit(const DevicesState());
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
// 2. 清空设备实时状态
// 3. 清空设备实时状态(图表数据等)
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
deviceStatusBloc.add(DeviceStatusReset());
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
_logger.logWithLevel('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
// 4. 清空场站所有数据(列表、选中状态、_hasLoadedSites 标记)
final siteCubit = GetIt.I<SiteCubit>();
siteCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 SiteCubit 所有数据');
_logger.logWithLevel('✅ [AUTH] 已清空 SiteCubit 所有数据');
// 5. 清空设备任务(taskPool、currentTask、currentTaskId 等)
final deviceTaskCubit = GetIt.I<DeviceTaskCubit>();
deviceTaskCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 DeviceTaskCubit 状态');
// 6. 清空权限请求弹窗状态
final permissionRequestBloc = GetIt.I<PermissionRequestBloc>();
permissionRequestBloc.clearAll();
debugPrint('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 PermissionRequestBloc 状态');
// 7. 清空我的页面数据(昵称等个人信息)
final myCubit = GetIt.I<MyCubit>();
myCubit.clearAll();
debugPrint('✅ [AUTH] 已清空 MyCubit 状态');
_logger.logWithLevel('✅ [AUTH] 已清空 MyCubit 状态');
// 8. 断开 MQTT 连接(避免新用户收到上个用户的实时推送)
try {
final droneOsdClient = GetIt.I<MqttClient>(instanceName: 'droneOsdClient');
if (droneOsdClient.isConnected) {
droneOsdClient.disconnect();
debugPrint('✅ [AUTH] 已断开 droneOsdClient MQTT');
}
} catch (_) {}
try {
final taskMessageClient = GetIt.I<MqttClient>(instanceName: 'taskMessageClient');
if (taskMessageClient.isConnected) {
taskMessageClient.disconnect();
debugPrint('✅ [AUTH] 已断开 taskMessageClient MQTT');
}
} catch (_) {}
// 9. 清除 SharedPreferences 会话相关 key
final prefs = GetIt.I<SharedPreferences>();
prefs.remove('current_session_id');
prefs.remove('pending_kill_logout');
prefs.remove('saved_session_id');
debugPrint('✅ [AUTH] 已清除 SharedPreferences 会话 key');
debugPrint('✅ [AUTH] 所有业务状态已清空');
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
} catch (e) {
@@ -113,83 +542,213 @@ class AuthCubit extends Cubit<AuthState> {
}
}
/// TCP 指令监听
// void _listenToAuthResponse() {
// print('>>> [AUTH] begin指令监听: ');
// _kickOutSub?.cancel(); // 防止重复监听
//
// // 假设 0x12 是踢下线或多设备登录提醒
// _kickOutSub = dispatcher.onJsonMessage(0x12).listen((json) {
// // 如果后端发来指令确认需要退出
// print('>>> [AUTH] 收到 0x12: $json');
// // logout();
// final respond = json['respond'] ?? '';
// if (respond == 'have_logged_in') {
// logout(); // ✅ 只有特定消息才退出
// }
// });
// }
/// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in)
Future<bool> _waitForLoginVerification() async {
_loginVerificationCompleter = Completer<bool>();
// 等待 2.5 秒
await Future.delayed(const Duration(milliseconds: 2500));
// 如果 completer 还没完成,说明没收到 have_logged_in,返回 false(可以进入)
if (!_loginVerificationCompleter!.isCompleted) {
_loginVerificationCompleter!.complete(false);
}
return _loginVerificationCompleter!.future;
}
/// TCP 指令监听
void _listenToAuthResponse() {
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'DEBUG');
debugPrint('>>> [AUTH] _listenToAuthResponse() 被调用,开始监听 0x12 指令');
_kickOutSub?.cancel(); // 防止重复监听
// 直接监听原始数据包,自己处理 JSON 解析(去掉 CRC 字节)
_kickOutSub = dispatcher.onCommand(0x12).listen((packet) {
_logger.logWithLevel('[AUTH] 获取原始包:$packet', level: 'DEBUG');
_logger.logWithLevel('>>> [AUTH] 收到 0x12 原始包,payload 长度=${packet.payload.length}, 内容=${packet.payload}', level: 'DEBUG');
debugPrint(
'>>> [AUTH] 收到 0x12 原始包,payload 长度=${packet.payload.length}, 内容=${packet.payload}',
);
try {
// 🔥 关键:手动去掉最后 2 个 CRC 字节
String jsonString;
if (packet.payload.length > 2) {
jsonString = utf8.decode(packet.payload.sublist(0, packet.payload.length - 2));
jsonString = utf8.decode(
packet.payload.sublist(0, packet.payload.length - 2),
);
} else {
jsonString = utf8.decode(packet.payload);
}
_logger.logWithLevel('[AUTH] 获取 JSON: $jsonString', level: 'DEBUG');
debugPrint('>>> [AUTH] 获取 JSON: "$jsonString"');
final jsonMap = jsonDecode(jsonString);
_logger.logWithLevel('[AUTH] 获取 JSON Map: $jsonMap', level: 'DEBUG');
debugPrint('>>> [AUTH] 获取 JSON Map: $jsonMap');
final respond = jsonMap['respond'] ?? '';
debugPrint('>>> [AUTH] respond 字段值: "$respond"');
_logger.logWithLevel(
'>>> [AUTH] respond 字段值: "$respond"',
shouldLog: true,
);
debugPrint('>>> [AUTH] respond 类型: ${respond.runtimeType}');
debugPrint(
'>>> [AUTH] respond == "have_logged_in": ${respond == "have_logged_in"}',
);
if (respond == 'have_logged_in') {
_logger.logWithLevel('[AUTH] ⚠️ 检测到异地登录 (respond=have_logged_in),开始退出...', level: 'WARN');
logout();
// 🔥 登录阶段策略:HTTP 已成功,TCP 认证阶段的推送视为服务端状态同步,直接放行
if (_loginVerificationCompleter != null &&
!_loginVerificationCompleter!.isCompleted) {
debugPrint(
'>>> [AUTH] 🛡️ 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入 APP',
);
_logger.logWithLevel(
'🛡️ [AUTH] 登录阶段收到 have_logged_in,视为服务端状态同步,允许进入',
shouldLog: true,
);
_loginVerificationCompleter!.complete(false); // 标记为可以进入
return;
}
// 🔥 已登录阶段策略:检查是否处于安全模式
if (_isSafeMode) {
print('>>> [AUTH] 🛡️ 安全模式下拦截 have_logged_in 推送,防止控制中断');
_logger.logWithLevel('🛡️ [AUTH] 安全模式下拦截异地登录推送', level: 'WARN');
return; // 拦截退出逻辑
}
print('>>> [AUTH] ⚠️ 已登录状态下收到 TCP 0x12 指令 respond=have_logged_in');
_logger.logWithLevel(
'⚠️ [AUTH] 已登录状态收到 have_logged_in,弹出异地登录提示',
level: 'WARN',
);
// 🔥 关键:检查是否是自身 TCP 重连触发的 have_logged_in
// 如果是自身刚发送 0x03 认证包引起的,忽略这次推送
if (tcp.isOwnAuthTriggeredKick()) {
debugPrint('>>> [AUTH] 🛡️ 检测到是自身认证触发的 have_logged_in,忽略');
_logger.logWithLevel(
'🛡️ [AUTH] 自身认证触发的 have_logged_in,忽略',
level: 'INFO',
);
tcp.clearAuthTimestamp();
return;
}
// 🔥 弹出"账号被顶下线"提示,2秒后执行退出登录
_showKickOutDialog();
} else {
_logger.logWithLevel('[AUTH] ⚠️ 检测到异地登录 (respond=$respond),开始退出...', level: 'WARN');
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
}
} catch (e) {
_logger.logWithLevel('[AUTH] ❌ JSON 解析失败:$e', level: 'ERROR');
debugPrint('>>> [AUTH] ❌ JSON 解析失败:$e');
}
});
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'DEBUG');
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'INFO');
}
/// 🔥 安全模式管理:进入关键页面时调用
void enterSafeMode() {
_isSafeMode = true;
_safeModeExitTimer?.cancel(); // 取消之前的退出倒计时
debugPrint('>>> [AUTH] 🛡️ 已进入安全模式,异地登录推送将被拦截');
_logger.logWithLevel('>>> [AUTH] 🛡️ 已进入安全模式', shouldLog: true);
_showSafeModeEnteredToast(); // 🔥 显示进入提示
}
/// 🔥 安全模式管理:离开关键页面时调用
void exitSafeMode() {
// 启动 90 秒倒计时
_safeModeExitTimer?.cancel();
_safeModeExitTimer = Timer(const Duration(seconds: 90), () {
if (_isSafeMode) {
_isSafeMode = false;
debugPrint('>>> [AUTH] ⏰ 90秒无操作,已自动关闭安全模式');
_logger.logWithLevel('>>> [AUTH] ⏰ 90秒无操作,已自动关闭安全模式', shouldLog: true);
_showSafeModeClosedToast();
}
});
debugPrint('>>> [AUTH] 🕒 已离开安全页面,90秒后若无操作将关闭安全模式');
_showSafeModeCountdownToast();
}
/// 🔥 显示“已进入安全模式”提示
void _showSafeModeEnteredToast() {
try {
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('已进入安全模式,防止异地登录干扰'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示安全模式提示失败:$e');
}
}
/// 🔥 显示“90秒后关闭”提示
void _showSafeModeCountdownToast() {
try {
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('90秒后无操作将关闭安全模式'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 3),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示倒计时提示失败:$e');
}
}
/// 🔥 显示“安全模式已关闭”提示
void _showSafeModeClosedToast() {
try {
final context = navigatorKey.currentContext;
if (context != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('安全模式已关闭,恢复异地登录检测'),
backgroundColor: Colors.grey,
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
),
);
}
} catch (e) {
debugPrint('>>> [AUTH] ❌ 显示关闭提示失败:$e');
}
}
@override
Future<void> close() {
_kickOutSub?.cancel(); // 销毁 Cubit 时关闭监听
_safeModeExitTimer?.cancel(); // 清理倒计时
return super.close();
}
/// 🔥 息屏/后台后恢复到前台时的重连方法
Future<void> reconnectAfterResume() async {
_logger.logWithLevel('[AUTH] 检测到应用恢复到前台,检查 TCP 连接状态...', level: 'DEBUG');
_logger.logWithLevel('[AUTH] 检测到应用恢复到前台,检查 TCP 连接状态...', level: 'INFO');
// 如果 TCP 未连接,则执行重连
if (!tcp.isConnected) {
_logger.logWithLevel('[AUTH] TCP 未连接,开始重连...',
level: 'DEBUG');
_logger.logWithLevel('[AUTH] TCP 未连接,开始重连...', level: 'INFO');
try {
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
tcp.startHeartbeat(interval: const Duration(seconds: 4));
_logger.logWithLevel('[AUTH] TCP 重连成功!', level: 'DEBUG');
_logger.logWithLevel('[AUTH] TCP 重连成功!', level: 'INFO');
} catch (e) {
_logger.logWithLevel('[AUTH] TCP 重连失败:$e', level: 'ERROR');
_logger.logWithLevel('[AUTH] TCP 重连失败:$e', level: 'ERROR');
}
} else {
_logger.logWithLevel('[AUTH] TCP 已连接,无需重连', level: 'DEBUG');
_logger.logWithLevel('[AUTH] TCP 已连接,无需重连', level: 'INFO');
// 可选:发送一个心跳包确认连接有效
tcp.sendHeartbeat();
}

View File

@@ -20,15 +20,24 @@ class LoginCubit extends Cubit<LoginState> {
Future<void> login(String username, String password, sourceType) async {
emit(LoginLoading());
try {
final result = await loginUseCase.call(LoginParams(username, password, sourceType));
final user = sl<AppUserCubit>().state.user;
final devicesCubit = sl<DevicesCubit>();
if (user != null) {
devicesCubit.fetchAllDevices(user.username);
}
result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
final result = await loginUseCase.call(
LoginParams(username, password, sourceType),
);
// final user = sl<AppUserCubit>().state.user;
// final devicesCubit = sl<DevicesCubit>();
// if (user != null) {
// devicesCubit.fetchAllDevices(user.username);
// }
result.fold((failure) => emit(LoginFailure(failure.message)), (user) async {
// 先更新全局用户状态(确保首页能获取到 token)
authCubit.appCubit.setAuth(user);
// 🔥 先等待 TCP 连接和认证完成,确保 AuthCubit 变为 AuthAuthenticated
// 再触发页面导航,避免 GoRouter 拦截踢回登录页
await authCubit.loginSuccess(user).catchError((e) {
print('TCP 初始化失败: $e');
});
// 发出登录成功状态(触发页面跳转)
emit(LoginSuccess(user));
authCubit.loginSuccess(user);
});
} catch (e) {
emit(LoginFailure(e.toString()));

View File

@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
import 'package:maibu_satabot_v2/core/di/injection.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
import '../../../../core/router/route_paths.dart';
@@ -17,111 +18,259 @@ class LoginPage extends StatefulWidget {
State<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends State<LoginPage> {
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
final _userCtrl = TextEditingController();
final _pwdCtrl = TextEditingController();
bool _isAgreed = false; // 协议勾选状态
bool _obscurePwd = true; // 密码可见性
// 进场动画控制器
late final AnimationController _entranceController;
@override
void initState() {
super.initState();
_entranceController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
);
// 等首帧渲染 + 图片解码完成后再播放,避开启动初始化争抢导致的掉帧
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
Future.delayed(const Duration(milliseconds: 120), () {
if (mounted) _entranceController.forward();
});
});
}
@override
void dispose() {
_entranceController.dispose();
_userCtrl.dispose();
_pwdCtrl.dispose();
super.dispose();
}
/// 生成一个错峰区间的动画(0.0 ~ 1.0)
Animation<double> _interval(double begin, double end) {
return CurvedAnimation(
parent: _entranceController,
curve: Interval(begin, end, curve: Curves.easeOut),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
backgroundColor: context.appColors.pageBackground,
// 保持 AppBar 简洁或直接去掉
appBar: AppBar(backgroundColor: Colors.white, elevation: 0),
appBar: AppBar(backgroundColor: context.appColors.pageBackground, elevation: 0),
body: BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
debugPrint('🔍 [LOGIN] 登录状态变化: $state');
if (state is LoginSuccess) {
debugPrint('✅ [LOGIN] 登录成功,准备跳转到首页');
context.go(RoutePaths.home);
debugPrint('✅ [LOGIN] 已调用 context.go(RoutePaths.home)');
} else if (state is LoginFailure) {
// 可以在这里弹出简洁的黑白提示框
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登录失败,${state.message}')));
}
},
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
const Text(
"账号登录",
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.black),
),
const SizedBox(height: 12),
const Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: Colors.grey)),
const SizedBox(height: 40),
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 顶部区域:图片 + 认证信息 + 标题(压缩空间)
const SizedBox(height: 10),
// ① 顶部居中图片和认证信息(缩放淡入)
_Entrance(
animation: _interval(0.00, 0.35),
scale: true,
child: Center(
child: Column(
children: [
Image.asset(
'assets/images/huawei.png',
width: 80,
height: 80,
fit: BoxFit.contain,
),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20.0),
child: Text(
"通过华为云生态技术认证,请放心使用,证书编号C202605301",
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
color: context.appColors.textTertiary,
height: 1.3,
),
),
),
],
),
),
),
// 账号输入框
_buildInputLabel("账号"),
TextField(
controller: _userCtrl,
cursorColor: Colors.black,
decoration: _inputDecoration(hint: "请输入用户名"),
),
const SizedBox(height: 16),
const SizedBox(height: 24),
// ② 标题区(上滑淡入)
_Entrance(
animation: _interval(0.10, 0.45),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"账号登录",
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
),
const SizedBox(height: 8),
Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
],
),
),
// 密码输入框
_buildInputLabel("密码"),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
cursorColor: Colors.black,
decoration: _inputDecoration(
hint: "请输入密码",
suffixIcon: IconButton(
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: Colors.grey, size: 20),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
const SizedBox(height: 24),
// ③ 表单区域
// 账号输入框(上滑淡入)
_Entrance(
animation: _interval(0.20, 0.55),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputLabel("账号"),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请输入用户名"),
),
],
),
),
const SizedBox(height: 20),
// 密码输入框(上滑淡入)
_Entrance(
animation: _interval(0.28, 0.63),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInputLabel("密码"),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请输入密码",
suffixIcon: IconButton(
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: context.appColors.textTertiary, size: 20),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
),
),
),
],
),
),
const SizedBox(height: 16),
// ④ 协议勾选区域(上滑淡入)
_Entrance(
animation: _interval(0.36, 0.71),
child: _buildProtocolSection(),
),
const SizedBox(height: 30),
// ⑤ 底部区域:登录按钮(上滑淡入)
_Entrance(
animation: _interval(0.45, 0.82),
child: SizedBox(
width: double.infinity,
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
bool isLoading = state is LoginLoading;
return CCPrimaryButton(
onPressed: isLoading
? null
: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先阅读并同意用户协议'),
duration: Duration(seconds: 1),
),
);
return;
}
_handleLogin();
},
text: isLoading ? '登录中...' : '登 录',
);
},
),
),
),
const SizedBox(height: 16),
// 注册链接(上滑淡入,最后登场)
_Entrance(
animation: _interval(0.55, 0.95),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"没有账号吗?",
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
),
InkWell(
onTap: () {
debugPrint(' 点击了立即注册');
debugPrint('👉 跳转路径: ${RoutePaths.register}');
context.go(RoutePaths.register);
},
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
"立即注册",
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
),
const SizedBox(height: 20),
],
),
),
),
const SizedBox(height: 20),
// 协议勾选区域
_buildProtocolSection(),
const SizedBox(height: 40),
// 登录按钮 (使用你的 CCPrimaryButton 并增加状态控制)
SizedBox(
width: double.infinity,
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
// 关键:判断是否正在 Loading
bool isLoading = state is LoginLoading;
return CCPrimaryButton(
// 核心修复:正在加载时 → onPressed 为 null,按钮禁用
onPressed: isLoading
? null
: () {
if (!_isAgreed) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先阅读并同意用户协议'),
duration: Duration(seconds: 1), // 这里设置显示时长:2秒
),
);
return;
}
_handleLogin();
},
text: isLoading ? '登录中...' : '登 录',
);
},
),
),
],
),
);
},
),
),
);
@@ -141,7 +290,7 @@ class _LoginPageState extends State<LoginPage> {
Widget _buildInputLabel(String label) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
child: Text(label, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: context.appColors.textSecondary)),
);
}
@@ -149,17 +298,17 @@ class _LoginPageState extends State<LoginPage> {
InputDecoration _inputDecoration({required String hint, Widget? suffixIcon}) {
return InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14),
hintStyle: TextStyle(color: context.appColors.textTertiary, fontSize: 14),
filled: true,
fillColor: Colors.grey.shade50,
fillColor: context.appColors.fillBackground,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade200),
borderSide: BorderSide(color: context.appColors.divider),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Colors.black, width: 1),
borderSide: BorderSide(color: context.appColors.primary, width: 1),
),
suffixIcon: suffixIcon,
);
@@ -175,7 +324,9 @@ class _LoginPageState extends State<LoginPage> {
width: 24,
child: Checkbox(
value: _isAgreed,
activeColor: Colors.black,
activeColor: context.appColors.primary,
checkColor: Colors.white,
side: BorderSide(color: context.appColors.divider),
onChanged: (val) => setState(() => _isAgreed = val!),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
),
@@ -184,9 +335,9 @@ class _LoginPageState extends State<LoginPage> {
Expanded(
child: Wrap(
children: [
const Text("我已阅读并同意 ", style: TextStyle(fontSize: 13, color: Colors.grey)),
Text("我已阅读并同意 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText("《用户协议》", () => _showProtocolDetail("用户协议")),
const Text(" 和 ", style: TextStyle(fontSize: 13, color: Colors.grey)),
Text(" 和 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")),
],
),
@@ -200,7 +351,7 @@ class _LoginPageState extends State<LoginPage> {
onTap: onTap,
child: Text(
text,
style: const TextStyle(fontSize: 13, color: Colors.black, fontWeight: FontWeight.bold),
style: TextStyle(fontSize: 13, color: context.appColors.textPrimary, fontWeight: FontWeight.bold),
),
);
}
@@ -209,18 +360,18 @@ class _LoginPageState extends State<LoginPage> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.white,
backgroundColor: context.appColors.cardBackground,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (context) => Container(
padding: const EdgeInsets.all(24),
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const Divider(height: 32),
Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: context.appColors.textPrimary)),
Divider(height: 32, color: context.appColors.divider),
Expanded(
child: SingleChildScrollView(
child: Text("此处放置您的${title}详细内容...\n" * 20, style: const TextStyle(color: Colors.black87, height: 1.5)),
child: Text("此处放置您的${title}详细内容...\n" * 20, style: TextStyle(color: context.appColors.textSecondary, height: 1.5)),
),
),
const SizedBox(height: 16),
@@ -238,3 +389,37 @@ class _LoginPageState extends State<LoginPage> {
);
}
}
/// 进场动画包装组件:淡入 + 上滑(默认)或淡入 + 缩放(scale=true)
class _Entrance extends StatelessWidget {
const _Entrance({
required this.animation,
required this.child,
this.scale = false,
this.slideOffset = const Offset(0, 0.15),
});
final Animation<double> animation;
final Widget child;
final bool scale;
final Offset slideOffset;
@override
Widget build(BuildContext context) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
if (scale) {
final scaleAnim = Tween<double>(begin: 0.85, end: 1.0).animate(curved);
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: scaleAnim, child: child),
);
}
final slideAnim = Tween<Offset>(begin: slideOffset, end: Offset.zero).animate(curved);
return FadeTransition(
opacity: animation,
child: SlideTransition(position: slideAnim, child: child),
);
}
}

View File

@@ -1,13 +1,307 @@
import 'package:cc_ui_kit/cc_ui_kit.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
class RegisterPage extends StatelessWidget {
class RegisterPage extends StatefulWidget {
const RegisterPage({super.key});
@override
State<RegisterPage> createState() => _RegisterPageState();
}
class _RegisterPageState extends State<RegisterPage> {
final _userCtrl = TextEditingController();
final _pwdCtrl = TextEditingController();
final _confirmPwdCtrl = TextEditingController();
bool _isAgreed = false;
bool _obscurePwd = true;
bool _obscureConfirmPwd = true;
@override
void dispose() {
_userCtrl.dispose();
_pwdCtrl.dispose();
_confirmPwdCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Register')),
body: Center(child: Text('Welcome Register page')),
backgroundColor: context.appColors.pageBackground,
appBar: AppBar(
backgroundColor: context.appColors.pageBackground,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: context.appColors.textPrimary),
onPressed: () => context.go(RoutePaths.login),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 30.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 40),
Text(
"账号注册",
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: context.appColors.textPrimary,
),
),
const SizedBox(height: 12),
Text(
"请填写以下信息完成注册",
style: TextStyle(fontSize: 15, color: context.appColors.textTertiary),
),
const SizedBox(height: 40),
_buildInputLabel("用户名"),
TextField(
controller: _userCtrl,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(hint: "请设置用户名"),
),
const SizedBox(height: 24),
_buildInputLabel("密码"),
TextField(
controller: _pwdCtrl,
obscureText: _obscurePwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请设置密码",
suffixIcon: IconButton(
icon: Icon(
_obscurePwd
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: context.appColors.textTertiary,
size: 20,
),
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
),
),
),
const SizedBox(height: 24),
_buildInputLabel("确认密码"),
TextField(
controller: _confirmPwdCtrl,
obscureText: _obscureConfirmPwd,
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
cursorColor: context.appColors.textPrimary,
decoration: _inputDecoration(
hint: "请再次输入密码",
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPwd
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: context.appColors.textTertiary,
size: 20,
),
onPressed: () => setState(
() => _obscureConfirmPwd = !_obscureConfirmPwd,
),
),
),
),
const SizedBox(height: 20),
_buildProtocolSection(),
const SizedBox(height: 40),
SizedBox(
width: double.infinity,
child: CCPrimaryButton(
onPressed: _isAgreed ? _handleRegister : null,
text: '注 册',
),
),
const SizedBox(height: 24),
Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"已有账号?",
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
),
GestureDetector(
onTap: () => context.go(RoutePaths.login),
child: Text(
"立即登录",
style: TextStyle(
fontSize: 14,
color: context.appColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
const SizedBox(height: 20),
],
),
),
);
}
void _handleRegister() {
if (_userCtrl.text.isEmpty || _pwdCtrl.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写完整信息')),
);
return;
}
if (_pwdCtrl.text != _confirmPwdCtrl.text) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('两次输入的密码不一致')),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('注册功能开发中...')),
);
}
Widget _buildInputLabel(String label) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: context.appColors.textSecondary,
),
),
);
}
InputDecoration _inputDecoration({required String hint, Widget? suffixIcon}) {
return InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: context.appColors.textTertiary, fontSize: 14),
filled: true,
fillColor: context.appColors.fillBackground,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: context.appColors.divider),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: context.appColors.primary, width: 1),
),
suffixIcon: suffixIcon,
);
}
Widget _buildProtocolSection() {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
height: 24,
width: 24,
child: Checkbox(
value: _isAgreed,
activeColor: context.appColors.primary,
checkColor: Colors.white,
side: BorderSide(color: context.appColors.divider),
onChanged: (val) => setState(() => _isAgreed = val!),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(4),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Wrap(
children: [
Text(
"我已阅读并同意 ",
style: TextStyle(fontSize: 13, color: context.appColors.textTertiary),
),
_protocolText("《用户协议》", () => _showProtocolDetail("用户协议")),
Text(" 和 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
_protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")),
],
),
),
],
);
}
Widget _protocolText(String text, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Text(
text,
style: TextStyle(
fontSize: 13,
color: context.appColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
);
}
void _showProtocolDetail(String title) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: context.appColors.cardBackground,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (context) => Container(
padding: const EdgeInsets.all(24),
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
Text(
title,
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
),
Divider(height: 32, color: context.appColors.divider),
Expanded(
child: SingleChildScrollView(
child: Text(
"此处放置您的${title}详细内容...\n" * 20,
style: TextStyle(color: context.appColors.textSecondary, height: 1.5),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: CCPrimaryButton(
onPressed: () => Navigator.pop(context),
text: "我已了解",
),
),
],
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More