Compare commits
79 Commits
feature/my
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
| 208147cdf2 | |||
| b3a700491a | |||
| d230279f4f | |||
| 32397f8899 | |||
| c8b597f683 | |||
| a1e01da241 | |||
| 3547f80383 | |||
| 4b88a291a5 | |||
| 877ff932d4 | |||
| 43c0b68e1a | |||
| 356c2a1925 | |||
| 3ba480f63f | |||
| 85e9632326 | |||
| b4bedd9737 | |||
| d76bcdcdf2 | |||
| b468e577a6 | |||
| db81816b78 | |||
| 5141d6e83f | |||
| 61bce2c1cf | |||
| 0fa67a127b | |||
| 7c01229b9e | |||
| a48778bc8a | |||
| e8070b5183 | |||
| fc3cf4de0f | |||
| db323a26ce | |||
| 11509d1aa6 | |||
| 97a2126fcf | |||
| 0978f387fd | |||
| e9532ddafa | |||
| 43fbd0af19 | |||
| 4b03da8142 | |||
| d0fcc067ca | |||
| ca805ee957 | |||
| d119407866 | |||
| 5a02522bb4 | |||
| 54dac30915 | |||
| 875766cc28 | |||
| e8a79168e8 | |||
| 7773fe7a60 | |||
| b6e2a40aa7 | |||
| bb2becdf7b | |||
| ca616faf11 | |||
| 83220869da | |||
| ab75e8c5b0 | |||
| 87609c7dc4 | |||
| 9dc445eeb5 | |||
| db70665ced | |||
| bf2fc49c19 | |||
| 1a4d0aedfd | |||
| 2124f3bc02 | |||
| 0db64a96e7 | |||
| d5a83c1221 | |||
| b7ea788298 | |||
| 06718ed957 | |||
| a6143f2ba7 | |||
| 443ce601a0 | |||
| 71aea48d9f | |||
| 52fe17c0d5 | |||
| b08e1e0b57 | |||
| 8eb878dd81 | |||
| 058a5d9588 | |||
| ff4a3836c7 | |||
| 354f7d6290 | |||
| 7c9734d918 | |||
| d8879dad62 | |||
| 9eab402551 | |||
| b6480778ed | |||
| 93e69a5e4b | |||
| 9154174614 | |||
| 335c468aac | |||
| 938aac2db9 | |||
| afef5c4ef1 | |||
| b9f747de05 | |||
| 07b6b19b34 | |||
| f1a279cf57 | |||
| 03f797e717 | |||
| a4ac503abd | |||
| fb529b2485 | |||
| 4b3ad72627 |
21
.gitignore
vendored
21
.gitignore
vendored
@@ -20,7 +20,9 @@ build/
|
|||||||
gen/
|
gen/
|
||||||
local.properties
|
local.properties
|
||||||
.gradle/
|
.gradle/
|
||||||
|
.kotlin/
|
||||||
captures/
|
captures/
|
||||||
|
hs_err_pid*.log
|
||||||
.idea/modules.xml
|
.idea/modules.xml
|
||||||
.idea/workspace.xml
|
.idea/workspace.xml
|
||||||
|
|
||||||
@@ -79,6 +81,25 @@ Debug/
|
|||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
lib/generated_plugin_registrant.dart
|
lib/generated_plugin_registrant.dart
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Node.js 依赖目录(前端/工具链产物,不需要提交)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
node_modules/
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 日志与崩溃转储(本地调试产物,不需要提交)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
flutter_*.log
|
||||||
|
test_run.log
|
||||||
|
*.log
|
||||||
|
.sentry-native/
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Office 临时锁文件(Word/Excel 打开时生成,不需要提交)
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
~$*
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# 编辑器与系统文件
|
# 编辑器与系统文件
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
|
|||||||
Binary file not shown.
45
android/.kotlin/errors/errors-1779355041254.log
Normal file
45
android/.kotlin/errors/errors-1779355041254.log
Normal 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)
|
||||||
|
|
||||||
|
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.Properties
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -5,6 +8,13 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 加载签名配置
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.maibu.maibu_satabot_v2"
|
namespace = "com.maibu.maibu_satabot_v2"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -30,11 +40,20 @@ android {
|
|||||||
versionName = flutter.versionName
|
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 {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
signingConfig = signingConfigs.getByName("release")
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
65
android/app/proguard-rules.pro
vendored
65
android/app/proguard-rules.pro
vendored
@@ -6,3 +6,68 @@
|
|||||||
# 如果你用 dio + json_serializable / gson
|
# 如果你用 dio + json_serializable / gson
|
||||||
-keepattributes Signature
|
-keepattributes Signature
|
||||||
-keepattributes *Annotation*
|
-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 { *; }
|
||||||
|
|||||||
@@ -1,85 +1,42 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
package="com.maibu.maibu_satabot_v2">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" /> <!-- 关键! -->
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-feature android:name="android.hardware.bluetooth" android:required="false"/>
|
||||||
<!-- 如果还需要扫描闪光灯控制,可选添加 -->
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false"/>
|
||||||
<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" />
|
|
||||||
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="飒沓机器人"
|
android:label="maibu_satabot_v2"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:usesCleartextTraffic="true"
|
android:icon="@mipmap/launcher_icon"
|
||||||
android:largeHeap="true"
|
android:usesCleartextTraffic="true">
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:enableOnBackInvokedCallback="true"
|
|
||||||
android:icon="@mipmap/launcher_icon">
|
|
||||||
<meta-data android:name="android.max_aspect" android:value="2.1" />
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
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
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"/>
|
||||||
/>
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2"/>
|
||||||
|
|
||||||
<!-- 高德 Key -->
|
|
||||||
<meta-data
|
|
||||||
android:name="com.amap.api.v2.apikey"
|
|
||||||
android:value="bbb1f0f20eed6bf679eddf2625630aba" />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</application>
|
</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>
|
</manifest>
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
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()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
// 火山引擎 RTC 仓库
|
||||||
|
maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") }
|
||||||
|
// BytePlus 公共仓库(包含火山引擎依赖)
|
||||||
|
maven { url = uri("https://artifact.byteplus.com/repository/public/") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.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
BIN
assets/images/chucao.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
BIN
assets/images/dianzhan.png
Normal file
BIN
assets/images/dianzhan.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 521 KiB |
BIN
assets/images/huawei.png
Normal file
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
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
BIN
assets/images/xunjian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
assets/images/yiwancheng.png
Normal file
BIN
assets/images/yiwancheng.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
@@ -19,10 +19,11 @@
|
|||||||
"password": "Password",
|
"password": "Password",
|
||||||
"login_button": "Login",
|
"login_button": "Login",
|
||||||
"logout": "Logout",
|
"logout": "Logout",
|
||||||
|
"confirm_logout": "Are you sure to logout?",
|
||||||
"username_hint": "Enter username",
|
"username_hint": "Enter username",
|
||||||
"password_hint": "Enter password",
|
"password_hint": "Enter password",
|
||||||
"login_success": "Login successful",
|
"login_success": "Login Success",
|
||||||
"login_failed": "Login failed"
|
"login_failed": "Login Failed"
|
||||||
},
|
},
|
||||||
|
|
||||||
"home": {
|
"home": {
|
||||||
@@ -98,7 +99,9 @@
|
|||||||
"value": "Value",
|
"value": "Value",
|
||||||
"initialized": "Initialized",
|
"initialized": "Initialized",
|
||||||
"no_data": "No Data",
|
"no_data": "No Data",
|
||||||
"voltage": "Voltage"
|
"voltage": "Voltage",
|
||||||
|
"tcp_reconnected": "TCP connection restored",
|
||||||
|
"tcp_reconnect_failed": "TCP reconnection failed, please check network"
|
||||||
},
|
},
|
||||||
|
|
||||||
"machine_details": {
|
"machine_details": {
|
||||||
@@ -285,7 +288,7 @@
|
|||||||
"location": "Location",
|
"location": "Location",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"select_work_mode": "Select Work Mode",
|
"select_work_mode": "Select Work Mode",
|
||||||
"bow_mode": "Bow Mode",
|
"bow_mode": "Coverage Mode",
|
||||||
"bow_mode_subtitle": "Standard full coverage path planning",
|
"bow_mode_subtitle": "Standard full coverage path planning",
|
||||||
"custom_mode": "Custom Mode",
|
"custom_mode": "Custom Mode",
|
||||||
"custom_mode_subtitle": "Manually set work area",
|
"custom_mode_subtitle": "Manually set work area",
|
||||||
@@ -308,6 +311,185 @@
|
|||||||
"refresh_failed": "Refresh failed"
|
"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": {
|
"product": {
|
||||||
"intro": "Product Introduction",
|
"intro": "Product Introduction",
|
||||||
"highlights": "Product Highlights",
|
"highlights": "Product Highlights",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"password": "密码",
|
"password": "密码",
|
||||||
"login_button": "登录",
|
"login_button": "登录",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
|
"confirm_logout": "确定要退出登录吗?",
|
||||||
"username_hint": "请输入用户名",
|
"username_hint": "请输入用户名",
|
||||||
"password_hint": "请输入密码",
|
"password_hint": "请输入密码",
|
||||||
"login_success": "登录成功",
|
"login_success": "登录成功",
|
||||||
@@ -98,7 +99,9 @@
|
|||||||
"value": "数值",
|
"value": "数值",
|
||||||
"initialized": "已初始化",
|
"initialized": "已初始化",
|
||||||
"no_data": "暂无数据",
|
"no_data": "暂无数据",
|
||||||
"voltage": "电压"
|
"voltage": "电压",
|
||||||
|
"tcp_reconnected": "TCP连接已恢复",
|
||||||
|
"tcp_reconnect_failed": "TCP重连失败,请检查网络"
|
||||||
},
|
},
|
||||||
|
|
||||||
"machine_details": {
|
"machine_details": {
|
||||||
@@ -299,7 +302,7 @@
|
|||||||
"location": "定位",
|
"location": "定位",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"select_work_mode": "选择作业模式",
|
"select_work_mode": "选择作业模式",
|
||||||
"bow_mode": "弓字模式",
|
"bow_mode": "覆盖模式",
|
||||||
"bow_mode_subtitle": "标准全覆盖路径规划",
|
"bow_mode_subtitle": "标准全覆盖路径规划",
|
||||||
"custom_mode": "自定义模式",
|
"custom_mode": "自定义模式",
|
||||||
"custom_mode_subtitle": "手动设定作业区域",
|
"custom_mode_subtitle": "手动设定作业区域",
|
||||||
@@ -321,6 +324,186 @@
|
|||||||
"clear_cache_success": "已清除所有缓存,恢复初始状态",
|
"clear_cache_success": "已清除所有缓存,恢复初始状态",
|
||||||
"refresh_failed": "刷新失败"
|
"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": {
|
"product": {
|
||||||
"intro": "产品介绍",
|
"intro": "产品介绍",
|
||||||
"highlights": "产品亮点",
|
"highlights": "产品亮点",
|
||||||
|
|||||||
52
build_patch.bat
Normal file
52
build_patch.bat
Normal 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
|
||||||
@@ -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
|
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
||||||
extensions:
|
extensions:
|
||||||
- get_it: true
|
- get_it: true
|
||||||
- provider: true
|
- provider: true
|
||||||
|
- shared_preferences: true
|
||||||
3
dist/download/.gitkeep
vendored
Normal file
3
dist/download/.gitkeep
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 完整安装包目录
|
||||||
|
|
||||||
|
将 app-release.apk 放在此目录下用于测试
|
||||||
6
dist/manifest.json
vendored
Normal file
6
dist/manifest.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.10",
|
||||||
|
"md5": "416e3f0e2a701bb8b4a0856a29dba597",
|
||||||
|
"targetVersionCode": 1,
|
||||||
|
"abi": "arm64-v8a"
|
||||||
|
}
|
||||||
3
dist/patch/.gitkeep
vendored
Normal file
3
dist/patch/.gitkeep
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# 差量更新包目录
|
||||||
|
|
||||||
|
将生成的 .patch 文件放在此目录下
|
||||||
9
dist/version/check.json
vendored
Normal file
9
dist/version/check.json
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"hasUpdate": true,
|
||||||
|
"patch": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"patchUrl": "http://127.0.0.1:8080/libapp.so",
|
||||||
|
"md5": "",
|
||||||
|
"targetVersionCode": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
213
docs/DEVICE_TASK_CUBIT_USAGE.md
Normal file
213
docs/DEVICE_TASK_CUBIT_USAGE.md
Normal 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秒后)
|
||||||
|
- 页面不会崩溃
|
||||||
|
|
||||||
|
示例错误提示:
|
||||||
|
- "网络连接超时,请检查网络设置"
|
||||||
|
- "登录已过期,请重新登录"
|
||||||
|
- "操作失败,请稍后重试"
|
||||||
|
- "未知错误,请稍后重试"
|
||||||
296
docs/DRONE_STATION_OSD_CARD_USAGE.md
Normal file
296
docs/DRONE_STATION_OSD_CARD_USAGE.md
Normal 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] 添加调试日志
|
||||||
303
docs/ERROR_HANDLING_BEST_PRACTICE.md
Normal file
303
docs/ERROR_HANDLING_BEST_PRACTICE.md
Normal 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
388
docs/ERROR_HANDLING_FIX.md
Normal 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
520
docs/SOP_使用文档.md
Normal 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 内"上报"页面提交问题反馈
|
||||||
|
- **技术支持**:请联系管理员获取联系方式
|
||||||
197
docs/TAB_CONFIG_DEVELOPMENT_GUIDE.md
Normal file
197
docs/TAB_CONFIG_DEVELOPMENT_GUIDE.md
Normal 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
|
||||||
|
**维护者:** 开发团队
|
||||||
195
docs/UAV_VIDEO_COMPLETION_SUMMARY.md
Normal file
195
docs/UAV_VIDEO_COMPLETION_SUMMARY.md
Normal 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 即可显示实际视频画面。
|
||||||
323
docs/UAV_VIDEO_INTEGRATION_GUIDE.md
Normal file
323
docs/UAV_VIDEO_INTEGRATION_GUIDE.md
Normal 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` - 注册依赖
|
||||||
298
docs/UAV_VIDEO_PAGE_INTEGRATION.md
Normal file
298
docs/UAV_VIDEO_PAGE_INTEGRATION.md
Normal 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 显示实际视频画面**,即可完整实现无人机实时视频监控功能!
|
||||||
642
docs/UAV_VIDEO_USAGE_EXAMPLES.md
Normal file
642
docs/UAV_VIDEO_USAGE_EXAMPLES.md
Normal 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
12
fix_json.ps1
Normal 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
106
flutter_01.log
Normal 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
109
flutter_02.log
Normal 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.
|
||||||
|
```
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
# 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.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||||
|
|||||||
121
ios/Podfile.lock
121
ios/Podfile.lock
@@ -1,7 +1,58 @@
|
|||||||
PODS:
|
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):
|
- device_info_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
|
- flutter_blue_plus_darwin (0.0.2):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- flutter_webrtc (1.4.0):
|
- flutter_webrtc (1.4.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- WebRTC-SDK (= 144.7559.01)
|
- WebRTC-SDK (= 144.7559.01)
|
||||||
@@ -44,6 +95,8 @@ PODS:
|
|||||||
- GTMSessionFetcher/Core (2.3.0)
|
- GTMSessionFetcher/Core (2.3.0)
|
||||||
- image_picker_ios (0.0.1):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- iris_method_channel (0.0.1):
|
||||||
|
- Flutter
|
||||||
- isar_community_flutter_libs (1.0.0):
|
- isar_community_flutter_libs (1.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- MLImage (1.0.0-beta4)
|
- MLImage (1.0.0-beta4)
|
||||||
@@ -72,6 +125,8 @@ PODS:
|
|||||||
- nanopb/encode (= 2.30910.0)
|
- nanopb/encode (= 2.30910.0)
|
||||||
- nanopb/decode (2.30910.0)
|
- nanopb/decode (2.30910.0)
|
||||||
- nanopb/encode (2.30910.0)
|
- nanopb/encode (2.30910.0)
|
||||||
|
- open_file_ios (1.0.3):
|
||||||
|
- Flutter
|
||||||
- package_info_plus (0.4.5):
|
- package_info_plus (0.4.5):
|
||||||
- Flutter
|
- Flutter
|
||||||
- permission_handler_apple (9.3.0):
|
- permission_handler_apple (9.3.0):
|
||||||
@@ -89,29 +144,67 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- vibration (3.0.0):
|
- vibration (3.0.0):
|
||||||
- Flutter
|
- 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)
|
- WebRTC-SDK (144.7559.01)
|
||||||
- webview_flutter_wkwebview (0.0.1):
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
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`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||||
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- 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`)
|
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
|
||||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/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`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
- vibration (from `.symlinks/plugins/vibration/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`)
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
|
https://github.com/volcengine/volcengine-specs.git:
|
||||||
|
- VolcApiEngine
|
||||||
|
- VolcEngineRTC
|
||||||
trunk:
|
trunk:
|
||||||
|
- AgoraInfra_iOS
|
||||||
|
- AgoraIrisRTC_iOS
|
||||||
|
- AgoraRtcEngine_iOS
|
||||||
- GoogleDataTransport
|
- GoogleDataTransport
|
||||||
- GoogleMLKit
|
- GoogleMLKit
|
||||||
- GoogleToolboxForMac
|
- GoogleToolboxForMac
|
||||||
@@ -128,20 +221,30 @@ SPEC REPOS:
|
|||||||
- WebRTC-SDK
|
- WebRTC-SDK
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
|
agora_rtc_engine:
|
||||||
|
:path: ".symlinks/plugins/agora_rtc_engine/ios"
|
||||||
|
connectivity_plus:
|
||||||
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_blue_plus_darwin:
|
||||||
|
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||||
flutter_webrtc:
|
flutter_webrtc:
|
||||||
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
||||||
geolocator_apple:
|
geolocator_apple:
|
||||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
|
iris_method_channel:
|
||||||
|
:path: ".symlinks/plugins/iris_method_channel/ios"
|
||||||
isar_community_flutter_libs:
|
isar_community_flutter_libs:
|
||||||
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
|
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
|
||||||
mobile_scanner:
|
mobile_scanner:
|
||||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||||
|
open_file_ios:
|
||||||
|
:path: ".symlinks/plugins/open_file_ios/ios"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
permission_handler_apple:
|
permission_handler_apple:
|
||||||
@@ -154,12 +257,22 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
vibration:
|
vibration:
|
||||||
:path: ".symlinks/plugins/vibration/ios"
|
: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:
|
webview_flutter_wkwebview:
|
||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
|
agora_rtc_engine: 0c7d50312967c4dc31c3c45e50589ce48f57e08a
|
||||||
|
AgoraInfra_iOS: 3691b2b277a1712a35ae96de25af319de0d73d08
|
||||||
|
AgoraIrisRTC_iOS: eab58c126439adf5ec99632828a558ea216860da
|
||||||
|
AgoraRtcEngine_iOS: 97e2398a2addda9057815a2a583a658e36796ff6
|
||||||
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||||
|
flutter_blue_plus_darwin: ee2377ea51a6c9e481f2ed76ec597717ef5d287d
|
||||||
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
|
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
|
||||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||||
@@ -169,6 +282,7 @@ SPEC CHECKSUMS:
|
|||||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
||||||
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
|
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
|
iris_method_channel: 7d661cf3259b3009ae423508470dbeb9374446ee
|
||||||
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
|
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
|
||||||
MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b
|
MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b
|
||||||
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
|
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
|
||||||
@@ -176,6 +290,7 @@ SPEC CHECKSUMS:
|
|||||||
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
|
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
|
||||||
mobile_scanner: b67191637a5ea7ba15652ca208069d2bae1900ab
|
mobile_scanner: b67191637a5ea7ba15652ca208069d2bae1900ab
|
||||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||||
|
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
|
||||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
@@ -184,9 +299,13 @@ SPEC CHECKSUMS:
|
|||||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||||
|
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
||||||
|
volc_engine_rtc: 1bc697fba3c7a2f6513a18ec2abfb629188ce6ff
|
||||||
|
VolcApiEngine: e1f8b47ec5ef64bf6b48e3600be132c42643f8f0
|
||||||
|
VolcEngineRTC: 889b981b13621208b25942263f0fd5817c93b9bc
|
||||||
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
|
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
|
||||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||||
|
|
||||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
PODFILE CHECKSUM: 2da7455b7ebb4d9567497e4f758b13986b3bb736
|
||||||
|
|
||||||
COCOAPODS: 1.16.2
|
COCOAPODS: 1.16.2
|
||||||
|
|||||||
@@ -494,14 +494,14 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -519,7 +519,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
@@ -537,7 +537,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -553,7 +553,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||||
@@ -680,14 +680,14 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
@@ -706,14 +706,14 @@
|
|||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
|||||||
@@ -66,5 +66,27 @@
|
|||||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||||
</array>
|
</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>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
947
lib/components/agora_video_player.dart
Normal file
947
lib/components/agora_video_player.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
lib/components/capsule_toast.dart
Normal file
125
lib/components/capsule_toast.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1364
lib/components/device_status_modal.dart
Normal file
1364
lib/components/device_status_modal.dart
Normal file
File diff suppressed because it is too large
Load Diff
130
lib/components/tcp_status_indicator.dart
Normal file
130
lib/components/tcp_status_indicator.dart
Normal 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,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ class AppUserCubit extends Cubit<AppUserState> {
|
|||||||
AppUserCubit() : super(const AppUserState());
|
AppUserCubit() : super(const AppUserState());
|
||||||
|
|
||||||
void setAuth(UserEntity user) {
|
void setAuth(UserEntity user) {
|
||||||
|
print('✅ [AppUserCubit] setAuth 被调用,用户: ${user.username}, roleKey=${user.roleKey}, orgId=${user.orgId}, siteId=${user.siteId}');
|
||||||
emit(state.copyWith(user));
|
emit(state.copyWith(user));
|
||||||
|
print('✅ [AppUserCubit] 状态已更新,当前用户: ${state.user?.username}, roleKey=${state.user?.roleKey}');
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAuth() {
|
void clearAuth() {
|
||||||
|
|||||||
521
lib/core/bluetooth/ble_manager.dart
Normal file
521
lib/core/bluetooth/ble_manager.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
744
lib/core/bluetooth/ble_protocol_decoder.dart
Normal file
744
lib/core/bluetooth/ble_protocol_decoder.dart
Normal 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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lib/core/bluetooth/bytes_util.dart
Normal file
38
lib/core/bluetooth/bytes_util.dart
Normal 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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
402
lib/core/bluetooth/mc700_device_config.dart
Normal file
402
lib/core/bluetooth/mc700_device_config.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
122
lib/core/bluetooth/protocol_parser.dart
Normal file
122
lib/core/bluetooth/protocol_parser.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,129 @@
|
|||||||
|
import '../env/env_config.dart';
|
||||||
|
|
||||||
class HttpApiConsts {
|
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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import '../env/env_config.dart';
|
||||||
|
|
||||||
class TCPConsts {
|
class TCPConsts {
|
||||||
static const String TCP_IP = "1.95.137.212";
|
static String get TCP_IP => EnvConfig.tcpIp;
|
||||||
static const int TCP_PORT = 9001;
|
static int get TCP_PORT => EnvConfig.tcpPort;
|
||||||
}
|
}
|
||||||
|
|||||||
44
lib/core/consts/workorder_consts.dart
Normal file
44
lib/core/consts/workorder_consts.dart
Normal 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, // 低
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:maibu_satabot_v2/core/infrastructure/logging/sentry_logger_impl.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.dart';
|
||||||
import 'package:maibu_satabot_v2/features/my/repository/my_repository_impl.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/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/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/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/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 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../../features/auth/data/datasources/auth_http_datasource.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/generate_path_usecase.dart';
|
||||||
import '../../features/devices/domain/usecases/get_device_location_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_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/route_planning_usecase.dart';
|
||||||
import '../../features/devices/domain/usecases/save_work_record_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/domain/usecases/switch_device_usecase.dart';
|
||||||
import '../../features/devices/presentation/bloc/device_status_bloc.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_http_datasource.dart';
|
||||||
import '../../features/remote_control/data/datasources/remote_tcp_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/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 '../app/app_user_cubit.dart';
|
||||||
import '../localization/locale_cubit.dart';
|
import '../localization/locale_cubit.dart';
|
||||||
|
import '../theme/theme_cubit.dart';
|
||||||
import '../../features/home/presentation/bloc/permission_request_bloc.dart';
|
import '../../features/home/presentation/bloc/permission_request_bloc.dart';
|
||||||
import '../network/dio_client.dart';
|
import '../network/dio_client.dart';
|
||||||
import '../network/net_message_dispatcher.dart';
|
import '../network/net_message_dispatcher.dart';
|
||||||
import '../network/tcp/tcp_client.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 '../router/app_router.dart';
|
||||||
import '../storage/impl/user_storage_impl.dart';
|
import '../storage/impl/user_storage_impl.dart';
|
||||||
import '../storage/user_storage.dart';
|
import '../storage/user_storage.dart';
|
||||||
@@ -75,17 +174,38 @@ Future<void> init() async {
|
|||||||
|
|
||||||
/// 1.1.2 TcpClient:TCP客户端
|
/// 1.1.2 TcpClient:TCP客户端
|
||||||
sl.registerLazySingleton(
|
sl.registerLazySingleton(
|
||||||
() => TcpClient(sl<UserStorage>(), getUserDeviceUseCase: sl<GetUserDeviceUseCase>(), switchDeviceUseCase: sl<SwitchDeviceUseCase>()),
|
() => TcpClient(
|
||||||
|
sl<UserStorage>(),
|
||||||
|
getUserDeviceUseCase: sl<GetUserDeviceUseCase>(),
|
||||||
|
switchDeviceUseCase: sl<SwitchDeviceUseCase>(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton(() => PathPlanningService());
|
sl.registerLazySingleton(() => PathPlanningService());
|
||||||
|
|
||||||
/// 1.1.3 NetMessageDispatcher:消息调度器,并将 TcpClient 注入给它
|
/// 1.1.3 MQTT Clients - 三个独立的 MQTT 客户端实例
|
||||||
sl.registerLazySingleton(() => NetMessageDispatcher(
|
sl.registerLazySingleton<MqttClient>(
|
||||||
sl<TcpClient>(),
|
() => MqttClientImpl(clientName: 'droneOsdClient'),
|
||||||
sl<RoutePlanningRepository>(),
|
instanceName: 'droneOsdClient',
|
||||||
sl<PathPlanningService>(),
|
);
|
||||||
getAppState: () => sl<DevicesCubit>().state.appState,
|
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 --- 本地存储 (LocalStorage) ---
|
||||||
/// 1.2.1 SharedPreferences:本地存储库
|
/// 1.2.1 SharedPreferences:本地存储库
|
||||||
@@ -101,17 +221,55 @@ Future<void> init() async {
|
|||||||
/// 1.3 Log工具Sentry
|
/// 1.3 Log工具Sentry
|
||||||
sl.registerLazySingleton<ILoggerService>(() => SentryLoggerImpl());
|
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)
|
/// 2. 数据源 (DataSource)
|
||||||
sl.registerLazySingleton<AuthHttpDataSource>(() => AuthHttpDataSourceImpl(sl()));
|
sl.registerLazySingleton<AuthHttpDataSource>(
|
||||||
|
() => AuthHttpDataSourceImpl(sl()),
|
||||||
|
);
|
||||||
sl.registerLazySingleton<DeviceHttpDatasource>(
|
sl.registerLazySingleton<DeviceHttpDatasource>(
|
||||||
() => DeviceHttpDatasourceImpl(sl(), sl<UserStorage>()), //
|
() => DeviceHttpDatasourceImpl(sl(), sl<UserStorage>()), //
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton<RemoteHttpDatasource>(
|
sl.registerLazySingleton<RemoteHttpDatasource>(
|
||||||
() => RemoteHttpDatasource(sl(), sl<UserStorage>()),
|
() => RemoteHttpDatasource(sl(), sl<UserStorage>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
sl.registerLazySingleton<RemoteTcpDatasource>(
|
sl.registerLazySingleton<RemoteTcpDatasource>(
|
||||||
() => RemoteTcpDatasource(sl<TcpClient>(), sl<UserStorage>()),
|
() => RemoteTcpDatasource(sl<TcpClient>(), sl<UserStorage>()),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// 3. 仓库 (Repository)
|
/// 3. 仓库 (Repository)
|
||||||
@@ -157,19 +315,27 @@ Future<void> init() async {
|
|||||||
);
|
);
|
||||||
|
|
||||||
sl.registerLazySingleton<DeviceRepository>(() => DeviceRepositoryImpl(sl()));
|
sl.registerLazySingleton<DeviceRepository>(() => DeviceRepositoryImpl(sl()));
|
||||||
sl.registerLazySingleton<RemoteControlRepository>(() => RemoteControlRepositoryImpl(sl(), sl() ,sl(),sl()));
|
sl.registerLazySingleton<RemoteControlRepository>(
|
||||||
|
() => RemoteControlRepositoryImpl(sl(), sl(), sl(), sl()),
|
||||||
|
);
|
||||||
|
|
||||||
/// 4. 用例 (UseCase)
|
/// 4. 用例 (UseCase)
|
||||||
sl.registerLazySingleton(() => LoginUseCase(sl()));
|
sl.registerLazySingleton(() => LoginUseCase(sl()));
|
||||||
sl.registerLazySingleton(() => GetUserDeviceUseCase(sl()));
|
sl.registerLazySingleton(() => GetUserDeviceUseCase(sl()));
|
||||||
sl.registerLazySingleton(() => DiffSteerUseCase());
|
sl.registerLazySingleton(() => DiffSteerUseCase());
|
||||||
sl.registerLazySingleton(() => DeviceWorkHostrirty(sl()));
|
sl.registerLazySingleton(() => DeviceWorkHostrirty(sl()));
|
||||||
sl.registerLazySingleton<SelectWorkRecordUseCase>(() => SelectWorkRecordUseCase(sl<PathRepository>()));
|
sl.registerLazySingleton<SelectWorkRecordUseCase>(
|
||||||
|
() => SelectWorkRecordUseCase(sl<PathRepository>()),
|
||||||
|
);
|
||||||
sl.registerLazySingleton(() => UnbindDeviceUseCase(sl()));
|
sl.registerLazySingleton(() => UnbindDeviceUseCase(sl()));
|
||||||
sl.registerLazySingleton(() => BindDeviceUseCase(sl()));
|
sl.registerLazySingleton(() => BindDeviceUseCase(sl()));
|
||||||
sl.registerLazySingleton(() => UpdateDevicenameUsecase(sl()));
|
sl.registerLazySingleton(() => UpdateDevicenameUsecase(sl()));
|
||||||
sl.registerLazySingleton<SaveWorkRecordUseCase>(() => SaveWorkRecordUseCase(sl<PathRepository>()));
|
sl.registerLazySingleton<SaveWorkRecordUseCase>(
|
||||||
sl.registerLazySingleton<SwitchDeviceUseCase>(() => SwitchDeviceUseCase(sl()));
|
() => SaveWorkRecordUseCase(sl<PathRepository>()),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<SwitchDeviceUseCase>(
|
||||||
|
() => SwitchDeviceUseCase(sl()),
|
||||||
|
);
|
||||||
|
|
||||||
sl.registerLazySingleton<AuthTcpDatasource>(
|
sl.registerLazySingleton<AuthTcpDatasource>(
|
||||||
() => AuthTcpDatasourceImpl(
|
() => 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)
|
/// 5. 状态管理 (Cubit/Bloc)
|
||||||
sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册
|
sl.registerLazySingleton(() => AppUserCubit()); // AuthCubit 依赖它,必须先注册
|
||||||
|
|
||||||
|
// TCP状态管理 Cubit
|
||||||
|
sl.registerLazySingleton(() => TcpStatusCubit());
|
||||||
|
|
||||||
|
// 🔥 SiteCubit (全局共享,持久化选中场站)
|
||||||
|
sl.registerLazySingleton(() => SiteCubit(sl<SharedPreferences>()));
|
||||||
|
|
||||||
|
// 🔥 FloatBarSettingService (全局共享,控制悬浮条显示)
|
||||||
|
sl.registerLazySingleton(
|
||||||
|
() => FloatBarSettingService(sl<SharedPreferences>()),
|
||||||
|
);
|
||||||
|
|
||||||
// 🔥 语言管理 Cubit (单例)
|
// 🔥 语言管理 Cubit (单例)
|
||||||
sl.registerLazySingleton(() => LocaleCubit());
|
sl.registerLazySingleton(() => LocaleCubit());
|
||||||
|
|
||||||
|
// 🔥 主题管理 Cubit (单例,管理亮/暗/跟随系统)
|
||||||
|
sl.registerLazySingleton(() => ThemeCubit());
|
||||||
|
|
||||||
|
// Tab 配置 Cubit (单例)
|
||||||
|
sl.registerLazySingleton(() => TabConfigCubit(sl<SharedPreferences>()));
|
||||||
|
|
||||||
sl.registerLazySingleton(() => GetDeviceLocationUseCase(sl()));
|
sl.registerLazySingleton(() => GetDeviceLocationUseCase(sl()));
|
||||||
sl.registerLazySingleton(() => DevicesCubit(sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(), sl(),sl(),sl(),sl()));
|
sl.registerLazySingleton(
|
||||||
|
() => DevicesCubit(
|
||||||
// 🔥 DeviceStatusBloc 必须在 RemoteControlCubit 之前注册(单例)
|
sl(), // repository
|
||||||
sl.registerLazySingleton(() => DeviceStatusBloc(
|
sl(), // GetUserDeviceUseCase
|
||||||
sl<NetMessageDispatcher>(),
|
sl(), // GetDeviceLocationUseCase
|
||||||
client: sl<TcpClient>(), // 🔥 传入 TcpClient
|
sl(), // GetWorkRecordUseCase
|
||||||
));
|
sl(), // GetWorkRecordsBySiteIdUseCase (NEW)
|
||||||
|
sl(), // DeleteWorkRecordUseCase
|
||||||
// 🔥 RemoteControlCubit 注入 DeviceStatusBloc(工厂模式,每次新建)
|
sl(), // UnbindDeviceUseCase
|
||||||
sl.registerFactory(() => RemoteControlCubit(sl(), sl(), sl(), sl()));
|
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 监听)
|
// 🔥 PermissionRequestBloc 用于首页权限弹窗(单例,通过 NetMessageDispatcher 监听)
|
||||||
sl.registerLazySingleton(() => PermissionRequestBloc(
|
sl.registerLazySingleton(
|
||||||
sl<NetMessageDispatcher>(),
|
() => PermissionRequestBloc(
|
||||||
sl<RemoteControlRepository>(),
|
sl<NetMessageDispatcher>(),
|
||||||
));
|
sl<RemoteControlRepository>(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
/// . 路径生成
|
/// . 路径生成
|
||||||
sl.registerLazySingleton<PathHttpDatasource>(
|
sl.registerLazySingleton<PathHttpDatasource>(
|
||||||
@@ -214,16 +543,36 @@ Future<void> init() async {
|
|||||||
sl<UserStorage>(),
|
sl<UserStorage>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
sl.registerLazySingleton<PathRepository>(() => PathRepositoryImpl(datasource: sl<PathHttpDatasource>()));
|
sl.registerLazySingleton<PathRepository>(
|
||||||
sl.registerLazySingleton<GeneratePathUseCase>(() => GeneratePathUseCaseImpl(sl<PathRepository>()));
|
() => PathRepositoryImpl(datasource: sl<PathHttpDatasource>()),
|
||||||
|
);
|
||||||
|
sl.registerLazySingleton<GeneratePathUseCase>(
|
||||||
|
() => GeneratePathUseCaseImpl(sl<PathRepository>()),
|
||||||
|
);
|
||||||
|
|
||||||
///.路径获取和删除
|
///.路径获取和删除
|
||||||
sl.registerLazySingleton(() => GetWorkRecordUseCase(sl<PathRepository>()));
|
sl.registerLazySingleton(() => GetWorkRecordUseCase(sl<PathRepository>()));
|
||||||
sl.registerLazySingleton(() => DeleteWorkRecordUseCase(sl<PathRepository>()));
|
sl.registerLazySingleton(() => DeleteWorkRecordUseCase(sl<PathRepository>()));
|
||||||
|
|
||||||
|
/// 根据场站ID获取工作记录(XML格式)
|
||||||
|
sl.registerLazySingleton(
|
||||||
|
() => GetWorkRecordsBySiteIdUseCase(sl<PathRepository>()),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 创建设备任务(通过接口执行作业)
|
||||||
|
sl.registerLazySingleton(() => CreateDeviceTaskUseCase(sl<PathRepository>()));
|
||||||
|
|
||||||
/// 6. 认证 (Auth)
|
/// 6. 认证 (Auth)
|
||||||
// --- 关键修改点 1: AuthCubit 必须在 GoRouter 之前注册,并传入参数 ---
|
// --- 关键修改点 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)
|
/// 7. 路由 (Router)
|
||||||
sl.registerSingleton<GoRouter>(createRouter(sl<AuthCubit>()));
|
sl.registerSingleton<GoRouter>(createRouter(sl<AuthCubit>()));
|
||||||
@@ -231,4 +580,116 @@ Future<void> init() async {
|
|||||||
/// 8. 页面级 Bloc/Cubit (Factory)
|
/// 8. 页面级 Bloc/Cubit (Factory)
|
||||||
sl.registerFactory(() => LoginBloc(sl()));
|
sl.registerFactory(() => LoginBloc(sl()));
|
||||||
sl.registerFactory(() => LoginCubit(sl(), 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>()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,24 @@ class UserEntity extends Equatable {
|
|||||||
final String username;
|
final String username;
|
||||||
final String nickname;
|
final String nickname;
|
||||||
final String token;
|
final String token;
|
||||||
|
final int orgId;
|
||||||
final String? avatar;
|
final String? avatar;
|
||||||
final String? email;
|
final String? email;
|
||||||
final String? phone;
|
final String? phone;
|
||||||
|
final String? roleKey;
|
||||||
|
final int? siteId;
|
||||||
|
|
||||||
UserEntity({
|
const UserEntity({
|
||||||
required this.userId,
|
required this.userId,
|
||||||
required this.username,
|
required this.username,
|
||||||
required this.nickname,
|
required this.nickname,
|
||||||
required this.token,
|
required this.token,
|
||||||
|
required this.orgId,
|
||||||
this.avatar,
|
this.avatar,
|
||||||
this.email,
|
this.email,
|
||||||
this.phone,
|
this.phone,
|
||||||
|
this.roleKey,
|
||||||
|
this.siteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -26,7 +32,36 @@ class UserEntity extends Equatable {
|
|||||||
nickname,
|
nickname,
|
||||||
avatar,
|
avatar,
|
||||||
token,
|
token,
|
||||||
|
orgId,
|
||||||
email,
|
email,
|
||||||
phone,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
lib/core/env/env_config.dart
vendored
17
lib/core/env/env_config.dart
vendored
@@ -1,7 +1,7 @@
|
|||||||
class EnvConfig {
|
class EnvConfig {
|
||||||
static const String environment = String.fromEnvironment(
|
static const String environment = String.fromEnvironment(
|
||||||
'ENV',
|
'ENV',
|
||||||
defaultValue: 'dev',
|
defaultValue: 'prod',
|
||||||
);
|
);
|
||||||
|
|
||||||
static String get sentryDsn {
|
static String get sentryDsn {
|
||||||
@@ -13,4 +13,19 @@ class EnvConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static bool get isProduction => environment == 'prod';
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ class Failure {
|
|||||||
class NetworkFailure extends Failure {
|
class NetworkFailure extends Failure {
|
||||||
NetworkFailure(super.message);
|
NetworkFailure(super.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ServerFailure extends Failure {
|
||||||
|
ServerFailure(super.message);
|
||||||
|
}
|
||||||
|
|||||||
10
lib/core/error/workorder_failure.dart
Normal file
10
lib/core/error/workorder_failure.dart
Normal 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);
|
||||||
|
}
|
||||||
14
lib/core/logging/log_time.dart
Normal file
14
lib/core/logging/log_time.dart
Normal 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')}]';
|
||||||
|
}
|
||||||
|
}
|
||||||
180
lib/core/managers/drone_task_state_manager.dart
Normal file
180
lib/core/managers/drone_task_state_manager.dart
Normal 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();
|
||||||
@@ -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(
|
dio.interceptors.add(
|
||||||
InterceptorsWrapper(
|
InterceptorsWrapper(
|
||||||
@@ -30,22 +36,29 @@ class DioClient {
|
|||||||
return handler.next(options);
|
return handler.next(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
// ======================
|
|
||||||
// ✅ 关键:401 自动拦截
|
|
||||||
// ======================
|
|
||||||
onResponse: (response, handler) {
|
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);
|
return handler.next(response);
|
||||||
},
|
},
|
||||||
|
|
||||||
onError: (DioException e, handler) async {
|
onError: (DioException e, handler) async {
|
||||||
// 401 = token 过期 / 未授权
|
if (e.response?.statusCode == 401 || e.response?.statusCode == 403) {
|
||||||
if (e.response?.statusCode == 401) {
|
print(
|
||||||
|
'>>> [DIO] 🚨🚨🚨 收到 HTTP ${e.response?.statusCode},触发 Token 过期处理!URL: ${e.requestOptions.uri},时间: ${DateTime.now()}',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// 调用 logout 清除本地缓存 + 跳登录
|
sl<AuthCubit>().tokenExpired();
|
||||||
await sl<AuthCubit>().logout();
|
} catch (ex) {}
|
||||||
} catch (ex) {
|
|
||||||
// 防止报错
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return handler.next(e);
|
return handler.next(e);
|
||||||
|
|||||||
70
lib/core/network/error_handler.dart
Normal file
70
lib/core/network/error_handler.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
166
lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart
Normal file
166
lib/core/network/mqtt/data/datasources/drone_osd_datasource.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart
Normal file
272
lib/core/network/mqtt/data/infrastructure/mqtt_client_impl.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/core/network/mqtt/domain/entities/drone_osd_entity.dart
Normal file
18
lib/core/network/mqtt/domain/entities/drone_osd_entity.dart
Normal 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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
14
lib/core/network/mqtt/domain/interfaces/mqtt_client.dart
Normal file
14
lib/core/network/mqtt/domain/interfaces/mqtt_client.dart
Normal 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;
|
||||||
|
}
|
||||||
140
lib/core/network/mqtt/domain/models/mqtt_config.dart
Normal file
140
lib/core/network/mqtt/domain/models/mqtt_config.dart
Normal 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
24
lib/core/network/mqtt/domain/models/mqtt_message.dart
Normal file
24
lib/core/network/mqtt/domain/models/mqtt_message.dart
Normal 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];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
69
lib/core/network/mqtt/mqtt_manager.dart
Normal file
69
lib/core/network/mqtt/mqtt_manager.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -91,7 +91,7 @@ class NetMessageDispatcher {
|
|||||||
//机器状态页面专用
|
//机器状态页面专用
|
||||||
Stream<String> onStringMessageOfdeviceStatus() {
|
Stream<String> onStringMessageOfdeviceStatus() {
|
||||||
//print("0x02--TCP拦截推送解析开始");
|
//print("0x02--TCP拦截推送解析开始");
|
||||||
_logger.log('0x02--TCP拦截推送解析开始');
|
_logger.logWithLevel('0x02--TCP拦截推送解析开始' ,shouldLog: true);
|
||||||
// 🔥 关键修复:使用asBroadcastStream()确保多个监听者都能收到数据
|
// 🔥 关键修复:使用asBroadcastStream()确保多个监听者都能收到数据
|
||||||
return onCommand(0x02).map((p) {
|
return onCommand(0x02).map((p) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
52
lib/core/network/tcp/tcp_status_cubit.dart
Normal file
52
lib/core/network/tcp/tcp_status_cubit.dart
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
|
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
|
||||||
import 'package:maibu_satabot_v2/features/ai/presentation/routes/ai_routes.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/auth/presentation/routes/auth_routes.dart';
|
||||||
import 'package:maibu_satabot_v2/features/devices/domain/entities/device_entity.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/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/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_cubit.dart';
|
||||||
import '../../features/auth/presentation/bloc/auth_state.dart';
|
import '../../features/auth/presentation/bloc/auth_state.dart';
|
||||||
import '../../features/machine_details/presentation/pages/machine_details_page.dart';
|
import '../../features/machine_details/presentation/pages/machine_details_page.dart';
|
||||||
import '../../features/main_container/presentation/main_wrapper.dart';
|
import '../../features/main_container/presentation/main_wrapper.dart';
|
||||||
|
import '../../main.dart'; // 🔥 导入 navigatorKey
|
||||||
import 'go_router_refresh_stream.dart';
|
import 'go_router_refresh_stream.dart';
|
||||||
|
|
||||||
GoRouter createRouter(AuthCubit authCubit) {
|
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(
|
return GoRouter(
|
||||||
initialLocation: RoutePaths.login,
|
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
|
||||||
|
initialLocation: RoutePaths.splash,
|
||||||
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final loggedIn = authCubit.state is AuthAuthenticated;
|
final loggedIn = authCubit.state is AuthAuthenticated;
|
||||||
|
final onSplash = state.matchedLocation == RoutePaths.splash;
|
||||||
final loggingIn = state.matchedLocation == RoutePaths.login;
|
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;
|
return RoutePaths.login;
|
||||||
}
|
}
|
||||||
if (loggedIn && loggingIn) {
|
// 如果已登录,尝试访问 login 或 register,则跳转到首页
|
||||||
|
if (loggedIn && (loggingIn || registering)) {
|
||||||
return RoutePaths.home;
|
return RoutePaths.home;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -43,33 +67,23 @@ GoRouter createRouter(AuthCubit authCubit) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// 注册登录注册 路由
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: RoutePaths.machineDetails,
|
path: RoutePaths.home,
|
||||||
builder: (context, state) => const LoginPage(), // 替换为实际页面
|
builder: (context, state) => const MainWrapper(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'settings',
|
||||||
|
builder: (context, state) => const TabSettingsPage(),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// 1. 外部路由(无底部导航栏)
|
|
||||||
...AuthRoutes.routes,
|
...AuthRoutes.routes,
|
||||||
...HomeRoutes.routes,
|
...HomeRoutes.routes,
|
||||||
...AiRoutes.routes,
|
...AiRoutes.routes,
|
||||||
...MyRoutes.routes,
|
...MyRoutes.routes,
|
||||||
|
...AlarmCenterRoutes.routes,
|
||||||
// 2. 内部路由(带底部导航栏的容器)
|
...WorkOrderRoutes.routes,
|
||||||
StatefulShellRoute.indexedStack(
|
|
||||||
builder: (context, state, navigationShell) {
|
|
||||||
// 返回我们定义的 MainWrapper
|
|
||||||
return MainWrapper(navigationShell: navigationShell);
|
|
||||||
},
|
|
||||||
branches: [
|
|
||||||
// 拿一级页面
|
|
||||||
HomeRoutes.branch,
|
|
||||||
|
|
||||||
AiRoutes.branch,
|
|
||||||
|
|
||||||
MyRoutes.branch,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
class RoutePaths {
|
class RoutePaths {
|
||||||
|
/// 启动页(App 第一个路由)
|
||||||
|
static const splash = '/splash';
|
||||||
|
|
||||||
/// 账号相关页面
|
/// 账号相关页面
|
||||||
static const login = '/auth/login';
|
static const login = '/auth/login';
|
||||||
static const register = '/auth/register';
|
static const register = '/auth/register';
|
||||||
@@ -16,6 +19,12 @@ class RoutePaths {
|
|||||||
static const djMap = '/home/dj_map';
|
static const djMap = '/home/dj_map';
|
||||||
static const productDesc = '/home/productDesc';
|
static const productDesc = '/home/productDesc';
|
||||||
static const usage = '/home/usage';
|
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 machineDetails = '/machine_details/machine_details';
|
||||||
|
|
||||||
|
/// 工单相关页面
|
||||||
|
static const workOrder = '/workorder';
|
||||||
|
static const workOrderDetail = '/workorder/detail/:orderId';
|
||||||
}
|
}
|
||||||
|
|||||||
69
lib/core/services/device_permission_service.dart
Normal file
69
lib/core/services/device_permission_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,159 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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 {
|
class AppTheme {
|
||||||
// 基础色值定义
|
// 基础色值定义
|
||||||
static const Color _pureWhite = Color(0xFFFFFFFF);
|
static const Color _pureWhite = Color(0xFFFFFFFF);
|
||||||
static const Color _offWhite = Color(0xFFfafafc); // 稍微带点灰的白,用于背景增加层次感
|
static const Color _offWhite = Color(0xFFfafafc); // 稍微带点灰的白,用于背景增加层次感
|
||||||
static const Color _pureBlack = Color(0xFF000000);
|
static const Color _pureBlack = Color(0xFF000000);
|
||||||
static const Color _darkGrey = Color(0xFF1C1C1E); // iOS 风格的深灰黑色
|
static const Color _darkGrey = Color(0xFF1C1C1E); // iOS 风格的深灰黑色
|
||||||
|
static const Color _surfaceDark = Color(0xFF2C2C2E); // 深色模式下卡片、弹窗表面色
|
||||||
|
|
||||||
static ThemeData get lightTheme {
|
static ThemeData get lightTheme {
|
||||||
return ThemeData(
|
return ThemeData(
|
||||||
@@ -15,6 +162,9 @@ class AppTheme {
|
|||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
fontFamily: (Platform.isWindows) ? 'Microsoft YaHei' : null,
|
fontFamily: (Platform.isWindows) ? 'Microsoft YaHei' : null,
|
||||||
|
|
||||||
|
// 语义色扩展
|
||||||
|
extensions: const [AppColors.light],
|
||||||
|
|
||||||
// 1. 整体背景色
|
// 1. 整体背景色
|
||||||
scaffoldBackgroundColor: _offWhite,
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
54
lib/core/theme/theme_cubit.dart
Normal file
54
lib/core/theme/theme_cubit.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
218
lib/core/update/update_cubit.dart
Normal file
218
lib/core/update/update_cubit.dart
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
372
lib/core/update/update_dialog.dart
Normal file
372
lib/core/update/update_dialog.dart
Normal 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('我知道了'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
67
lib/core/update/update_state.dart
Normal file
67
lib/core/update/update_state.dart
Normal 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);
|
||||||
|
}
|
||||||
327
lib/core/update/version_check_service.dart
Normal file
327
lib/core/update/version_check_service.dart
Normal 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};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
lib/core/utils/image_url_util.dart
Normal file
24
lib/core/utils/image_url_util.dart
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,48 +60,48 @@ class AuthTcpDatasourceImpl implements AuthTcpDatasource {
|
|||||||
debugPrint('✅ sendAuthPacket[AuthTcp] 认证包已发送');
|
debugPrint('✅ sendAuthPacket[AuthTcp] 认证包已发送');
|
||||||
|
|
||||||
// 获取用户设备列表
|
// 获取用户设备列表
|
||||||
try {
|
// try {
|
||||||
// 1. 获取 Either 结果
|
// // 1. 获取 Either 结果
|
||||||
final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
|
// final eitherResult = await getUserDeviceUseCase.repository.getUserDevice(username);
|
||||||
|
//
|
||||||
// 2. 使用 fold 解包 Either
|
// // 2. 使用 fold 解包 Either
|
||||||
// left: 处理错误情况 (DeviceFailure)
|
// // left: 处理错误情况 (DeviceFailure)
|
||||||
// right: 处理成功情况 (List<DeviceEntity>)
|
// // right: 处理成功情况 (List<DeviceEntity>)
|
||||||
await eitherResult.fold(
|
// await eitherResult.fold(
|
||||||
(failure) {
|
// (failure) {
|
||||||
// 处理失败:打印日志或抛出异常
|
// // 处理失败:打印日志或抛出异常
|
||||||
debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure');
|
// debugPrint('❌ sendAuthPacket [AuthTcp] 获取设备列表失败:$failure');
|
||||||
throw Exception('获取设备列表失败:$failure');
|
// throw Exception('获取设备列表失败:$failure');
|
||||||
},
|
// },
|
||||||
(devices) async {
|
// (devices) async {
|
||||||
// 处理成功:devices 现在是真正的 List<DeviceEntity>
|
// // 处理成功:devices 现在是真正的 List<DeviceEntity>
|
||||||
if (devices.isEmpty) {
|
// if (devices.isEmpty) {
|
||||||
debugPrint('⚠️ sendAuthPacket[AuthTcp] 当前用户无可用设备,跳过切换步骤');
|
// debugPrint('⚠️ sendAuthPacket[AuthTcp] 当前用户无可用设备,跳过切换步骤');
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
// 取第一个设备
|
// // 取第一个设备
|
||||||
final DeviceEntity targetDevice = devices.first;
|
// final DeviceEntity targetDevice = devices.first;
|
||||||
debugPrint('📱 sendAuthPacket[AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
|
// debugPrint('📱 sendAuthPacket[AuthTcp] 准备切换至默认设备:${targetDevice.deviceName}');
|
||||||
|
//
|
||||||
// 切换设备 (同样,如果 switchDeviceUseCase 也返回 Either,也需要 fold 处理)
|
// // 切换设备 (同样,如果 switchDeviceUseCase 也返回 Either,也需要 fold 处理)
|
||||||
final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
|
// final switchResult = await switchDeviceUseCase.deviceRepository.switchDevice("app",targetDevice.deviceName);
|
||||||
|
//
|
||||||
await switchResult.fold(
|
// await switchResult.fold(
|
||||||
(failure) {
|
// (failure) {
|
||||||
debugPrint('❌ sendAuthPacket[AuthTcp] 切换设备失败:$failure');
|
// debugPrint('❌ sendAuthPacket[AuthTcp] 切换设备失败:$failure');
|
||||||
throw Exception('切换设备失败:$failure');
|
// throw Exception('切换设备失败:$failure');
|
||||||
},
|
// },
|
||||||
(success) {
|
// (success) {
|
||||||
debugPrint('✅ sendAuthPacket[AuthTcp] 设备切换成功,服务端应开始推送数据');
|
// debugPrint('✅ sendAuthPacket[AuthTcp] 设备切换成功,服务端应开始推送数据');
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
},
|
// },
|
||||||
);
|
// );
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
debugPrint('❌ sendAuthPacket[AuthTcp] 设备订阅流程异常:$e');
|
// debugPrint('❌ sendAuthPacket[AuthTcp] 设备订阅流程异常:$e');
|
||||||
rethrow;
|
// rethrow;
|
||||||
}
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,21 +8,26 @@ class UserModel extends UserEntity implements BaseModel {
|
|||||||
required super.username,
|
required super.username,
|
||||||
required super.nickname,
|
required super.nickname,
|
||||||
required super.token,
|
required super.token,
|
||||||
|
required super.orgId,
|
||||||
super.avatar,
|
super.avatar,
|
||||||
super.email,
|
super.email,
|
||||||
super.phone,
|
super.phone,
|
||||||
|
super.roleKey,
|
||||||
|
super.siteId,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||||
print(json);
|
|
||||||
return UserModel(
|
return UserModel(
|
||||||
userId: json['userId'],
|
userId: json['userId']?.toString() ?? '',
|
||||||
username: json['username'],
|
username: json['username'] ?? '',
|
||||||
nickname: json['nickName'],
|
nickname: json['nickName'] ?? '',
|
||||||
token: json['token'],
|
token: json['token'] ?? '',
|
||||||
|
orgId: (json['orgId'] as num?)?.toInt() ?? 0,
|
||||||
avatar: json['avatar'],
|
avatar: json['avatar'],
|
||||||
email: json['email'],
|
email: json['email'],
|
||||||
phone: json['phone'],
|
phone: json['phone'],
|
||||||
|
roleKey: json['roleKey'],
|
||||||
|
siteId: (json['siteId'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,9 +37,12 @@ class UserModel extends UserEntity implements BaseModel {
|
|||||||
'username': username,
|
'username': username,
|
||||||
'nickName': nickname,
|
'nickName': nickname,
|
||||||
'token': token,
|
'token': token,
|
||||||
|
'orgId': orgId,
|
||||||
'avatar': avatar,
|
'avatar': avatar,
|
||||||
'email': email,
|
'email': email,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
|
'roleKey': roleKey,
|
||||||
|
'siteId': siteId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,9 +52,12 @@ class UserModel extends UserEntity implements BaseModel {
|
|||||||
username: username,
|
username: username,
|
||||||
nickname: nickname,
|
nickname: nickname,
|
||||||
token: token,
|
token: token,
|
||||||
|
orgId: orgId,
|
||||||
avatar: avatar,
|
avatar: avatar,
|
||||||
email: email,
|
email: email,
|
||||||
phone: phone,
|
phone: phone,
|
||||||
|
roleKey: roleKey,
|
||||||
|
siteId: siteId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,9 +67,12 @@ class UserModel extends UserEntity implements BaseModel {
|
|||||||
username: entity.username,
|
username: entity.username,
|
||||||
nickname: entity.nickname,
|
nickname: entity.nickname,
|
||||||
token: entity.token,
|
token: entity.token,
|
||||||
|
orgId: entity.orgId,
|
||||||
avatar: entity.avatar,
|
avatar: entity.avatar,
|
||||||
email: entity.email,
|
email: entity.email,
|
||||||
phone: entity.phone,
|
phone: entity.phone,
|
||||||
|
roleKey: entity.roleKey,
|
||||||
|
siteId: entity.siteId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:get_it/get_it.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: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/domain/entities/user_entity.dart';
|
||||||
import 'package:maibu_satabot_v2/core/network/net_message_dispatcher.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_bloc.dart';
|
||||||
import '../../../devices/presentation/bloc/device_status_event.dart';
|
import '../../../devices/presentation/bloc/device_status_event.dart';
|
||||||
import '../../../devices/presentation/bloc/devices_state.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/auth_tcp_datasource.dart';
|
||||||
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
|
import '../../data/datasources/impl/auth_tcp_datasource_impl.dart';
|
||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
|
import '../../../../main.dart'; // 🔥 导入 main.dart 以使用 navigatorKey
|
||||||
|
|
||||||
/// emit做的事情:
|
/// emit做的事情:
|
||||||
/// 1.修改状态标志:通过改变类的类型(从 Initial 变成 Authenticated)
|
/// 1.修改状态标志:通过改变类的类型(从 Initial 变成 Authenticated)
|
||||||
@@ -37,74 +48,492 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
|
|
||||||
StreamSubscription? _kickOutSub; // 新增:用于管理监听生命周期
|
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 的“自动逻辑”
|
// Cubit 一启动就开始监听 TCP 的“自动逻辑”
|
||||||
_listenToAuthResponse();
|
_listenToAuthResponse();
|
||||||
|
// 🔥 设置重连耗尽回调:连续4次重连失败后弹窗提示用户退出登录
|
||||||
|
tcp.onReconnectExhausted = _showReconnectFailedDialog;
|
||||||
|
// 🔥 设置重连提示回调:重连中/重连成功 SnackBar
|
||||||
|
tcp.onReconnectStarted = _showReconnectingToast;
|
||||||
|
tcp.onReconnectSuccess = _showReconnectSuccessToast;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App 启动时检查本地缓存
|
/// App 启动时检查本地缓存
|
||||||
Future<void> appStarted() async {
|
Future<void> appStarted() async {
|
||||||
final logger = GetIt.I<ILoggerService>() as SentryLoggerImpl;
|
final logger = GetIt.I<ILoggerService>() as SentryLoggerImpl;
|
||||||
// print("App 启动时检查本地缓存");
|
|
||||||
final user = await storage.getUser();
|
try {
|
||||||
logger.logWithLevel(
|
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',
|
level: 'INFO',
|
||||||
data: {'user': user, 'data': user}
|
data: {'user': user != null ? '找到用户: ${user.username}' : '未找到用户'},
|
||||||
);
|
);
|
||||||
if (user != null) {
|
|
||||||
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
if (user != null) {
|
||||||
// await _authTcpDatasource.sendAuthPacket();//包括发送认证包和获取列表和切换函数
|
final isValid = await _verifyToken(user);
|
||||||
tcp.startHeartbeat(interval: const Duration(seconds: 4));
|
if (isValid) {
|
||||||
// 2. 同步全局 App 状态
|
logger.logWithLevel('✅ [AUTH] Token 有效,恢复登录状态', level: 'INFO');
|
||||||
appCubit.setAuth(user);
|
appCubit.setAuth(user);
|
||||||
// 3. 进入已登录状态
|
emit(AuthAuthenticated(user));
|
||||||
emit(AuthAuthenticated(user));
|
logger.logWithLevel('✅ [AUTH] 应用启动 - 已恢复登录状态', level: 'INFO');
|
||||||
} else {
|
} else {
|
||||||
// print("App 启动时检查本地缓存user=null) ");
|
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());
|
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 登录/注册成功后调用
|
/// 当 HTTP 登录/注册成功后调用
|
||||||
Future<void> loginSuccess(UserEntity user) async {
|
Future<void> loginSuccess(UserEntity user) async {
|
||||||
await storage.saveUser(user);
|
await storage.saveUser(user);
|
||||||
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
|
||||||
// await _authTcpDatasource.sendAuthPacket(); //包括发送认证包和获取列表和切换函数
|
// 🔥 登录后立即创建TCP连接并验证
|
||||||
//tcp.startHeartbeat(interval: const Duration(seconds: 4)); //启动心跳
|
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);
|
appCubit.setAuth(user);
|
||||||
emit(AuthAuthenticated(user));
|
emit(AuthAuthenticated(user));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 退出登录 (主动或被动)
|
/// 退出登录 (主动或被动)
|
||||||
Future<void> logout() async {
|
Future<void> logout() async {
|
||||||
//print("退出登录");
|
print('>>> [AUTH] 🚨🚨🚨 logout() 被调用!时间: ${DateTime.now()}');
|
||||||
|
print('>>> [AUTH] 调用栈:\n${StackTrace.current}');
|
||||||
|
|
||||||
// 🔥 关键修复:在断开TCP前,先清空所有业务状态,避免重连触发旧数据
|
// 🔥 关键修复:在断开TCP前,先清空所有业务状态,避免重连触发旧数据
|
||||||
_clearAllBusinessState();
|
_clearAllBusinessState();
|
||||||
|
|
||||||
await storage.deleteUser();
|
await storage.deleteUser();
|
||||||
tcp.disconnect();
|
tcp.forceDisconnect(); // 🔥 修复:彻底断开连接,禁止自动重连
|
||||||
appCubit.clearAuth();
|
appCubit.clearAuth();
|
||||||
emit(AuthUnauthenticated());
|
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() {
|
void _clearAllBusinessState() {
|
||||||
try {
|
try {
|
||||||
|
// 1. 清空远程控制所有状态(targetDevice、权限、摇杆数据、电压电量等)
|
||||||
|
final remoteControlCubit = GetIt.I<RemoteControlCubit>();
|
||||||
|
remoteControlCubit.clearAll();
|
||||||
|
debugPrint('✅ [AUTH] 已清空 RemoteControlCubit 状态');
|
||||||
|
_logger.logWithLevel('✅ [AUTH] 已清空 RemoteControlCubit 状态');
|
||||||
|
|
||||||
|
// 2. 清空设备列表和选中设备
|
||||||
final devicesCubit = GetIt.I<DevicesCubit>();
|
final devicesCubit = GetIt.I<DevicesCubit>();
|
||||||
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
|
|
||||||
|
|
||||||
// 1. 清空设备列表和选中设备
|
|
||||||
devicesCubit.emit(const DevicesState());
|
devicesCubit.emit(const DevicesState());
|
||||||
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
|
debugPrint('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||||||
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
|
_logger.logWithLevel('✅ [AUTH] 已清空 DevicesCubit 状态');
|
||||||
|
|
||||||
// 2. 清空设备实时状态
|
// 3. 清空设备实时状态(图表数据等)
|
||||||
|
final deviceStatusBloc = GetIt.I<DeviceStatusBloc>();
|
||||||
deviceStatusBloc.add(DeviceStatusReset());
|
deviceStatusBloc.add(DeviceStatusReset());
|
||||||
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
debugPrint('✅ [AUTH] 已重置 DeviceStatusBloc 状态');
|
||||||
_logger.logWithLevel('✅ [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] 所有业务状态已清空');
|
debugPrint('✅ [AUTH] 所有业务状态已清空');
|
||||||
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
|
_logger.logWithLevel('✅ [AUTH] 所有业务状态已清空');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -113,83 +542,213 @@ class AuthCubit extends Cubit<AuthState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// TCP 指令监听
|
/// 🔥 等待登录验证结果(2.5 秒内看是否收到 have_logged_in)
|
||||||
// void _listenToAuthResponse() {
|
Future<bool> _waitForLoginVerification() async {
|
||||||
// print('>>> [AUTH] begin指令监听: ');
|
_loginVerificationCompleter = Completer<bool>();
|
||||||
// _kickOutSub?.cancel(); // 防止重复监听
|
|
||||||
//
|
// 等待 2.5 秒
|
||||||
// // 假设 0x12 是踢下线或多设备登录提醒
|
await Future.delayed(const Duration(milliseconds: 2500));
|
||||||
// _kickOutSub = dispatcher.onJsonMessage(0x12).listen((json) {
|
|
||||||
// // 如果后端发来指令确认需要退出
|
// 如果 completer 还没完成,说明没收到 have_logged_in,返回 false(可以进入)
|
||||||
// print('>>> [AUTH] 收到 0x12: $json');
|
if (!_loginVerificationCompleter!.isCompleted) {
|
||||||
// // logout();
|
_loginVerificationCompleter!.complete(false);
|
||||||
// final respond = json['respond'] ?? '';
|
}
|
||||||
// if (respond == 'have_logged_in') {
|
|
||||||
// logout(); // ✅ 只有特定消息才退出
|
return _loginVerificationCompleter!.future;
|
||||||
// }
|
}
|
||||||
// });
|
|
||||||
// }
|
|
||||||
/// TCP 指令监听
|
/// TCP 指令监听
|
||||||
void _listenToAuthResponse() {
|
void _listenToAuthResponse() {
|
||||||
_logger.logWithLevel('[AUTH] 监听 TCP 0x12 指令...', level: 'DEBUG');
|
debugPrint('>>> [AUTH] _listenToAuthResponse() 被调用,开始监听 0x12 指令');
|
||||||
_kickOutSub?.cancel(); // 防止重复监听
|
_kickOutSub?.cancel(); // 防止重复监听
|
||||||
|
|
||||||
// 直接监听原始数据包,自己处理 JSON 解析(去掉 CRC 字节)
|
// 直接监听原始数据包,自己处理 JSON 解析(去掉 CRC 字节)
|
||||||
_kickOutSub = dispatcher.onCommand(0x12).listen((packet) {
|
_kickOutSub = dispatcher.onCommand(0x12).listen((packet) {
|
||||||
_logger.logWithLevel('[AUTH] 获取原始包:$packet', level: 'DEBUG');
|
debugPrint(
|
||||||
_logger.logWithLevel('>>> [AUTH] 收到 0x12 原始包,payload 长度=${packet.payload.length}, 内容=${packet.payload}', level: 'DEBUG');
|
'>>> [AUTH] 收到 0x12 原始包,payload 长度=${packet.payload.length}, 内容=${packet.payload}',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
// 🔥 关键:手动去掉最后 2 个 CRC 字节
|
// 🔥 关键:手动去掉最后 2 个 CRC 字节
|
||||||
String jsonString;
|
String jsonString;
|
||||||
if (packet.payload.length > 2) {
|
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 {
|
} else {
|
||||||
jsonString = utf8.decode(packet.payload);
|
jsonString = utf8.decode(packet.payload);
|
||||||
}
|
}
|
||||||
_logger.logWithLevel('[AUTH] 获取 JSON: $jsonString', level: 'DEBUG');
|
debugPrint('>>> [AUTH] 获取 JSON: "$jsonString"');
|
||||||
|
|
||||||
final jsonMap = jsonDecode(jsonString);
|
final jsonMap = jsonDecode(jsonString);
|
||||||
_logger.logWithLevel('[AUTH] 获取 JSON Map: $jsonMap', level: 'DEBUG');
|
debugPrint('>>> [AUTH] 获取 JSON Map: $jsonMap');
|
||||||
|
|
||||||
final respond = jsonMap['respond'] ?? '';
|
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') {
|
if (respond == 'have_logged_in') {
|
||||||
_logger.logWithLevel('[AUTH] ⚠️ 检测到异地登录 (respond=have_logged_in),开始退出...', level: 'WARN');
|
// 🔥 登录阶段策略:HTTP 已成功,TCP 认证阶段的推送视为服务端状态同步,直接放行
|
||||||
logout();
|
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 {
|
} else {
|
||||||
_logger.logWithLevel('[AUTH] ⚠️ 检测到异地登录 (respond=$respond),开始退出...', level: 'WARN');
|
debugPrint('>>> [AUTH] ℹ️ 收到 0x12 消息,respond="$respond",不处理');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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
|
@override
|
||||||
Future<void> close() {
|
Future<void> close() {
|
||||||
_kickOutSub?.cancel(); // 销毁 Cubit 时关闭监听
|
_kickOutSub?.cancel(); // 销毁 Cubit 时关闭监听
|
||||||
|
_safeModeExitTimer?.cancel(); // 清理倒计时
|
||||||
return super.close();
|
return super.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 🔥 息屏/后台后恢复到前台时的重连方法
|
/// 🔥 息屏/后台后恢复到前台时的重连方法
|
||||||
Future<void> reconnectAfterResume() async {
|
Future<void> reconnectAfterResume() async {
|
||||||
_logger.logWithLevel('[AUTH] 检测到应用恢复到前台,检查 TCP 连接状态...', level: 'DEBUG');
|
_logger.logWithLevel('[AUTH] 检测到应用恢复到前台,检查 TCP 连接状态...', level: 'INFO');
|
||||||
// 如果 TCP 未连接,则执行重连
|
// 如果 TCP 未连接,则执行重连
|
||||||
if (!tcp.isConnected) {
|
if (!tcp.isConnected) {
|
||||||
_logger.logWithLevel('[AUTH] TCP 未连接,开始重连...',
|
_logger.logWithLevel('[AUTH] TCP 未连接,开始重连...', level: 'INFO');
|
||||||
level: 'DEBUG');
|
|
||||||
try {
|
try {
|
||||||
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
await tcp.connect(host: TCPConsts.TCP_IP, port: TCPConsts.TCP_PORT);
|
||||||
tcp.startHeartbeat(interval: const Duration(seconds: 4));
|
tcp.startHeartbeat(interval: const Duration(seconds: 4));
|
||||||
_logger.logWithLevel('[AUTH] TCP 重连成功!', level: 'DEBUG');
|
_logger.logWithLevel('[AUTH] TCP 重连成功!', level: 'INFO');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_logger.logWithLevel('[AUTH] TCP 重连失败:$e', level: 'ERROR');
|
_logger.logWithLevel('[AUTH] TCP 重连失败:$e', level: 'ERROR');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
_logger.logWithLevel('[AUTH] TCP 已连接,无需重连', level: 'DEBUG');
|
_logger.logWithLevel('[AUTH] TCP 已连接,无需重连', level: 'INFO');
|
||||||
// 可选:发送一个心跳包确认连接有效
|
// 可选:发送一个心跳包确认连接有效
|
||||||
tcp.sendHeartbeat();
|
tcp.sendHeartbeat();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,15 +20,24 @@ class LoginCubit extends Cubit<LoginState> {
|
|||||||
Future<void> login(String username, String password, sourceType) async {
|
Future<void> login(String username, String password, sourceType) async {
|
||||||
emit(LoginLoading());
|
emit(LoginLoading());
|
||||||
try {
|
try {
|
||||||
final result = await loginUseCase.call(LoginParams(username, password, sourceType));
|
final result = await loginUseCase.call(
|
||||||
final user = sl<AppUserCubit>().state.user;
|
LoginParams(username, password, sourceType),
|
||||||
final devicesCubit = sl<DevicesCubit>();
|
);
|
||||||
if (user != null) {
|
// final user = sl<AppUserCubit>().state.user;
|
||||||
devicesCubit.fetchAllDevices(user.username);
|
// final devicesCubit = sl<DevicesCubit>();
|
||||||
}
|
// if (user != null) {
|
||||||
result.fold((failure) => emit(LoginFailure(failure.message)), (user) {
|
// 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));
|
emit(LoginSuccess(user));
|
||||||
authCubit.loginSuccess(user);
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(LoginFailure(e.toString()));
|
emit(LoginFailure(e.toString()));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:go_router/go_router.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/app/app_user_cubit.dart';
|
||||||
import 'package:maibu_satabot_v2/core/di/injection.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 'package:maibu_satabot_v2/features/devices/presentation/bloc/devices_cubit.dart';
|
||||||
|
|
||||||
import '../../../../core/router/route_paths.dart';
|
import '../../../../core/router/route_paths.dart';
|
||||||
@@ -17,111 +18,259 @@ class LoginPage extends StatefulWidget {
|
|||||||
State<LoginPage> createState() => _LoginPageState();
|
State<LoginPage> createState() => _LoginPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends State<LoginPage> {
|
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
|
||||||
final _userCtrl = TextEditingController();
|
final _userCtrl = TextEditingController();
|
||||||
final _pwdCtrl = TextEditingController();
|
final _pwdCtrl = TextEditingController();
|
||||||
bool _isAgreed = false; // 协议勾选状态
|
bool _isAgreed = false; // 协议勾选状态
|
||||||
bool _obscurePwd = true; // 密码可见性
|
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_entranceController.dispose();
|
||||||
_userCtrl.dispose();
|
_userCtrl.dispose();
|
||||||
_pwdCtrl.dispose();
|
_pwdCtrl.dispose();
|
||||||
super.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: context.appColors.pageBackground,
|
||||||
// 保持 AppBar 简洁或直接去掉
|
// 保持 AppBar 简洁或直接去掉
|
||||||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0),
|
appBar: AppBar(backgroundColor: context.appColors.pageBackground, elevation: 0),
|
||||||
body: BlocListener<LoginCubit, LoginState>(
|
body: BlocListener<LoginCubit, LoginState>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
|
debugPrint('🔍 [LOGIN] 登录状态变化: $state');
|
||||||
if (state is LoginSuccess) {
|
if (state is LoginSuccess) {
|
||||||
|
debugPrint('✅ [LOGIN] 登录成功,准备跳转到首页');
|
||||||
context.go(RoutePaths.home);
|
context.go(RoutePaths.home);
|
||||||
|
debugPrint('✅ [LOGIN] 已调用 context.go(RoutePaths.home)');
|
||||||
} else if (state is LoginFailure) {
|
} else if (state is LoginFailure) {
|
||||||
// 可以在这里弹出简洁的黑白提示框
|
// 可以在这里弹出简洁的黑白提示框
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登录失败,${state.message}')));
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登录失败,${state.message}')));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: LayoutBuilder(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 30.0),
|
builder: (context, constraints) {
|
||||||
child: Column(
|
return SingleChildScrollView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.symmetric(horizontal: 20.0),
|
||||||
children: [
|
child: ConstrainedBox(
|
||||||
const SizedBox(height: 40),
|
constraints: BoxConstraints(
|
||||||
const Text(
|
minHeight: constraints.maxHeight,
|
||||||
"账号登录",
|
),
|
||||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.black),
|
child: IntrinsicHeight(
|
||||||
),
|
child: Column(
|
||||||
const SizedBox(height: 12),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: Colors.grey)),
|
children: [
|
||||||
const SizedBox(height: 40),
|
// 顶部区域:图片 + 认证信息 + 标题(压缩空间)
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 账号输入框
|
const SizedBox(height: 16),
|
||||||
_buildInputLabel("账号"),
|
|
||||||
TextField(
|
|
||||||
controller: _userCtrl,
|
|
||||||
cursorColor: Colors.black,
|
|
||||||
decoration: _inputDecoration(hint: "请输入用户名"),
|
|
||||||
),
|
|
||||||
|
|
||||||
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// 密码输入框
|
const SizedBox(height: 24),
|
||||||
_buildInputLabel("密码"),
|
|
||||||
TextField(
|
// ③ 表单区域
|
||||||
controller: _pwdCtrl,
|
// 账号输入框(上滑淡入)
|
||||||
obscureText: _obscurePwd,
|
_Entrance(
|
||||||
cursorColor: Colors.black,
|
animation: _interval(0.20, 0.55),
|
||||||
decoration: _inputDecoration(
|
child: Column(
|
||||||
hint: "请输入密码",
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
suffixIcon: IconButton(
|
children: [
|
||||||
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: Colors.grey, size: 20),
|
_buildInputLabel("账号"),
|
||||||
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
|
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) {
|
Widget _buildInputLabel(String label) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
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}) {
|
InputDecoration _inputDecoration({required String hint, Widget? suffixIcon}) {
|
||||||
return InputDecoration(
|
return InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
hintStyle: TextStyle(color: Colors.grey.shade400, fontSize: 14),
|
hintStyle: TextStyle(color: context.appColors.textTertiary, fontSize: 14),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.grey.shade50,
|
fillColor: context.appColors.fillBackground,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade200),
|
borderSide: BorderSide(color: context.appColors.divider),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: const BorderSide(color: Colors.black, width: 1),
|
borderSide: BorderSide(color: context.appColors.primary, width: 1),
|
||||||
),
|
),
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
);
|
);
|
||||||
@@ -175,7 +324,9 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
width: 24,
|
width: 24,
|
||||||
child: Checkbox(
|
child: Checkbox(
|
||||||
value: _isAgreed,
|
value: _isAgreed,
|
||||||
activeColor: Colors.black,
|
activeColor: context.appColors.primary,
|
||||||
|
checkColor: Colors.white,
|
||||||
|
side: BorderSide(color: context.appColors.divider),
|
||||||
onChanged: (val) => setState(() => _isAgreed = val!),
|
onChanged: (val) => setState(() => _isAgreed = val!),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4)),
|
||||||
),
|
),
|
||||||
@@ -184,9 +335,9 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
children: [
|
children: [
|
||||||
const Text("我已阅读并同意 ", style: TextStyle(fontSize: 13, color: Colors.grey)),
|
Text("我已阅读并同意 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
|
||||||
_protocolText("《用户协议》", () => _showProtocolDetail("用户协议")),
|
_protocolText("《用户协议》", () => _showProtocolDetail("用户协议")),
|
||||||
const Text(" 和 ", style: TextStyle(fontSize: 13, color: Colors.grey)),
|
Text(" 和 ", style: TextStyle(fontSize: 13, color: context.appColors.textTertiary)),
|
||||||
_protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")),
|
_protocolText("《隐私政策》", () => _showProtocolDetail("隐私政策")),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -200,7 +351,7 @@ class _LoginPageState extends State<LoginPage> {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Text(
|
child: Text(
|
||||||
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(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: context.appColors.cardBackground,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||||
builder: (context) => Container(
|
builder: (context) => Container(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
height: MediaQuery.of(context).size.height * 0.7,
|
height: MediaQuery.of(context).size.height * 0.7,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
Text(title, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: context.appColors.textPrimary)),
|
||||||
const Divider(height: 32),
|
Divider(height: 32, color: context.appColors.divider),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
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),
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,307 @@
|
|||||||
|
import 'package:cc_ui_kit/cc_ui_kit.dart';
|
||||||
import 'package:flutter/material.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});
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Register')),
|
backgroundColor: context.appColors.pageBackground,
|
||||||
body: Center(child: Text('Welcome Register page')),
|
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
Reference in New Issue
Block a user