Compare commits
15 Commits
b468e577a6
...
feature/ne
| Author | SHA1 | Date | |
|---|---|---|---|
| 208147cdf2 | |||
| b3a700491a | |||
| d230279f4f | |||
| 32397f8899 | |||
| c8b597f683 | |||
| a1e01da241 | |||
| 3547f80383 | |||
| 4b88a291a5 | |||
| 877ff932d4 | |||
| 43c0b68e1a | |||
| 356c2a1925 | |||
| 3ba480f63f | |||
| 85e9632326 | |||
| b4bedd9737 | |||
| d76bcdcdf2 |
21
.gitignore
vendored
21
.gitignore
vendored
@@ -20,7 +20,9 @@ build/
|
||||
gen/
|
||||
local.properties
|
||||
.gradle/
|
||||
.kotlin/
|
||||
captures/
|
||||
hs_err_pid*.log
|
||||
.idea/modules.xml
|
||||
.idea/workspace.xml
|
||||
|
||||
@@ -79,6 +81,25 @@ Debug/
|
||||
# ----------------------------------------------------------------------------
|
||||
lib/generated_plugin_registrant.dart
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Node.js 依赖目录(前端/工具链产物,不需要提交)
|
||||
# ----------------------------------------------------------------------------
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 日志与崩溃转储(本地调试产物,不需要提交)
|
||||
# ----------------------------------------------------------------------------
|
||||
flutter_*.log
|
||||
test_run.log
|
||||
*.log
|
||||
.sentry-native/
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Office 临时锁文件(Word/Excel 打开时生成,不需要提交)
|
||||
# ----------------------------------------------------------------------------
|
||||
~$*
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 编辑器与系统文件
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
Binary file not shown.
@@ -1,4 +1,6 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dhttps.protocols=TLSv1.3,TLSv1.2,TLSv1.1 -Djava.net.preferIPv4Stack=true -Djavax.net.ssl.trustStoreType=JKS
|
||||
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
|
||||
|
||||
# 使用阿里云镜像加速
|
||||
|
||||
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
|
||||
4
dist/manifest.json
vendored
4
dist/manifest.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.0.8",
|
||||
"md5": "3793eb271a2a4f992b11b5f3623420e5",
|
||||
"version": "1.0.10",
|
||||
"md5": "416e3f0e2a701bb8b4a0856a29dba597",
|
||||
"targetVersionCode": 1,
|
||||
"abi": "arm64-v8a"
|
||||
}
|
||||
|
||||
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 内"上报"页面提交问题反馈
|
||||
- **技术支持**:请联系管理员获取联系方式
|
||||
@@ -1,6 +1,7 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
source 'https://github.com/volcengine/volcengine-specs.git'error: Classes can only extend other classes. (extends_non_class at [maibu_satabot_v2] lib\features\v2\device_list\domain\entities\video_stream_entity.dart:2)error: Classes can only extend other classes. (extends_non_class at [maibu_satabot_v2] lib\features\v2\device_list\domain\entities\video_stream_entity.dart:2)you
|
||||
platform :ios, '13.0'
|
||||
source 'https://github.com/volcengine/volcengine-specs.git'
|
||||
source 'https://cdn.cocoapods.org/'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
121
ios/Podfile.lock
121
ios/Podfile.lock
@@ -1,7 +1,58 @@
|
||||
PODS:
|
||||
- agora_rtc_engine (6.5.3):
|
||||
- AgoraIrisRTC_iOS (= 4.5.2-build.1)
|
||||
- AgoraRtcEngine_iOS (= 4.5.2)
|
||||
- Flutter
|
||||
- AgoraInfra_iOS (1.2.13.1)
|
||||
- AgoraIrisRTC_iOS (4.5.2-build.1)
|
||||
- AgoraRtcEngine_iOS (4.5.2):
|
||||
- AgoraRtcEngine_iOS/AIAEC (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AIAECLL (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINS (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINSLL (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AudioBeauty (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/ClearVision (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/ContentInspect (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceCapture (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceDetection (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/LipSync (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/ReplayKit (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/RtcBasic (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/SpatialAudio (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecDec (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecDec (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecEnc (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VirtualBackground (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/VQA (= 4.5.2)
|
||||
- AgoraRtcEngine_iOS/AIAEC (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AIAECLL (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINS (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AINSLL (4.5.2)
|
||||
- AgoraRtcEngine_iOS/AudioBeauty (4.5.2)
|
||||
- AgoraRtcEngine_iOS/ClearVision (4.5.2)
|
||||
- AgoraRtcEngine_iOS/ContentInspect (4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceCapture (4.5.2)
|
||||
- AgoraRtcEngine_iOS/FaceDetection (4.5.2)
|
||||
- AgoraRtcEngine_iOS/LipSync (4.5.2)
|
||||
- AgoraRtcEngine_iOS/ReplayKit (4.5.2)
|
||||
- AgoraRtcEngine_iOS/RtcBasic (4.5.2):
|
||||
- AgoraInfra_iOS (= 1.2.13.1)
|
||||
- AgoraRtcEngine_iOS/SpatialAudio (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecDec (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoAv1CodecEnc (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecDec (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VideoCodecEnc (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VirtualBackground (4.5.2)
|
||||
- AgoraRtcEngine_iOS/VQA (4.5.2)
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_blue_plus_darwin (0.0.2):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- flutter_webrtc (1.4.0):
|
||||
- Flutter
|
||||
- WebRTC-SDK (= 144.7559.01)
|
||||
@@ -44,6 +95,8 @@ PODS:
|
||||
- GTMSessionFetcher/Core (2.3.0)
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- iris_method_channel (0.0.1):
|
||||
- Flutter
|
||||
- isar_community_flutter_libs (1.0.0):
|
||||
- Flutter
|
||||
- MLImage (1.0.0-beta4)
|
||||
@@ -72,6 +125,8 @@ PODS:
|
||||
- nanopb/encode (= 2.30910.0)
|
||||
- nanopb/decode (2.30910.0)
|
||||
- nanopb/encode (2.30910.0)
|
||||
- open_file_ios (1.0.3):
|
||||
- Flutter
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- permission_handler_apple (9.3.0):
|
||||
@@ -89,29 +144,67 @@ PODS:
|
||||
- Flutter
|
||||
- vibration (3.0.0):
|
||||
- Flutter
|
||||
- video_player_avfoundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- volc_engine_rtc (3.60.4):
|
||||
- Flutter
|
||||
- VolcApiEngine (= 1.7.0)
|
||||
- VolcEngineRTC (= 3.60.103.2100)
|
||||
- VolcApiEngine (1.7.0)
|
||||
- VolcEngineRTC (3.60.103.2100):
|
||||
- VolcEngineRTC/BMF (= 3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (= 3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSharpenExtension (= 3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSRExtension (= 3.60.103.2100)
|
||||
- VolcEngineRTC/Core (= 3.60.103.2100)
|
||||
- VolcEngineRTC/CVByteNN (= 3.60.103.2100)
|
||||
- VolcEngineRTC/RealXBase (= 3.60.103.2100)
|
||||
- VolcEngineRTC/RTCFFmpeg (= 3.60.103.2100)
|
||||
- VolcEngineRTC/BMF (3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoDenoiseExtension (3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSharpenExtension (3.60.103.2100)
|
||||
- VolcEngineRTC/ByteRTCVideoSRExtension (3.60.103.2100)
|
||||
- VolcEngineRTC/Core (3.60.103.2100)
|
||||
- VolcEngineRTC/CVByteNN (3.60.103.2100)
|
||||
- VolcEngineRTC/RealXBase (3.60.103.2100)
|
||||
- VolcEngineRTC/RTCFFmpeg (3.60.103.2100)
|
||||
- WebRTC-SDK (144.7559.01)
|
||||
- webview_flutter_wkwebview (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
|
||||
DEPENDENCIES:
|
||||
- agora_rtc_engine (from `.symlinks/plugins/agora_rtc_engine/ios`)
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`)
|
||||
- flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`)
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- iris_method_channel (from `.symlinks/plugins/iris_method_channel/ios`)
|
||||
- isar_community_flutter_libs (from `.symlinks/plugins/isar_community_flutter_libs/ios`)
|
||||
- mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`)
|
||||
- open_file_ios (from `.symlinks/plugins/open_file_ios/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- vibration (from `.symlinks/plugins/vibration/ios`)
|
||||
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`)
|
||||
- volc_engine_rtc (from `.symlinks/plugins/volc_engine_rtc/ios`)
|
||||
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||
|
||||
SPEC REPOS:
|
||||
https://github.com/volcengine/volcengine-specs.git:
|
||||
- VolcApiEngine
|
||||
- VolcEngineRTC
|
||||
trunk:
|
||||
- AgoraInfra_iOS
|
||||
- AgoraIrisRTC_iOS
|
||||
- AgoraRtcEngine_iOS
|
||||
- GoogleDataTransport
|
||||
- GoogleMLKit
|
||||
- GoogleToolboxForMac
|
||||
@@ -128,20 +221,30 @@ SPEC REPOS:
|
||||
- WebRTC-SDK
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
agora_rtc_engine:
|
||||
:path: ".symlinks/plugins/agora_rtc_engine/ios"
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_blue_plus_darwin:
|
||||
:path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin"
|
||||
flutter_webrtc:
|
||||
:path: ".symlinks/plugins/flutter_webrtc/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/darwin"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
iris_method_channel:
|
||||
:path: ".symlinks/plugins/iris_method_channel/ios"
|
||||
isar_community_flutter_libs:
|
||||
:path: ".symlinks/plugins/isar_community_flutter_libs/ios"
|
||||
mobile_scanner:
|
||||
:path: ".symlinks/plugins/mobile_scanner/ios"
|
||||
open_file_ios:
|
||||
:path: ".symlinks/plugins/open_file_ios/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
permission_handler_apple:
|
||||
@@ -154,12 +257,22 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
vibration:
|
||||
:path: ".symlinks/plugins/vibration/ios"
|
||||
video_player_avfoundation:
|
||||
:path: ".symlinks/plugins/video_player_avfoundation/darwin"
|
||||
volc_engine_rtc:
|
||||
:path: ".symlinks/plugins/volc_engine_rtc/ios"
|
||||
webview_flutter_wkwebview:
|
||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
agora_rtc_engine: 0c7d50312967c4dc31c3c45e50589ce48f57e08a
|
||||
AgoraInfra_iOS: 3691b2b277a1712a35ae96de25af319de0d73d08
|
||||
AgoraIrisRTC_iOS: eab58c126439adf5ec99632828a558ea216860da
|
||||
AgoraRtcEngine_iOS: 97e2398a2addda9057815a2a583a658e36796ff6
|
||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
|
||||
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
|
||||
flutter_blue_plus_darwin: ee2377ea51a6c9e481f2ed76ec597717ef5d287d
|
||||
flutter_webrtc: ec91d94b484ad49cf191ef93413f64a40ffd3b4c
|
||||
geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e
|
||||
GoogleDataTransport: 6c09b596d841063d76d4288cc2d2f42cc36e1e2a
|
||||
@@ -169,6 +282,7 @@ SPEC CHECKSUMS:
|
||||
GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe
|
||||
GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2
|
||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||
iris_method_channel: 7d661cf3259b3009ae423508470dbeb9374446ee
|
||||
isar_community_flutter_libs: bede843185a61a05ff364a05c9b23209523f7e0d
|
||||
MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b
|
||||
MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505
|
||||
@@ -176,6 +290,7 @@ SPEC CHECKSUMS:
|
||||
MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49
|
||||
mobile_scanner: b67191637a5ea7ba15652ca208069d2bae1900ab
|
||||
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||
open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
@@ -184,9 +299,13 @@ SPEC CHECKSUMS:
|
||||
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
|
||||
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
|
||||
vibration: ca8104a8875b9c493e15b21b04e456befd0ff6eb
|
||||
video_player_avfoundation: 3453f792138786248960ca029747fcd9f318ef52
|
||||
volc_engine_rtc: 1bc697fba3c7a2f6513a18ec2abfb629188ce6ff
|
||||
VolcApiEngine: e1f8b47ec5ef64bf6b48e3600be132c42643f8f0
|
||||
VolcEngineRTC: 889b981b13621208b25942263f0fd5817c93b9bc
|
||||
WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
|
||||
webview_flutter_wkwebview: 8ebf4fded22593026f7dbff1fbff31ea98573c8d
|
||||
|
||||
PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
|
||||
PODFILE CHECKSUM: 2da7455b7ebb4d9567497e4f758b13986b3bb736
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -494,14 +494,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
||||
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -519,7 +519,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
@@ -537,7 +537,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -553,7 +553,7 @@
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.RunnerTests;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
|
||||
@@ -680,14 +680,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
||||
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -706,14 +706,14 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
|
||||
DEVELOPMENT_TEAM = R5KTW5WPS3;
|
||||
DEVELOPMENT_TEAM = 93X3AN3P79;
|
||||
ENABLE_BITCODE = NO;
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.maibu.maibuSatabotV2.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
|
||||
@@ -83,15 +83,10 @@
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>需要使用麦克风</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<!-- 蓝牙权限 -->
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接设备</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接设备</string>
|
||||
</dict>
|
||||
</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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
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';
|
||||
@@ -15,6 +16,9 @@ class BleManager {
|
||||
BluetoothCharacteristic? _writeCharacteristic;
|
||||
BluetoothCharacteristic? _readCharacteristic;
|
||||
|
||||
/// 🔥 已连接设备的名称(连接时从扫描结果或设备属性保存,断开时清空)
|
||||
String? _connectedDeviceName;
|
||||
|
||||
/// 有状态的协议解析器(支持 BLE 分片)
|
||||
final ProtocolParser _parser = ProtocolParser();
|
||||
|
||||
@@ -32,6 +36,7 @@ class BleManager {
|
||||
StreamSubscription<List<int>>? _readSubscription;
|
||||
StreamSubscription<BluetoothAdapterState>? _adapterStateSubscription;
|
||||
StreamSubscription<BluetoothConnectionState>? _deviceConnectionSubscription;
|
||||
StreamSubscription<int>? _mtuSubscription;
|
||||
|
||||
/// 防止异步竞态:stopScan() 后 in-flight 的 startScan() 不应生效
|
||||
int _scanGen = 0;
|
||||
@@ -50,11 +55,19 @@ class BleManager {
|
||||
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;
|
||||
|
||||
@@ -67,47 +80,46 @@ class BleManager {
|
||||
}
|
||||
|
||||
Future<bool> requestPermissions() async {
|
||||
final connStatus = await Permission.bluetoothConnect.status;
|
||||
developer.log(
|
||||
'[BLE] bluetoothConnect status: $connStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (connStatus.isDenied) {
|
||||
developer.log('[BLE] requesting bluetoothConnect...', name: 'BleManager');
|
||||
await Permission.bluetoothConnect.request();
|
||||
}
|
||||
|
||||
final scanStatus = await Permission.bluetoothScan.status;
|
||||
developer.log(
|
||||
'[BLE] bluetoothScan status: $scanStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (scanStatus.isDenied) {
|
||||
developer.log('[BLE] requesting bluetoothScan...', name: 'BleManager');
|
||||
await Permission.bluetoothScan.request();
|
||||
}
|
||||
|
||||
final locStatus = await Permission.locationWhenInUse.status;
|
||||
developer.log(
|
||||
'[BLE] locationWhenInUse status: $locStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
if (locStatus.isDenied) {
|
||||
if (Platform.isAndroid) {
|
||||
final connStatus = await Permission.bluetoothConnect.status;
|
||||
developer.log(
|
||||
'[BLE] requesting locationWhenInUse...',
|
||||
'[BLE] bluetoothConnect status: $connStatus',
|
||||
name: 'BleManager',
|
||||
);
|
||||
await Permission.locationWhenInUse.request();
|
||||
}
|
||||
if (connStatus.isDenied) {
|
||||
await Permission.bluetoothConnect.request();
|
||||
}
|
||||
|
||||
final allGranted =
|
||||
await Permission.bluetoothConnect.isGranted &&
|
||||
await Permission.bluetoothScan.isGranted;
|
||||
developer.log(
|
||||
'[BLE] all permissions granted: $allGranted',
|
||||
name: 'BleManager',
|
||||
);
|
||||
return allGranted;
|
||||
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 {
|
||||
@@ -168,15 +180,17 @@ class BleManager {
|
||||
return;
|
||||
}
|
||||
|
||||
final locEnabled = await _checkLocationService();
|
||||
if (myGen != _scanGen) return;
|
||||
if (!locEnabled) {
|
||||
developer.log(
|
||||
'[BLE] Location service OFF, directing to settings',
|
||||
name: 'BleManager',
|
||||
);
|
||||
await _openLocationSettings();
|
||||
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();
|
||||
@@ -238,6 +252,18 @@ class BleManager {
|
||||
|
||||
/// 连接设备,返回 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);
|
||||
@@ -264,14 +290,8 @@ class BleManager {
|
||||
});
|
||||
_connectionController.add(device);
|
||||
await _discoverServices(device);
|
||||
// 确认协商后的 MTU
|
||||
try {
|
||||
final mtu = await device.requestMtu(512);
|
||||
_negotiatedMtu = mtu;
|
||||
developer.log('[BLE] MTU: $mtu', name: 'BleManager');
|
||||
} catch (e) {
|
||||
developer.log('[BLE] requestMtu failed: $e', name: 'BleManager');
|
||||
}
|
||||
// 确认协商后的 MTU(Android 主动请求;iOS 由 CoreBluetooth 自动协商)
|
||||
await _setupMtu(device);
|
||||
return null; // 成功
|
||||
} catch (e) {
|
||||
// 连接失败,清除连接中状态
|
||||
@@ -285,14 +305,59 @@ class BleManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 平台差异化的 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);
|
||||
// 清空协议解析器缓冲区
|
||||
@@ -405,7 +470,7 @@ class BleManager {
|
||||
final totalChunks = (frame.length + maxWriteLen - 1) ~/ maxWriteLen;
|
||||
if (totalChunks > 1) {
|
||||
developer.log(
|
||||
'[BLE] 🔀 分${totalChunks}片写入 (MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
|
||||
'[BLE] 🔀 分${totalChunks}片写入 (platform=${Platform.isIOS ? "iOS" : "Android"}, MTU=$_negotiatedMtu, 每片≤${maxWriteLen}B)',
|
||||
name: 'BleManager',
|
||||
);
|
||||
}
|
||||
@@ -422,6 +487,13 @@ class 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');
|
||||
}
|
||||
@@ -440,6 +512,7 @@ class BleManager {
|
||||
_readSubscription?.cancel();
|
||||
_adapterStateSubscription?.cancel();
|
||||
_deviceConnectionSubscription?.cancel();
|
||||
_mtuSubscription?.cancel();
|
||||
_scanController.close();
|
||||
_packetController.close();
|
||||
_connectionController.close();
|
||||
|
||||
@@ -68,27 +68,38 @@ class BleProtocolDecoder {
|
||||
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 {
|
||||
final text = utf8.decode(data);
|
||||
// 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(',');
|
||||
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
|
||||
}
|
||||
} catch (e) {
|
||||
try {
|
||||
final text = latin1.decode(data);
|
||||
if (text.contains(',')) {
|
||||
final parts = text.split(',');
|
||||
return BleDecodedResult(
|
||||
fields: _parseStatusFields(parts),
|
||||
rawHex: hex,
|
||||
// 诊断:仅当控制模式槽位(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',
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
return BleDecodedResult(fields: _parseStatusFields(parts), rawHex: hex);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
if (data.isNotEmpty) {
|
||||
final status = data[0];
|
||||
if (body.isNotEmpty) {
|
||||
final status = body[0];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '状态码',
|
||||
@@ -96,8 +107,8 @@ class BleProtocolDecoder {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.length >= 2) {
|
||||
final mode = data[1];
|
||||
if (body.length >= 2) {
|
||||
final mode = body[1];
|
||||
const modes = {0x00: '待机', 0x01: '遥控', 0x02: '自动', 0x03: '急停'};
|
||||
fields.add(
|
||||
BleField(
|
||||
@@ -106,10 +117,10 @@ class BleProtocolDecoder {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.length >= 3)
|
||||
fields.add(BleField(label: '电量', value: '${data[2]}%'));
|
||||
if (data.length >= 4) {
|
||||
final fault = data[3];
|
||||
if (body.length >= 3)
|
||||
fields.add(BleField(label: '电量', value: '${body[2]}%'));
|
||||
if (body.length >= 4) {
|
||||
final fault = body[3];
|
||||
fields.add(
|
||||
BleField(
|
||||
label: '故障状态',
|
||||
@@ -119,13 +130,13 @@ class BleProtocolDecoder {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.length >= 6) {
|
||||
final speed = (data[5] << 8) | data[4];
|
||||
if (body.length >= 6) {
|
||||
final speed = (body[5] << 8) | body[4];
|
||||
fields.add(BleField(label: '速度', value: '$speed'));
|
||||
}
|
||||
if (data.length > 8) {
|
||||
if (body.length > 8) {
|
||||
try {
|
||||
final text = String.fromCharCodes(data.sublist(8));
|
||||
final text = String.fromCharCodes(body.sublist(8));
|
||||
if (text.isNotEmpty && !text.contains('\x00')) {
|
||||
fields.add(BleField(label: '附加', value: text));
|
||||
}
|
||||
@@ -141,24 +152,32 @@ class BleProtocolDecoder {
|
||||
parts.add('');
|
||||
}
|
||||
|
||||
// 协议定义 控制模式: 0无控制 1本地遥控 2蓝牙 3TCP 4其他接口;
|
||||
// 先按整数解析(容忍前后空格),有映射的一律显示对应文字;
|
||||
// 超出协议范围的码(如固件扩展值)显示为 未知(n),避免被误当成有效模式
|
||||
String controlModeText(String v) {
|
||||
return switch (v) {
|
||||
'0' => '待机',
|
||||
'1' => '遥控',
|
||||
'2' => '自动',
|
||||
'3' => '急停',
|
||||
_ => v.isNotEmpty ? 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 => '单点定位',
|
||||
2 => '差分定位',
|
||||
4 => '固定解',
|
||||
5 => '浮点解',
|
||||
1 => 'GPS 单点定位',
|
||||
2 => 'DGPS 差分/SBAS',
|
||||
4 => 'RTK 固定解',
|
||||
5 => 'RTK 浮点解',
|
||||
7 => '手动输入模式',
|
||||
_ => v.isNotEmpty ? v : '--',
|
||||
};
|
||||
}
|
||||
@@ -173,11 +192,14 @@ class BleProtocolDecoder {
|
||||
}
|
||||
|
||||
String obstacleText(String v) {
|
||||
final o = int.tryParse(v) ?? -1;
|
||||
return switch (o) {
|
||||
// 障碍物标志位为单个数字 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.isNotEmpty ? v : '--',
|
||||
_ => v,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,10 @@ class HttpApiConsts {
|
||||
// 获取全部设备列表(用于蓝牙绑定匹配)
|
||||
static String get getDeviceList => "$baseUrl/iot/device/getDeviceList";
|
||||
|
||||
// 根据拆分码(蓝牙设备名/时间戳)精确查询设备(用于蓝牙绑定匹配)
|
||||
static String get getDeviceBySpiltCode =>
|
||||
"$baseUrl/iot/device/getDeviceBySpiltCode";
|
||||
|
||||
// 获取机器人列表
|
||||
static String get getRobotList => "$baseUrl/iot/device/getSiteList";
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ import '../../features/v2/device_list/domain/repositories/device_repository.dart
|
||||
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';
|
||||
@@ -380,6 +381,9 @@ Future<void> init() async {
|
||||
sl.registerLazySingleton<GetAllDevicesUseCase>(
|
||||
() => GetAllDevicesUseCase(repository: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<GetDeviceBySpiltCodeUseCase>(
|
||||
() => GetDeviceBySpiltCodeUseCase(repository: sl()),
|
||||
);
|
||||
sl.registerFactory<device_v2_bloc.DeviceStatusBloc>(
|
||||
() => device_v2_bloc.DeviceStatusBloc(sl()),
|
||||
);
|
||||
|
||||
6
lib/core/env/env_config.dart
vendored
6
lib/core/env/env_config.dart
vendored
@@ -1,7 +1,7 @@
|
||||
class EnvConfig {
|
||||
static const String environment = String.fromEnvironment(
|
||||
'ENV',
|
||||
defaultValue: 'test',
|
||||
defaultValue: 'prod',
|
||||
);
|
||||
|
||||
static String get sentryDsn {
|
||||
@@ -24,4 +24,8 @@ class EnvConfig {
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import '../../../../env/env_config.dart';
|
||||
|
||||
/// MQTT 传输协议类型
|
||||
enum MqttProtocol {
|
||||
/// 纯 TCP 连接(原生 MQTT)
|
||||
@@ -67,11 +69,11 @@ class MqttConfig extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// 任务状态消息(TCP MQTT)
|
||||
/// 任务状态消息(TCP MQTT)——端口随环境切换(prod=1883 / test=59007)
|
||||
factory MqttConfig.taskMessage() {
|
||||
return const MqttConfig(
|
||||
host: '1.95.137.212',
|
||||
port: 1883,
|
||||
return MqttConfig(
|
||||
host: EnvConfig.serverHost,
|
||||
port: EnvConfig.mqttPort,
|
||||
username: 'maibu',
|
||||
password: 'jsmbzn520',
|
||||
protocol: MqttProtocol.tcp,
|
||||
@@ -81,12 +83,12 @@ class MqttConfig extends Equatable {
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔥 割草机机器状态(TCP MQTT)——独立连接,地址 1.95.137.212:59007
|
||||
/// 🔥 割草机机器状态(TCP MQTT)——独立连接,端口随环境切换(prod=1883 / test=59007)
|
||||
/// 仅用于 mower/{sn}/property/realtime/post 和 mower/{sn}/property/location/post
|
||||
factory MqttConfig.mowerRealtime() {
|
||||
return const MqttConfig(
|
||||
host: '1.95.137.212',
|
||||
port: 59007,
|
||||
return MqttConfig(
|
||||
host: EnvConfig.serverHost,
|
||||
port: EnvConfig.mqttPort,
|
||||
username: 'maibu',
|
||||
password: 'jsmbzn520',
|
||||
protocol: MqttProtocol.tcp,
|
||||
|
||||
@@ -30,13 +30,19 @@ GoRouter createRouter(AuthCubit authCubit) {
|
||||
|
||||
return GoRouter(
|
||||
navigatorKey: navigatorKey, // 🔥 绑定全局 navigatorKey,使异地登录弹窗能获取 context
|
||||
initialLocation: RoutePaths.login,
|
||||
initialLocation: RoutePaths.splash,
|
||||
refreshListenable: GoRouterRefreshStream(authCubit.stream),
|
||||
redirect: (context, state) {
|
||||
final loggedIn = authCubit.state is AuthAuthenticated;
|
||||
final onSplash = state.matchedLocation == RoutePaths.splash;
|
||||
final loggingIn = state.matchedLocation == RoutePaths.login;
|
||||
final registering = state.matchedLocation == RoutePaths.register;
|
||||
|
||||
// 启动屏:交给 SplashPage 自己根据就绪信号跳转,路由层不干预
|
||||
if (onSplash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果未登录,只允许访问 login 和 register 页面
|
||||
if (!loggedIn && !loggingIn && !registering) {
|
||||
return RoutePaths.login;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class RoutePaths {
|
||||
/// 启动页(App 第一个路由)
|
||||
static const splash = '/splash';
|
||||
|
||||
/// 账号相关页面
|
||||
static const login = '/auth/login';
|
||||
static const register = '/auth/register';
|
||||
|
||||
@@ -18,19 +18,47 @@ class LoginPage extends StatefulWidget {
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
class _LoginPageState extends State<LoginPage> with SingleTickerProviderStateMixin {
|
||||
final _userCtrl = TextEditingController();
|
||||
final _pwdCtrl = TextEditingController();
|
||||
bool _isAgreed = false; // 协议勾选状态
|
||||
bool _obscurePwd = true; // 密码可见性
|
||||
|
||||
// 进场动画控制器
|
||||
late final AnimationController _entranceController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_entranceController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
);
|
||||
// 等首帧渲染 + 图片解码完成后再播放,避开启动初始化争抢导致的掉帧
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
Future.delayed(const Duration(milliseconds: 120), () {
|
||||
if (mounted) _entranceController.forward();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_entranceController.dispose();
|
||||
_userCtrl.dispose();
|
||||
_pwdCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 生成一个错峰区间的动画(0.0 ~ 1.0)
|
||||
Animation<double> _interval(double begin, double end) {
|
||||
return CurvedAnimation(
|
||||
parent: _entranceController,
|
||||
curve: Interval(begin, end, curve: Curves.easeOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -64,130 +92,94 @@ class _LoginPageState extends State<LoginPage> {
|
||||
// 顶部区域:图片 + 认证信息 + 标题(压缩空间)
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// 顶部居中图片和认证信息
|
||||
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,
|
||||
// ① 顶部居中图片和认证信息(缩放淡入)
|
||||
_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),
|
||||
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: _userCtrl,
|
||||
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
|
||||
cursorColor: context.appColors.textPrimary,
|
||||
decoration: _inputDecoration(hint: "请输入用户名"),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 密码输入框
|
||||
_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),
|
||||
|
||||
// 协议勾选区域
|
||||
_buildProtocolSection(),
|
||||
|
||||
const SizedBox(height: 30),
|
||||
|
||||
// 底部区域:登录按钮 + 注册链接
|
||||
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),
|
||||
|
||||
Center(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
// ② 标题区(上滑淡入)
|
||||
_Entrance(
|
||||
animation: _interval(0.10, 0.45),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"没有账号吗?",
|
||||
style: TextStyle(fontSize: 14, color: context.appColors.textTertiary),
|
||||
"账号登录",
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: context.appColors.textPrimary),
|
||||
),
|
||||
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: 8),
|
||||
Text("请填写以下信息以验证身份", style: TextStyle(fontSize: 15, color: context.appColors.textTertiary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ③ 表单区域
|
||||
// 账号输入框(上滑淡入)
|
||||
_Entrance(
|
||||
animation: _interval(0.20, 0.55),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInputLabel("账号"),
|
||||
TextField(
|
||||
controller: _userCtrl,
|
||||
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
|
||||
cursorColor: context.appColors.textPrimary,
|
||||
decoration: _inputDecoration(hint: "请输入用户名"),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 密码输入框(上滑淡入)
|
||||
_Entrance(
|
||||
animation: _interval(0.28, 0.63),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildInputLabel("密码"),
|
||||
TextField(
|
||||
controller: _pwdCtrl,
|
||||
obscureText: _obscurePwd,
|
||||
style: TextStyle(fontSize: 14, color: context.appColors.textPrimary),
|
||||
cursorColor: context.appColors.textPrimary,
|
||||
decoration: _inputDecoration(
|
||||
hint: "请输入密码",
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(_obscurePwd ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: context.appColors.textTertiary, size: 20),
|
||||
onPressed: () => setState(() => _obscurePwd = !_obscurePwd),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -195,6 +187,83 @@ class _LoginPageState extends State<LoginPage> {
|
||||
),
|
||||
),
|
||||
|
||||
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),
|
||||
],
|
||||
),
|
||||
@@ -320,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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
114
lib/features/auth/presentation/pages/splash_page.dart
Normal file
114
lib/features/auth/presentation/pages/splash_page.dart
Normal file
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../../core/router/route_paths.dart';
|
||||
import '../bloc/auth_cubit.dart';
|
||||
import '../bloc/auth_state.dart';
|
||||
|
||||
/// 启动页:展示 App Logo 并循环播放「呼吸缩放」动画。
|
||||
///
|
||||
/// 该页面作为 App 的第一个路由,衔接原生启动屏(静态 Logo)。
|
||||
/// 动画会一直循环,直到 [AuthCubit.appStarted] 完成、登录态确定
|
||||
/// ([AuthAuthenticated] / [AuthUnauthenticated])这一「就绪信号」到来,
|
||||
/// 才停止动画并跳转到首页或登录页。
|
||||
class SplashPage extends StatefulWidget {
|
||||
const SplashPage({super.key});
|
||||
|
||||
@override
|
||||
State<SplashPage> createState() => _SplashPageState();
|
||||
}
|
||||
|
||||
class _SplashPageState extends State<SplashPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
/// 呼吸动画控制器(循环往复)
|
||||
late final AnimationController _breathController;
|
||||
|
||||
/// 就绪信号是否已到达
|
||||
bool _ready = false;
|
||||
|
||||
/// 就绪后的目标登录态
|
||||
AuthState? _resolvedState;
|
||||
|
||||
/// 防止重复跳转
|
||||
bool _navigated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_breathController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
// 处理进入 splash 时登录态已经确定的情况(appStarted 先于首帧完成)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _onReady(context.read<AuthCubit>().state);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_breathController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 记录就绪信号,并尝试跳转
|
||||
void _onReady(AuthState state) {
|
||||
if (state is AuthAuthenticated || state is AuthUnauthenticated) {
|
||||
_resolvedState = state;
|
||||
_ready = true;
|
||||
_maybeNavigate();
|
||||
}
|
||||
// AuthInitial:尚未就绪,继续停留在启动页播放动画
|
||||
}
|
||||
|
||||
/// 就绪信号到达即离开启动页,不额外增加打开时间
|
||||
void _maybeNavigate() {
|
||||
if (_navigated || !mounted) return;
|
||||
if (!_ready || _resolvedState == null) return;
|
||||
|
||||
_navigated = true;
|
||||
_breathController.stop();
|
||||
if (_resolvedState is AuthAuthenticated) {
|
||||
context.go(RoutePaths.home);
|
||||
} else {
|
||||
context.go(RoutePaths.login);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<AuthCubit, AuthState>(
|
||||
listenWhen: (prev, cur) =>
|
||||
cur is AuthAuthenticated || cur is AuthUnauthenticated,
|
||||
listener: (context, state) => _onReady(state),
|
||||
child: Scaffold(
|
||||
// 与原生启动屏白底保持一致,避免切换时闪色
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _breathController,
|
||||
builder: (context, child) {
|
||||
// easeInOut 让呼吸更柔和;scale 从 1.0 起始以贴合原生屏尺寸
|
||||
final t = Curves.easeInOut.transform(_breathController.value);
|
||||
final scale = 1.0 - 0.22 * t; // 1.0 -> 0.78,幅度更大更明显
|
||||
final opacity = 1.0 - 0.55 * t; // 1.0 -> 0.45
|
||||
return Opacity(
|
||||
opacity: opacity,
|
||||
child: Transform.scale(scale: scale, child: child),
|
||||
);
|
||||
},
|
||||
// 透明底应用图标,避免白底上出现浅灰"阴影"底块
|
||||
child: Image.asset(
|
||||
'assets/images/app_logo_gray_transparent.png',
|
||||
width: 180,
|
||||
height: 180,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,15 @@ import 'package:maibu_satabot_v2/core/router/route_paths.dart';
|
||||
import 'package:maibu_satabot_v2/features/auth/presentation/pages/login_page.dart';
|
||||
|
||||
import '../pages/register_page.dart';
|
||||
import '../pages/splash_page.dart';
|
||||
|
||||
class AuthRoutes {
|
||||
// 返回一个 List<RouteBase>
|
||||
static List<RouteBase> routes = [
|
||||
GoRoute(
|
||||
path: RoutePaths.splash,
|
||||
builder: (context, state) => const SplashPage(),
|
||||
),
|
||||
GoRoute(
|
||||
path: RoutePaths.login,
|
||||
builder: (context, state) => const LoginPage(),
|
||||
|
||||
@@ -95,10 +95,9 @@ class DeviceTaskDatasourceImpl implements DeviceTaskDatasource {
|
||||
}) async {
|
||||
final url = '${HttpApiConsts.baseUrl}/iot/deviceTask/cancelTask';
|
||||
final body = {
|
||||
'deviceId': deviceId,
|
||||
'taskId': taskId,
|
||||
'orgId': orgId,
|
||||
'siteId': siteId,
|
||||
'orgId': orgId,
|
||||
'forceCancel': forceCancel,
|
||||
};
|
||||
_logger.logWithLevel('[cancelTask] 请求: POST $url');
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../../../../core/consts/http_api_consts.dart';
|
||||
import '../../../../core/di/injection.dart';
|
||||
import '../../../../core/storage/user_storage.dart';
|
||||
import '../../domain/repositories/path_repository.dart';
|
||||
@@ -47,7 +48,7 @@ class PathRepositoryImpl implements PathRepository {
|
||||
required String userId,
|
||||
required String jsonData,
|
||||
}) async {
|
||||
final url = Uri.parse('http://1.95.137.212:59003/iot/workRecord/add');
|
||||
final url = Uri.parse('${HttpApiConsts.baseUrl}/iot/workRecord/add');
|
||||
final headers = {'Content-Type': 'application/json'};
|
||||
final body = jsonEncode({
|
||||
'workName': workName,
|
||||
@@ -74,7 +75,7 @@ class PathRepositoryImpl implements PathRepository {
|
||||
}) async {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final url = Uri.parse(
|
||||
'http://1.95.137.212:59003/iot/workRecord/selectByUserId',
|
||||
'${HttpApiConsts.baseUrl}/iot/workRecord/selectByUserId',
|
||||
).replace(queryParameters: {'userId': userId, '_t': timestamp.toString()});
|
||||
|
||||
try {
|
||||
@@ -103,7 +104,7 @@ class PathRepositoryImpl implements PathRepository {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final url =
|
||||
Uri.parse(
|
||||
'http://1.95.137.212:59003/iot/workRecord/deleteByWorkName',
|
||||
'${HttpApiConsts.baseUrl}/iot/workRecord/deleteByWorkName',
|
||||
).replace(
|
||||
queryParameters: {'workName': workName, '_t': timestamp.toString()},
|
||||
);
|
||||
@@ -132,7 +133,7 @@ class PathRepositoryImpl implements PathRepository {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final url =
|
||||
Uri.parse(
|
||||
'http://1.95.137.212:59003/iot/workRecord/selectByWorkName',
|
||||
'${HttpApiConsts.baseUrl}/iot/workRecord/selectByWorkName',
|
||||
).replace(
|
||||
queryParameters: {'workName': workName, '_t': timestamp.toString()},
|
||||
);
|
||||
@@ -268,7 +269,7 @@ class PathRepositoryImpl implements PathRepository {
|
||||
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||
final url =
|
||||
Uri.parse(
|
||||
'http://1.95.137.212:59003/iot/workRecord/selectBySiteId',
|
||||
'${HttpApiConsts.baseUrl}/iot/workRecord/selectBySiteId',
|
||||
).replace(
|
||||
queryParameters: {
|
||||
'siteId': siteId.toString(),
|
||||
@@ -508,7 +509,7 @@ class PathRepositoryImpl implements PathRepository {
|
||||
}
|
||||
|
||||
/// 鍒涘缓璁惧<E79281>浠诲姟锛堥€氳繃鎺ュ彛鎵ц<E98EB5>浣滀笟锛?
|
||||
/// 鎺ュ彛鍦板潃: http://1.95.137.212:59003/iot/deviceTask/createDeviceTask
|
||||
/// 鎺ュ彛鍦板潃: ${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask
|
||||
/// 鍏ュ弬: {"deviceId":"...","routeId":76,"siteId":22,"orgId":5}
|
||||
/// 杩斿洖: 鍒涘缓鎴愬姛鐨勪换鍔<E68DA2>D
|
||||
@override
|
||||
@@ -531,7 +532,7 @@ class PathRepositoryImpl implements PathRepository {
|
||||
|
||||
try {
|
||||
final response = await dio.post(
|
||||
'http://1.95.137.212:59003/iot/deviceTask/createDeviceTask',
|
||||
'${HttpApiConsts.baseUrl}/iot/deviceTask/createDeviceTask',
|
||||
data: body,
|
||||
);
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
taskPool: taskList,
|
||||
currentTask: currentTask,
|
||||
currentTaskId: currentTask?.id,
|
||||
// 🔥 活跃任务为空时必须显式清空,否则 copyWith 会保留旧任务导致卡片一直显示
|
||||
clearCurrentTask: currentTask == null,
|
||||
clearCurrentTaskId: currentTask == null,
|
||||
activeTasks: activeTasks,
|
||||
),
|
||||
);
|
||||
@@ -398,7 +401,7 @@ class DeviceTaskCubit extends Cubit<DeviceTaskState> {
|
||||
|
||||
/// 清除当前任务
|
||||
void clearCurrentTask() {
|
||||
emit(state.copyWith(currentTask: null, currentTaskId: null));
|
||||
emit(state.copyWith(clearCurrentTask: true, clearCurrentTaskId: true));
|
||||
_logger.logWithLevel('🧹 清除当前任务');
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ class DeviceTaskState extends Equatable {
|
||||
DeviceTaskState copyWith({
|
||||
List<DeviceTaskEntity>? taskPool,
|
||||
DeviceTaskEntity? currentTask,
|
||||
bool clearCurrentTask = false, // 🔥 显式清空 currentTask(copyWith 传 null 无法置空)
|
||||
int? currentTaskId,
|
||||
bool clearCurrentTaskId = false, // 🔥 显式清空 currentTaskId
|
||||
List<DeviceTaskEntity>? activeTasks, // 🔥 新增
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
@@ -41,8 +43,9 @@ class DeviceTaskState extends Equatable {
|
||||
}) {
|
||||
return DeviceTaskState(
|
||||
taskPool: taskPool ?? this.taskPool,
|
||||
currentTask: currentTask ?? this.currentTask,
|
||||
currentTaskId: currentTaskId ?? this.currentTaskId,
|
||||
currentTask: clearCurrentTask ? null : (currentTask ?? this.currentTask),
|
||||
currentTaskId:
|
||||
clearCurrentTaskId ? null : (currentTaskId ?? this.currentTaskId),
|
||||
activeTasks: activeTasks ?? this.activeTasks, // 🔥 新增
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage,
|
||||
|
||||
@@ -450,7 +450,7 @@ class DevicesCubit extends Cubit<DevicesState> {
|
||||
state.copyWith(
|
||||
isLoading: false,
|
||||
workRecords: state.workRecords
|
||||
?.where((record) => record != workName)
|
||||
?.where((record) => record['workName'] != workName)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
/// GeoServer 基地址(公司端)
|
||||
const String kGeoserverBase = 'http://1.95.137.212:7900/geoserver';
|
||||
|
||||
/// GeoServer 工作区名(后端 siteMapName 形如 workSpace:suzhou)
|
||||
const String kGeoWorkspace = 'workSpace';
|
||||
|
||||
/// WMS 瓦片网络超时。大图层(如 neimeng/zhongke)一屏十几片、GeoServer 实时渲染排队,
|
||||
/// 单片实际等待可能远超 30s;浏览器预览肯等所以最终能出图,App 若超时太短会集体放弃、
|
||||
/// 返回透明图导致“一直出不来”。故放宽到 90s,并配合下方并发闸门分批取图。
|
||||
const Duration kWmsTileTimeout = Duration(seconds: 90);
|
||||
|
||||
/// 各场站高清图层的真实范围(取自 GeoServer GetCapabilities,原生 EPSG:4326)
|
||||
/// key = 后端下发的 siteMapName(形如 workSpace:suzhou)
|
||||
final Map<String, LatLngBounds> kStationLayerBounds = {
|
||||
@@ -43,7 +58,8 @@ final Map<String, LatLngBounds> kStationLayerBounds = {
|
||||
/// 取某场站图层的范围中心点(用作进场默认定位);未知/为空返回 null
|
||||
LatLng? stationLayerCenter(String? siteMapName) {
|
||||
if (siteMapName == null) return null;
|
||||
final b = kStationLayerBounds[siteMapName];
|
||||
// 优先用动态拉取的范围(StationLayerRepo),其内部已含硬编码兜底
|
||||
final b = StationLayerRepo.instance.bounds[siteMapName];
|
||||
if (b == null) return null;
|
||||
return LatLng((b.south + b.north) / 2, (b.west + b.east) / 2);
|
||||
}
|
||||
@@ -123,7 +139,7 @@ class GeoWmsProvider extends TileProvider {
|
||||
'STYLES': '',
|
||||
},
|
||||
);
|
||||
return NetworkImage(url.toString());
|
||||
return CachedWmsImage(url.toString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,3 +220,376 @@ final List<TileLayer> kAllStationTileLayers = kStationLayerBounds.entries
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
/// 带磁盘缓存 + 超时的 WMS 瓦片图:
|
||||
/// 命中本地缓存直接解码(秒开);未命中则联网拉取(带超时),成功后写入缓存;
|
||||
/// 失败/超时返回 1×1 透明图,避免异常刷屏。解决 WMS 实时渲染“每次都慢”的问题。
|
||||
class CachedWmsImage extends ImageProvider<CachedWmsImage> {
|
||||
CachedWmsImage(this.url) : _gen = _generation;
|
||||
|
||||
final String url;
|
||||
|
||||
/// 构造时捕获的缓存代次,纳入 key 相等性与磁盘文件名。
|
||||
/// 手动刷新时 _generation++,旧瓦片(内存 key + 磁盘文件)立即全部失效,
|
||||
/// 只影响 WMS 瓦片、不动 ESRI 底图缓存。
|
||||
final int _gen;
|
||||
static int _generation = 0;
|
||||
|
||||
static Future<Directory>? _cacheDirFuture;
|
||||
|
||||
static Future<Directory> _cacheDir() {
|
||||
return _cacheDirFuture ??= () async {
|
||||
try {
|
||||
final base = await getTemporaryDirectory();
|
||||
final dir = Directory('${base.path}${Platform.pathSeparator}wms_tiles');
|
||||
if (!await dir.exists()) {
|
||||
await dir.create(recursive: true);
|
||||
}
|
||||
return dir;
|
||||
} catch (e) {
|
||||
// 初始化失败不要把“坏 Future”永久缓存下来(否则后续所有瓦片都卡在这一步集体失败、
|
||||
// 表现为图层全不出来),置空以便下次调用重试
|
||||
_cacheDirFuture = null;
|
||||
rethrow;
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
/// URL + 代次 → 稳定短文件名(FNV-1a 32 位 + 长度,降低碰撞;代次前缀用于失效旧缓存)
|
||||
static String _fileName(String url, int gen) {
|
||||
var h = 0x811c9dc5;
|
||||
for (final c in url.codeUnits) {
|
||||
h ^= c;
|
||||
h = (h * 0x01000193) & 0xFFFFFFFF;
|
||||
}
|
||||
return 'g${gen}_${h.toRadixString(16).padLeft(8, '0')}_${url.length}.png';
|
||||
}
|
||||
|
||||
/// 清空全部 WMS 瓦片缓存(磁盘文件 + 递增代次使内存 key 失效)。
|
||||
/// 后端更新了同名图层的影像内容后,调用它强制丢弃旧缓存、重新联网拉取最新图。
|
||||
static Future<void> clearTileCache() async {
|
||||
_generation++;
|
||||
try {
|
||||
final dir = await _cacheDir();
|
||||
if (await dir.exists()) {
|
||||
await for (final e in dir.list()) {
|
||||
try {
|
||||
await e.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
debugPrint('🧹 [WMS] 已清空瓦片磁盘缓存,代次 → $_generation');
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [WMS] 清缓存失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== WMS 并发闸门 =====
|
||||
// 大图层(如 neimeng/zhongke)一屏可达十几片瓦片,若同时发起会把 GeoServer 压到排队渲染,
|
||||
// 每片等待时间暴涨、集体超时 → 全部返回透明图,表现为“一直出不来”。限制并发分批取,
|
||||
// 单片能更快拿到渲染资源、在超时内完成(浏览器预览能出图,正是因为它肯等且不这么挤)。
|
||||
static const int _maxConcurrentWms = 5;
|
||||
static int _activeWms = 0;
|
||||
static final List<Completer<void>> _wmsWaiters = [];
|
||||
|
||||
static Future<void> _acquireWmsSlot() async {
|
||||
if (_activeWms < _maxConcurrentWms) {
|
||||
_activeWms++;
|
||||
return;
|
||||
}
|
||||
final c = Completer<void>();
|
||||
_wmsWaiters.add(c);
|
||||
await c.future; // 被唤醒即表示 slot 已由 _releaseWmsSlot 直接转交,_activeWms 不变
|
||||
}
|
||||
|
||||
static void _releaseWmsSlot() {
|
||||
if (_wmsWaiters.isNotEmpty) {
|
||||
_wmsWaiters.removeAt(0).complete(); // 有等待者:slot 直接转交队首,_activeWms 保持
|
||||
} else {
|
||||
_activeWms--;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CachedWmsImage> obtainKey(ImageConfiguration configuration) =>
|
||||
SynchronousFuture<CachedWmsImage>(this);
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
CachedWmsImage key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: _loadAsync(decode),
|
||||
scale: 1.0,
|
||||
debugLabel: 'CachedWmsImage',
|
||||
);
|
||||
}
|
||||
|
||||
Future<ui.Codec> _loadAsync(ImageDecoderCallback decode) async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
final dir = await _cacheDir();
|
||||
final file =
|
||||
File('${dir.path}${Platform.pathSeparator}${_fileName(url, _gen)}');
|
||||
|
||||
// 1) 命中磁盘缓存 → 秒开
|
||||
if (await file.exists()) {
|
||||
final cached = await file.readAsBytes();
|
||||
if (cached.isNotEmpty) {
|
||||
return decode(await ui.ImmutableBuffer.fromUint8List(cached));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 未命中 → 联网拉取。先过并发闸门,避免大图层一次性压垮 GeoServer 导致集体超时
|
||||
debugPrint('🌐 [WMS] MISS 联网取瓦片: ${_shortLayerTag(url)}');
|
||||
await _acquireWmsSlot();
|
||||
try {
|
||||
final resp = await http.get(Uri.parse(url)).timeout(kWmsTileTimeout);
|
||||
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
|
||||
try {
|
||||
await file.writeAsBytes(resp.bodyBytes, flush: false);
|
||||
} catch (_) {}
|
||||
debugPrint(
|
||||
'✅ [WMS] 已缓存(${sw.elapsedMilliseconds}ms, ${resp.bodyBytes.length}B): ${_shortLayerTag(url)}',
|
||||
);
|
||||
return decode(await ui.ImmutableBuffer.fromUint8List(resp.bodyBytes));
|
||||
}
|
||||
debugPrint(
|
||||
'⚠️ [WMS] 响应异常 status=${resp.statusCode}(${sw.elapsedMilliseconds}ms): ${_shortLayerTag(url)}',
|
||||
);
|
||||
} finally {
|
||||
_releaseWmsSlot();
|
||||
}
|
||||
} catch (e) {
|
||||
// 超时 / 网络 / 解码异常 → 透明兜底
|
||||
debugPrint('❌ [WMS] 瓦片失败(${sw.elapsedMilliseconds}ms): $e');
|
||||
}
|
||||
return decode(
|
||||
await ui.ImmutableBuffer.fromUint8List(TileProvider.transparentImage),
|
||||
);
|
||||
}
|
||||
|
||||
/// 从 URL 提取 LAYERS 参数做简短日志标签(避免整条 URL 刷屏)
|
||||
static String _shortLayerTag(String url) {
|
||||
final m = RegExp(r'LAYERS=([^&]+)').firstMatch(url);
|
||||
return m?.group(1) ?? 'unknown';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is CachedWmsImage && other.url == url && other._gen == _gen;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(url, _gen);
|
||||
}
|
||||
|
||||
/// 场站图层范围仓库:
|
||||
/// - 优先从 GeoServer WMS GetCapabilities 动态拉取各图层真实范围
|
||||
/// (后台增/改/删图层,App 不发版即可跟上)
|
||||
/// - 拉取或解析失败时,回退到内置 kStationLayerBounds(离线/异常仍可用)
|
||||
class StationLayerRepo {
|
||||
StationLayerRepo._();
|
||||
|
||||
static final StationLayerRepo instance = StationLayerRepo._();
|
||||
|
||||
Map<String, LatLngBounds> _bounds =
|
||||
Map<String, LatLngBounds>.of(kStationLayerBounds);
|
||||
bool _dynamicLoaded = false;
|
||||
Future<void>? _inflight;
|
||||
final List<void Function()> _listeners = [];
|
||||
|
||||
/// 当前生效的范围表(动态成功后为最新;否则为内置兜底)
|
||||
Map<String, LatLngBounds> get bounds => _bounds;
|
||||
|
||||
/// 范围表是否来自动态拉取
|
||||
bool get isDynamic => _dynamicLoaded;
|
||||
|
||||
void addListener(void Function() cb) {
|
||||
if (!_listeners.contains(cb)) _listeners.add(cb);
|
||||
}
|
||||
|
||||
void removeListener(void Function() cb) => _listeners.remove(cb);
|
||||
|
||||
void _notify() {
|
||||
for (final cb in List<void Function()>.of(_listeners)) {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
/// 拉取 GetCapabilities 刷新范围表;并发调用自动合并;失败保持兜底不变
|
||||
Future<void> refresh() {
|
||||
return _inflight ??= _doRefresh().whenComplete(() => _inflight = null);
|
||||
}
|
||||
|
||||
Future<void> _doRefresh() async {
|
||||
final sw = Stopwatch()..start();
|
||||
try {
|
||||
// 用工作区专用端点:只返回 workSpace 下图层,XML 小、快、稳(全局端点图层多易超时)
|
||||
final url = Uri.parse('$kGeoserverBase/$kGeoWorkspace/wms').replace(
|
||||
queryParameters: {
|
||||
'SERVICE': 'WMS',
|
||||
'VERSION': '1.1.1',
|
||||
'REQUEST': 'GetCapabilities',
|
||||
},
|
||||
);
|
||||
debugPrint('🌐 [StationLayerRepo] 拉取 GetCapabilities...');
|
||||
final resp = await http.get(url).timeout(const Duration(seconds: 20));
|
||||
if (resp.statusCode != 200) {
|
||||
debugPrint(
|
||||
'⚠️ [StationLayerRepo] status=${resp.statusCode},保持兜底',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final parsed = parseStationLayerBounds(resp.body);
|
||||
debugPrint(
|
||||
'📦 [StationLayerRepo] 解析到 ${parsed.length} 个图层(${sw.elapsedMilliseconds}ms): ${parsed.keys.join(', ')}',
|
||||
);
|
||||
if (parsed.isEmpty) {
|
||||
debugPrint('⚠️ [StationLayerRepo] 解析为空,保持内置兜底');
|
||||
return;
|
||||
}
|
||||
// 数量防护:动态解析出的图层数若明显少于内置兜底(如网络截断只解析到个别图层),
|
||||
// 直接整体替换会让其余图层凭空消失。改用“兜底打底 + 动态覆盖同名”的合并策略,
|
||||
// 既跟上后端最新范围,又保证已有图层不会因一次异常响应而集体消失。
|
||||
if (parsed.length < kStationLayerBounds.length) {
|
||||
debugPrint(
|
||||
'⚠️ [StationLayerRepo] 动态解析图层数(${parsed.length}) < 兜底(${kStationLayerBounds.length}),采用合并策略防止图层消失',
|
||||
);
|
||||
_bounds = {...kStationLayerBounds, ...parsed};
|
||||
} else {
|
||||
_bounds = parsed;
|
||||
debugPrint('✅ [StationLayerRepo] 已切换为动态范围');
|
||||
}
|
||||
_dynamicLoaded = true;
|
||||
_notify();
|
||||
} catch (e) {
|
||||
// 保持兜底,不打断地图
|
||||
debugPrint(
|
||||
'❌ [StationLayerRepo] GetCapabilities 失败(${sw.elapsedMilliseconds}ms),保持兜底: $e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于当前范围表构建全部场站叠加图层(滑到哪个场站区域就叠加哪个)
|
||||
List<TileLayer> buildLayers({double minZoom = 16, double maxZoom = 21}) {
|
||||
return _bounds.entries.map((entry) {
|
||||
// 自愈兜底:万一范围表里仍残留非法 bounds(历史污染等),回退到内置兜底的同名范围,
|
||||
// 避免生成“框不住任何瓦片”的坏图层导致该场站整体不显示
|
||||
final b = _isValidLayerBounds(entry.value)
|
||||
? entry.value
|
||||
: (kStationLayerBounds[entry.key] ?? entry.value);
|
||||
return TileLayer(
|
||||
tileProvider: GeoWmsProvider(
|
||||
layerName: entry.key,
|
||||
bounds: b,
|
||||
maxLevel: maxZoom.toInt(),
|
||||
),
|
||||
userAgentPackageName: 'com.example.app',
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
maxNativeZoom: maxZoom.toInt(),
|
||||
tileBounds: b,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 校验图层范围是否为合法且有意义的经纬度矩形:
|
||||
/// - 纬度 ∈ [-90,90]、经度 ∈ [-180,180]
|
||||
/// - 南北/东西跨度 > 0 且 ≤ 5°(场站地块都是几百米级小范围,跨度过大必是投影错误/脏数据)
|
||||
/// 非法范围一旦混入范围表,会让 getImage 的相交判断恒为 false → 瓦片全透明 → 图层“整体消失”,
|
||||
/// 这正是“放大也不出来、只能退出重登(页面重建重置图层列表)才恢复”的根因。
|
||||
bool _isValidLayerBounds(LatLngBounds b) {
|
||||
final s = b.south;
|
||||
final n = b.north;
|
||||
final w = b.west;
|
||||
final e = b.east;
|
||||
if (s < -90 || s > 90 || n < -90 || n > 90) return false;
|
||||
if (w < -180 || w > 180 || e < -180 || e > 180) return false;
|
||||
final dLat = n - s;
|
||||
final dLng = e - w;
|
||||
if (dLat <= 0 || dLng <= 0) return false;
|
||||
if (dLat > 5 || dLng > 5) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 解析 WMS GetCapabilities,提取 workSpace 下每个图层的 name → EPSG:4326 范围。
|
||||
/// 同时兼容 1.1.1(LatLongBoundingBox)与 1.3.0(EX_GeographicBoundingBox)。
|
||||
Map<String, LatLngBounds> parseStationLayerBounds(String xmlStr) {
|
||||
final result = <String, LatLngBounds>{};
|
||||
try {
|
||||
final doc = XmlDocument.parse(xmlStr);
|
||||
for (final layer in doc.findAllElements('Layer')) {
|
||||
final nameEls = layer.findElements('Name');
|
||||
if (nameEls.isEmpty) continue;
|
||||
final rawName = nameEls.first.innerText.trim();
|
||||
// 兼容两种命名:全局端点返回 workSpace:xxx;工作区端点可能只返回裸名 xxx
|
||||
final String name;
|
||||
if (rawName.startsWith('$kGeoWorkspace:')) {
|
||||
name = rawName;
|
||||
} else if (!rawName.contains(':')) {
|
||||
name = '$kGeoWorkspace:$rawName';
|
||||
} else {
|
||||
continue; // 其它工作区图层,跳过
|
||||
}
|
||||
|
||||
LatLngBounds? b;
|
||||
|
||||
// WMS 1.1.1
|
||||
final bbEls = layer.findElements('LatLongBoundingBox');
|
||||
if (bbEls.isNotEmpty) {
|
||||
final bb = bbEls.first;
|
||||
final minx = double.tryParse(bb.getAttribute('minx') ?? '');
|
||||
final miny = double.tryParse(bb.getAttribute('miny') ?? '');
|
||||
final maxx = double.tryParse(bb.getAttribute('maxx') ?? '');
|
||||
final maxy = double.tryParse(bb.getAttribute('maxy') ?? '');
|
||||
if (minx != null &&
|
||||
miny != null &&
|
||||
maxx != null &&
|
||||
maxy != null &&
|
||||
maxx > minx &&
|
||||
maxy > miny) {
|
||||
b = LatLngBounds(LatLng(miny, minx), LatLng(maxy, maxx));
|
||||
}
|
||||
}
|
||||
|
||||
// WMS 1.3.0 兜底
|
||||
if (b == null) {
|
||||
final exEls = layer.findElements('EX_GeographicBoundingBox');
|
||||
if (exEls.isNotEmpty) {
|
||||
final ex = exEls.first;
|
||||
double? read(String tag) {
|
||||
final els = ex.findElements(tag);
|
||||
return els.isEmpty
|
||||
? null
|
||||
: double.tryParse(els.first.innerText.trim());
|
||||
}
|
||||
|
||||
final w = read('westBoundLongitude');
|
||||
final e = read('eastBoundLongitude');
|
||||
final n = read('northBoundLatitude');
|
||||
final s = read('southBoundLatitude');
|
||||
if (w != null && e != null && n != null && s != null && e > w && n > s) {
|
||||
b = LatLngBounds(LatLng(s, w), LatLng(n, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (b != null) {
|
||||
if (_isValidLayerBounds(b)) {
|
||||
result[name] = b;
|
||||
} else {
|
||||
// 范围非法(经纬度越界 / 跨度过大或为 0)→ 丢弃该图层,
|
||||
// 避免污染范围表导致瓦片框不住、图层整体消失
|
||||
debugPrint(
|
||||
'⚠️ [StationLayerRepo] 图层 $name 范围非法已丢弃: S=${b.south} N=${b.north} W=${b.west} E=${b.east}',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
// 解析失败返回空表,调用方保持兜底
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
bool _isToastShowing = false;
|
||||
|
||||
final MapController _mapController = MapController();
|
||||
/// 全部场站高清叠加图层:初始用内置兜底,GetCapabilities 拉取成功后自动替换为动态范围
|
||||
List<TileLayer> _stationLayers = kAllStationTileLayers;
|
||||
// 初始化轨迹管理器(泛型指定为LatLng)
|
||||
late final TracePoint<PlotPoint> _traceManager;
|
||||
late String workMode = "覆盖模式"; // 作业模式:默认值为"覆盖模式"
|
||||
@@ -181,6 +183,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
|
||||
LatLng _mapCenter = const LatLng(39.9042, 116.4074);
|
||||
double _headingAngle = 0.0; // 当前机器航向角(单位:度)
|
||||
|
||||
// 🔥 地图 zoom 日志节流:上次已打印过的 zoom 值(变化 ≥ 0.1 才再打)
|
||||
double? _lastLoggedZoom;
|
||||
List typedPathList =
|
||||
<work_area_model.DeviceAddPathPointModel>[]; // 存储生成路径的坐标列表(已转换为LatLng)
|
||||
|
||||
@@ -272,11 +277,54 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 新增:zoom 变化日志(节流)
|
||||
// 用途:方便开发者手动 pinch/双击缩放到“理想级别”,从日志里读出 zoom 数值,
|
||||
// 反向调整 fitBounds 的 maxZoom(或其他缩放相关参数)。
|
||||
// 节流策略:只有 zoom 变化 ≥ 0.1 才打一次,避免拖拽/缩放过程中刷屏。
|
||||
// 事件来源(event.source)可区分“用户手势”还是“代码调用”:
|
||||
// - dragUpdate / pinchZoomUpdate / doubleTapZoomAnimation* → 用户交互
|
||||
// - custom → controller.move / fitBounds 等代码调用
|
||||
if (mounted && _mapController.camera != null) {
|
||||
final camera = _mapController.camera!;
|
||||
final currentZoom = camera.zoom;
|
||||
final shouldLog =
|
||||
_lastLoggedZoom == null ||
|
||||
(currentZoom - _lastLoggedZoom!).abs() >= 0.1;
|
||||
if (shouldLog) {
|
||||
final delta =
|
||||
_lastLoggedZoom == null
|
||||
? 'init'
|
||||
: '${currentZoom > _lastLoggedZoom! ? "↑放大" : "↓缩小"} '
|
||||
'${(currentZoom - _lastLoggedZoom!).toStringAsFixed(2)}';
|
||||
debugPrint(
|
||||
'🔍 [地图缩放] zoom=${currentZoom.toStringAsFixed(3)} ($delta), '
|
||||
'center=(${camera.center.latitude.toStringAsFixed(6)}, ${camera.center.longitude.toStringAsFixed(6)}), '
|
||||
'source=${event.source}',
|
||||
);
|
||||
_lastLoggedZoom = currentZoom;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 🌐 场站高清图层:监听动态范围更新 + 触发一次 GetCapabilities 刷新
|
||||
StationLayerRepo.instance.addListener(_onStationLayersUpdated);
|
||||
StationLayerRepo.instance.refresh();
|
||||
|
||||
// 🔄 进场刷新场站数据:避免 siteMapName/默认中心沿用上次缓存(无需退回首页重新请求接口)
|
||||
sl<SiteCubit>().reloadSites();
|
||||
|
||||
debugPrint('✅ [MapPage] initState 完成');
|
||||
}
|
||||
|
||||
/// GeoServer 动态范围拉取完成 → 用最新范围重建叠加图层
|
||||
void _onStationLayersUpdated() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stationLayers = StationLayerRepo.instance.buildLayers();
|
||||
});
|
||||
}
|
||||
|
||||
// 🔥 核心修复:监听路径规划应答流
|
||||
void _setupPathPlanningListener() {
|
||||
debugPrint('🔗 [MapPage] 开始监听路径规划指令应答 (CMD: 0x01)');
|
||||
@@ -670,6 +718,47 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 选中路线任务时,自动校验"上次执行的任务"是否仍然存在
|
||||
/// 若任务已被取消/完成/不存在,则清除当前任务,避免面板显示过期任务卡片
|
||||
Future<void> _validateCurrentTaskOnSelect() async {
|
||||
try {
|
||||
final targetDevice =
|
||||
context.read<RemoteControlCubit>().state.targetDevice;
|
||||
final deviceId = targetDevice?.deviceName;
|
||||
if (deviceId == null || deviceId.isEmpty) return;
|
||||
|
||||
final taskCubit = sl<DeviceTaskCubit>();
|
||||
|
||||
// 优先取内存中的 taskId,其次从本地恢复(仅用于存在性校验,不提前构造卡片,避免闪现假任务)
|
||||
var previousTaskId = taskCubit.state.currentTaskId;
|
||||
if (previousTaskId == null) {
|
||||
previousTaskId = await _restoreTaskIdFromLocal(deviceId);
|
||||
}
|
||||
|
||||
// 没有历史任务,无需校验,也不展示卡片
|
||||
if (previousTaskId == null) return;
|
||||
|
||||
debugPrint(
|
||||
'🔄 [选中校验] 校验上次任务是否存在, deviceId=$deviceId, taskId=$previousTaskId',
|
||||
);
|
||||
await taskCubit.fetchAndFilterTask(deviceId);
|
||||
|
||||
// 🔥 查询后如果上次的任务已不在活跃任务中,清除当前任务,卡片不再展示
|
||||
final stillExists = taskCubit.state.activeTasks.any(
|
||||
(t) => t.id == previousTaskId,
|
||||
);
|
||||
if (!stillExists) {
|
||||
debugPrint('🔄 [选中校验] 任务已不存在,清除当前任务与本地缓存');
|
||||
taskCubit.clearCurrentTask();
|
||||
await _clearTaskIdFromLocal(deviceId);
|
||||
} else {
|
||||
debugPrint('🔄 [选中校验] 任务仍存在,保留显示');
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('🔄 [选中校验] 校验失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 新增:计算坐标列表的边界范围 ==========
|
||||
LatLngBounds? calculateBounds(List<LatLng> points) {
|
||||
if (points.isEmpty) return null;
|
||||
@@ -1607,6 +1696,37 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
_mapController.move(_currentLatLng!, 17);
|
||||
}
|
||||
|
||||
/// 手动刷新高清场站图层(不动用户的作业/障碍物绘制数据):
|
||||
/// ① 清 WMS 瓦片缓存(拿后端最新影像)② 重拉场站接口(更新 siteMapName 映射)
|
||||
/// ③ 重拉 GetCapabilities(更新图层范围数组)④ 重建图层 ⑤ 定位到最新场站中心
|
||||
Future<void> _onRefreshStationLayers() async {
|
||||
debugPrint('🔄 [刷新图层] 开始手动刷新高清场站图层...');
|
||||
_showPageToast(message: '正在刷新高清图层...', type: ToastType.loading);
|
||||
try {
|
||||
await CachedWmsImage.clearTileCache();
|
||||
await sl<SiteCubit>().reloadSites();
|
||||
await StationLayerRepo.instance.refresh();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_stationLayers = StationLayerRepo.instance.buildLayers();
|
||||
});
|
||||
final c = _getDefaultValidCenter();
|
||||
_mapController.move(c, 18);
|
||||
_cancelAllToast();
|
||||
_showPageToast(message: '已刷新到最新图层', type: ToastType.success);
|
||||
debugPrint('✅ [刷新图层] 完成,定位到 $c');
|
||||
} catch (e) {
|
||||
debugPrint('❌ [刷新图层] 失败: $e');
|
||||
if (!mounted) return;
|
||||
_cancelAllToast();
|
||||
final msg = e.toString();
|
||||
_showPageToast(
|
||||
message: '刷新失败: ${msg.length > 50 ? msg.substring(0, 50) : msg}',
|
||||
type: ToastType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 障碍物模式:完成当前障碍物绘制 ==========
|
||||
void _completeObstacle() {
|
||||
debugPrint(
|
||||
@@ -1716,6 +1836,7 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
StationLayerRepo.instance.removeListener(_onStationLayersUpdated);
|
||||
_traceManager.reset();
|
||||
_mapController.dispose();
|
||||
_pathPlanningSub?.cancel(); // 🔥 取消订阅
|
||||
@@ -1784,6 +1905,9 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
_selectedPlot = plot;
|
||||
});
|
||||
|
||||
// 🔥 选中任务时自动校验上次的任务是否还存在:不存在则不展示(等同手动刷新)
|
||||
_validateCurrentTaskOnSelect();
|
||||
|
||||
try {
|
||||
_traceManager.reset();
|
||||
|
||||
@@ -2032,7 +2156,25 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
'🗺️ [选中地块] 准备移动地图: allPoints=${allPoints.length}, gcjPathPoints=${gcjPathPoints.length}, gcjOuterPoints=${gcjOuterPoints.length}',
|
||||
);
|
||||
if (allPoints.isNotEmpty) {
|
||||
moveMapToPointsCenter(allPoints);
|
||||
// 🔥 选中路线时自动定位到路径区域:
|
||||
// - center = bounds.center = 金黄线(gcjPathPoints)+ 灰色边框(gcjOuterPoints)所有点的
|
||||
// bounding box 几何中心,保证路径永远居中,不会因屏幕当前位置而偏移
|
||||
// - zoom = 21.0 固定值(开发者实测的理想级别):不管路径大小都缩到同一级别,视觉一致
|
||||
// - ⚠️ zoom=21 超过 ESRI 底图 19 级上限,底图可能白或靠场站高清叠加层显示(开发者已确认可接受)
|
||||
// - 一次性操作,不锁交互:用户之后仍可自由 pinch/drag 缩放平移
|
||||
final bounds = calculateBounds(allPoints);
|
||||
if (bounds != null) {
|
||||
const double targetZoom = 21.0;
|
||||
final zoomBefore = _mapController.camera?.zoom;
|
||||
_mapController.move(bounds.center, targetZoom);
|
||||
debugPrint(
|
||||
'🗺️ [选中地块] move 完成: zoom=$zoomBefore → $targetZoom, '
|
||||
'center=${bounds.center} (路径几何中心)',
|
||||
);
|
||||
} else {
|
||||
debugPrint('⚠️ [选中地块] bounds 计算失败,回退到仅居中(保持原缩放)');
|
||||
moveMapToPointsCenter(allPoints);
|
||||
}
|
||||
} else {
|
||||
debugPrint('❌ [选中地块] allPoints 为空,无法移动地图!');
|
||||
}
|
||||
@@ -2178,10 +2320,10 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
),
|
||||
|
||||
/// GeoServer 全部场站无人机高清影像叠加层(WGS84 基准,无偏移)
|
||||
/// 一次性叠加所有场站图层:滑到哪个场站区域(zoom>=16)就自动显示哪个高清图
|
||||
/// 每层各自 tileBounds,框外瓦片不请求,开销极小;苏州等手动滑过去即可叠加
|
||||
/// 当前 WMS 兜底(原生 EPSG:4326);公司补配 GWC EPSG:3857 后改 geoserver_suzhou_layer.dart
|
||||
...kAllStationTileLayers,
|
||||
/// 图层范围优先取自 GeoServer GetCapabilities(动态),失败回退内置兜底
|
||||
/// 滑到哪个场站区域(zoom>=16)就自动显示哪个高清图;每层各自 tileBounds,框外不请求
|
||||
/// WMS 瓦片带磁盘缓存+超时(CachedWmsImage),二次进入秒开
|
||||
..._stationLayers,
|
||||
//if (isDjMapShow) DJITileLayer(stationName: _currentStation.name, mapController: _mapController),
|
||||
|
||||
// 绘制path折线(Line模式)
|
||||
@@ -3213,8 +3355,10 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
gctracePoint?.clear();
|
||||
tracePoint = [];
|
||||
gctracePoint = [];
|
||||
// 🔥 取消任务后消除规划路径显示,只保留原先的边界框(gcjOuterPoints)
|
||||
gcjPathPoints = [];
|
||||
});
|
||||
debugPrint('[停止作业] 最终 setState 完成,轨迹已清空');
|
||||
debugPrint('[停止作业] 最终 setState 完成,轨迹与路径已清空,保留边界框');
|
||||
}
|
||||
|
||||
debugPrint('══════════ [停止作业] 结束 ══════════');
|
||||
@@ -4542,6 +4686,37 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
),
|
||||
),
|
||||
|
||||
/// 悬浮“刷新高清图层”按钮:后端改了图层/影像/场站映射后,
|
||||
/// 点它强制拉最新(清瓦片缓存 + 重拉场站接口 + 重拉 GetCapabilities + 重新定位)
|
||||
Positioned(
|
||||
top: 110,
|
||||
left: 10,
|
||||
child: GestureDetector(
|
||||
onTap: _onRefreshStationLayers,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const Icon(
|
||||
Icons.refresh,
|
||||
color: Colors.black87,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
/// 核心:地图正中心固定定位标
|
||||
if (_currentRobotMode == RobotMode.point)
|
||||
Positioned(
|
||||
@@ -4900,18 +5075,22 @@ class _MapPageEnterpriseState extends State<MapPageEnterprise> {
|
||||
.deleteWorkRecord(
|
||||
deletedPlot.plotName,
|
||||
);
|
||||
final userId =
|
||||
context
|
||||
.read<AppUserCubit>()
|
||||
// 🔥 修复:删除后按“当前全局场站”重查,与打开列表(onListBox)走同一接口 selectBySiteId
|
||||
// 原逻辑用 loadWorkRecords(userId) 走 selectByUserId(按登录用户查),
|
||||
// 与列表口径不一致,导致删除后重查的数据不对;重新点列表才恢复正常。
|
||||
final selectedSite =
|
||||
sl<SiteCubit>()
|
||||
.state
|
||||
.user
|
||||
?.userId ??
|
||||
"";
|
||||
await context
|
||||
.read<DevicesCubit>()
|
||||
.loadWorkRecords(userId);
|
||||
.selectedSite;
|
||||
if (selectedSite != null) {
|
||||
await context
|
||||
.read<DevicesCubit>()
|
||||
.loadWorkRecordsBySiteId(
|
||||
selectedSite.id,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
@@ -50,6 +50,40 @@ class TabConfig extends Equatable {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/// roleKey == 'personal'(个人角色):底部导航与「系统设置 → Tab 设置」强制只保留
|
||||
/// 「设备(device) + 我的(me)」,且常显、开关锁定不可关闭;其他角色不受影响。
|
||||
static const String personalRoleKey = 'personal';
|
||||
static const Set<String> personalVisibleTabIds = {'device', 'me'};
|
||||
|
||||
/// 底部导航实际渲染的 Tab:
|
||||
/// - personal:强制返回 设备 + 我的(忽略 isEnabled,恒为启用),按 order 排序
|
||||
/// - 其他角色:返回用户启用的 Tab(enabledItems)
|
||||
List<TabConfigItem> navItemsForRole(String? roleKey) {
|
||||
if (roleKey == personalRoleKey) {
|
||||
final forced = items
|
||||
.where((item) => personalVisibleTabIds.contains(item.id))
|
||||
.map((item) => item.copyWith(isEnabled: true))
|
||||
.toList();
|
||||
forced.sort((a, b) => a.order.compareTo(b.order));
|
||||
return forced;
|
||||
}
|
||||
return enabledItems;
|
||||
}
|
||||
|
||||
/// 「Tab 设置」列表应展示的 Tab:
|
||||
/// - personal:只列出 设备 + 我的,按 order 排序
|
||||
/// - 其他角色:列出全部
|
||||
List<TabConfigItem> settingItemsForRole(String? roleKey) {
|
||||
if (roleKey == personalRoleKey) {
|
||||
final visible = items
|
||||
.where((item) => personalVisibleTabIds.contains(item.id))
|
||||
.toList();
|
||||
visible.sort((a, b) => a.order.compareTo(b.order));
|
||||
return visible;
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
TabConfig copyWith({List<TabConfigItem>? items}) {
|
||||
return TabConfig(items: items ?? this.items);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/
|
||||
import 'package:maibu_satabot_v2/features/v2/device_list/presentation/float_bar/cubit/float_bar_setting_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/di/injection.dart';
|
||||
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_cubit.dart';
|
||||
|
||||
class MainWrapper extends StatefulWidget {
|
||||
const MainWrapper({super.key});
|
||||
@@ -30,8 +31,6 @@ class MainWrapper extends StatefulWidget {
|
||||
class _MainWrapperState extends State<MainWrapper> {
|
||||
int _currentIndex = 0;
|
||||
final PageController _pageController = PageController();
|
||||
final List<GlobalKey> _tabKeys = [];
|
||||
double? _circleLeft;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -39,22 +38,6 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
|
||||
// 🔥 确保 FloatBarSettingService 已初始化
|
||||
GetIt.I<FloatBarSettingService>();
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_updateCirclePosition();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateCirclePosition() {
|
||||
if (_tabKeys.isEmpty || _currentIndex >= _tabKeys.length) return;
|
||||
|
||||
final key = _tabKeys[_currentIndex];
|
||||
final renderBox = key.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (renderBox != null) {
|
||||
setState(() {
|
||||
_circleLeft = renderBox.localToGlobal(Offset.zero).dx;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -68,52 +51,36 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
_currentIndex = index;
|
||||
});
|
||||
_pageController.jumpToPage(index);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_updateCirclePosition();
|
||||
});
|
||||
}
|
||||
|
||||
IconData _getIconData(String iconName) {
|
||||
/// Tab 图标:选中用 filled(实心),未选用 outlined(描边),对比更清晰
|
||||
IconData _navIcon(String iconName, bool selected) {
|
||||
switch (iconName) {
|
||||
case 'home_rounded':
|
||||
return Icons.home_rounded;
|
||||
return selected ? Icons.home_rounded : Icons.home_outlined;
|
||||
case 'grid_view_rounded':
|
||||
return Icons.grid_view_rounded;
|
||||
return selected ? Icons.grid_view_rounded : Icons.grid_view_outlined;
|
||||
case 'devices_rounded':
|
||||
return Icons.devices_rounded;
|
||||
return selected ? Icons.devices_rounded : Icons.devices_outlined;
|
||||
case 'auto_awesome_rounded':
|
||||
return Icons.auto_awesome_rounded;
|
||||
return selected
|
||||
? Icons.auto_awesome_rounded
|
||||
: Icons.auto_awesome_outlined;
|
||||
case 'warning_amber_rounded':
|
||||
return Icons.warning_amber_rounded;
|
||||
return selected
|
||||
? Icons.warning_amber_rounded
|
||||
: Icons.warning_amber_outlined;
|
||||
case 'assignment_rounded':
|
||||
return Icons.assignment_rounded;
|
||||
return selected ? Icons.assignment_rounded : Icons.assignment_outlined;
|
||||
case 'post_add_rounded':
|
||||
return Icons.post_add_rounded;
|
||||
return selected ? Icons.post_add_rounded : Icons.post_add_outlined;
|
||||
case 'person_rounded':
|
||||
return Icons.person_rounded;
|
||||
return selected ? Icons.person_rounded : Icons.person_outlined;
|
||||
default:
|
||||
return Icons.home_rounded;
|
||||
return selected ? Icons.home_rounded : Icons.home_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
double _getIndicatorPosition(int itemCount, int currentIndex) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final itemWidth = screenWidth / itemCount;
|
||||
return currentIndex * itemWidth;
|
||||
}
|
||||
|
||||
double _getCircularLeftPosition(int itemCount, int currentIndex) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final itemWidth = screenWidth / itemCount;
|
||||
// 圆形应该在每个Tab项的中心
|
||||
final tabCenterX = 12 + (currentIndex * itemWidth) + (itemWidth / 2);
|
||||
final circularLeft = tabCenterX - 22; // 22 = 44/2,让圆形中心对齐Tab中心
|
||||
|
||||
// 边界限制
|
||||
final maxLeft = screenWidth - 12 - 44;
|
||||
return circularLeft.clamp(12.0, maxLeft);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage(TabConfigItem tab) {
|
||||
switch (tab.id) {
|
||||
case 'home_v2':
|
||||
@@ -147,6 +114,8 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 🔥 个人角色(roleKey == 'personal')底部导航强制只显示「设备 + 我的」
|
||||
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
|
||||
return BlocBuilder<TabConfigCubit, TabConfigState>(
|
||||
builder: (context, state) {
|
||||
if (state is! TabConfigLoaded) {
|
||||
@@ -156,7 +125,7 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
}
|
||||
|
||||
final config = state.config;
|
||||
final enabledItems = config.enabledItems;
|
||||
final enabledItems = config.navItemsForRole(roleKey);
|
||||
|
||||
if (enabledItems.isEmpty) {
|
||||
return const Scaffold(body: Center(child: Text('请至少启用一个 Tab')));
|
||||
@@ -166,24 +135,16 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
_currentIndex = 0;
|
||||
}
|
||||
|
||||
// 初始化 GlobalKey
|
||||
if (_tabKeys.length != enabledItems.length) {
|
||||
_tabKeys.clear();
|
||||
for (int i = 0; i < enabledItems.length; i++) {
|
||||
_tabKeys.add(GlobalKey());
|
||||
}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_updateCirclePosition();
|
||||
});
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
extendBody: false,
|
||||
// 底部导航预留区(SafeArea 那条)与页面同色,消除突兀的底色带
|
||||
backgroundColor: context.appColors.pageBackground,
|
||||
body: Stack(
|
||||
children: [
|
||||
PageView(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
// 🔥 启用左右滑动切换 Tab
|
||||
physics: const BouncingScrollPhysics(),
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
@@ -205,102 +166,138 @@ class _MainWrapperState extends State<MainWrapper> {
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomNavigationBar: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.cardBackground,
|
||||
),
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 16,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
border: Border.all(
|
||||
color: context.appColors.divider,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.appColors.cardShadow,
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 滑动圆形背景
|
||||
if (_circleLeft != null)
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
left: _circleLeft! - 8, // 减去外层 margin + 向右偏移
|
||||
top: 6,
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.divider,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: context.appColors.divider,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tab 项
|
||||
Row(
|
||||
children: enabledItems.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final isSelected = _currentIndex == index;
|
||||
final itemCount = enabledItems.length;
|
||||
// Tab 较少时收缩整体宽度并居中,Tab 多时铺满可用宽度
|
||||
const double preferredTabWidth = 84;
|
||||
const double barPaddingH = 6;
|
||||
final double maxBarWidth = constraints.maxWidth;
|
||||
final double barWidth =
|
||||
itemCount * preferredTabWidth < maxBarWidth
|
||||
? itemCount * preferredTabWidth
|
||||
: maxBarWidth;
|
||||
final double tabWidth =
|
||||
(barWidth - barPaddingH * 2) / itemCount;
|
||||
// 选中高亮:包裹「图标 + 文字」的紧凑胶囊 chip
|
||||
const double indicatorH = 46;
|
||||
final double indicatorW =
|
||||
tabWidth - 8 < 54 ? tabWidth - 8 : 54.0;
|
||||
|
||||
return Expanded(
|
||||
key: _tabKeys[index],
|
||||
child: InkWell(
|
||||
onTap: () => _onTabChanged(index),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
_getIconData(item.icon),
|
||||
size: 24,
|
||||
color: isSelected
|
||||
? context.appColors.textPrimary
|
||||
: context.appColors.textTertiary,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.name,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isSelected
|
||||
? context.appColors.textPrimary
|
||||
: context.appColors.textTertiary,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
// 用 Align + heightFactor 只水平居中,高度贴合内容;
|
||||
// 切勿用 Center:它在底部栏有界高度约束下会撑满全屏,把 body 挤没
|
||||
return Align(
|
||||
alignment: Alignment.center,
|
||||
heightFactor: 1.0,
|
||||
child: Container(
|
||||
width: barWidth,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: barPaddingH,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
border: Border.all(
|
||||
color: context.appColors.divider,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.appColors.cardShadow,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 蓝色胶囊 chip:纯数学定位,居中包裹图标 + 文字
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeOutCubic,
|
||||
left: _currentIndex * tabWidth +
|
||||
(tabWidth - indicatorW) / 2,
|
||||
top: (56 - indicatorH) / 2,
|
||||
width: indicatorW,
|
||||
height: indicatorH,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.primary.withOpacity(
|
||||
0.12,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
indicatorH / 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
// Tab 项
|
||||
Row(
|
||||
children:
|
||||
enabledItems.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final item = entry.value;
|
||||
final isSelected = _currentIndex == index;
|
||||
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(
|
||||
indicatorH / 2,
|
||||
),
|
||||
onTap: () => _onTabChanged(index),
|
||||
child: Column(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedScale(
|
||||
scale: isSelected ? 1.1 : 1.0,
|
||||
duration: const Duration(
|
||||
milliseconds: 200,
|
||||
),
|
||||
curve: Curves.easeOutBack,
|
||||
child: Icon(
|
||||
_navIcon(item.icon, isSelected),
|
||||
size: 22,
|
||||
color: isSelected
|
||||
? context.appColors.primary
|
||||
: context
|
||||
.appColors.textTertiary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
AnimatedDefaultTextStyle(
|
||||
duration: const Duration(
|
||||
milliseconds: 200,
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
height: 1.0,
|
||||
color: isSelected
|
||||
? context.appColors.primary
|
||||
: context
|
||||
.appColors.textTertiary,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
),
|
||||
child: Text(item.name),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -14,6 +14,9 @@ abstract class DeviceRemoteDataSource {
|
||||
String? typeFilter,
|
||||
});
|
||||
Future<List<DeviceDataModel>> getAllDevices();
|
||||
|
||||
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null
|
||||
Future<DeviceDataModel?> getDeviceBySpiltCode(String code);
|
||||
}
|
||||
|
||||
/// 设备远程数据源实现类 - 从真实 API 获取数据
|
||||
@@ -97,6 +100,44 @@ class DeviceRemoteDataSourceImpl implements DeviceRemoteDataSource {
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DeviceDataModel?> getDeviceBySpiltCode(String code) async {
|
||||
try {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getDeviceBySpiltCode,
|
||||
queryParameters: {'code': code},
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('网络请求失败: ${response.statusCode}');
|
||||
}
|
||||
|
||||
final responseData = response.data;
|
||||
if (responseData['code'] != 200) {
|
||||
throw Exception(responseData['msg'] ?? '业务异常');
|
||||
}
|
||||
|
||||
// 未查到时后端仅返回 {msg, code},无 data 字段
|
||||
final data = responseData['data'];
|
||||
if (data == null) {
|
||||
debugPrint(
|
||||
'>>> [DeviceRemoteDataSource] getDeviceBySpiltCode 未查到设备 code=$code',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// data 结构与 getDeviceList 的单条 row 一致,复用解析(deviceName 即 serialNumber)
|
||||
final model = _parseDeviceFromJson(Map<String, dynamic>.from(data as Map));
|
||||
debugPrint(
|
||||
'>>> [DeviceRemoteDataSource] getDeviceBySpiltCode 命中 code=$code serialNumber=${model.name}',
|
||||
);
|
||||
return model;
|
||||
} catch (e) {
|
||||
debugPrint('>>> [DeviceRemoteDataSource] getDeviceBySpiltCode error: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<DeviceDataModel>> _fetchDevicesFromAPI(int siteId) async {
|
||||
final response = await dio.get(
|
||||
HttpApiConsts.getSiteDeviceList,
|
||||
|
||||
@@ -9,6 +9,7 @@ class RobotDataModel {
|
||||
final int statusCode; // 接口原始 status 状态码
|
||||
final double battery;
|
||||
final String task;
|
||||
final bool hasHost;
|
||||
|
||||
const RobotDataModel({
|
||||
required this.name,
|
||||
@@ -20,6 +21,7 @@ class RobotDataModel {
|
||||
this.statusCode = 0,
|
||||
required this.battery,
|
||||
required this.task,
|
||||
this.hasHost = false,
|
||||
});
|
||||
|
||||
/// 从 JSON 创建数据模型
|
||||
@@ -46,6 +48,7 @@ class RobotDataModel {
|
||||
statusCode: statusCode is int ? statusCode : 0,
|
||||
battery: batteryValue,
|
||||
task: json['task'] ?? json['currentTask'] ?? '待机中',
|
||||
hasHost: json['hasHost'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,10 @@ class DeviceRepositoryImpl implements DeviceRepository {
|
||||
final dataModels = await remoteDataSource.getAllDevices();
|
||||
return dataModels.map((model) => model.toEntity()).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DeviceEntity?> getDeviceBySpiltCode(String code) async {
|
||||
final dataModel = await remoteDataSource.getDeviceBySpiltCode(code);
|
||||
return dataModel?.toEntity();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,4 +6,7 @@ abstract class DeviceRepository {
|
||||
Future<DeviceStatusEntity> getDeviceStatus();
|
||||
Future<List<DeviceEntity>> getDeviceList({int? siteId, String? typeFilter});
|
||||
Future<List<DeviceEntity>> getAllDevices();
|
||||
|
||||
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备,未查到时返回 null
|
||||
Future<DeviceEntity?> getDeviceBySpiltCode(String code);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import '../entities/device_entity.dart';
|
||||
import '../repositories/device_repository.dart';
|
||||
|
||||
/// 根据拆分码(蓝牙设备名/时间戳)精确查询设备用例(用于蓝牙绑定匹配)
|
||||
class GetDeviceBySpiltCodeUseCase {
|
||||
final DeviceRepository repository;
|
||||
|
||||
const GetDeviceBySpiltCodeUseCase({required this.repository});
|
||||
|
||||
Future<DeviceEntity?> execute(String code) async {
|
||||
return await repository.getDeviceBySpiltCode(code);
|
||||
}
|
||||
}
|
||||
@@ -13,14 +13,23 @@ import '../../../../../core/protocol/machine_protocol_constants.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../../core/services/device_permission_service.dart';
|
||||
import '../../domain/entities/device_entity.dart';
|
||||
import '../../domain/usecases/get_all_devices_usecase.dart';
|
||||
import '../../domain/usecases/get_device_by_spilt_code_usecase.dart';
|
||||
import 'bind_device_page.dart';
|
||||
|
||||
class BleDeviceDetailPage extends StatefulWidget {
|
||||
final BluetoothDevice device;
|
||||
final ScanResult? scanResult;
|
||||
|
||||
const BleDeviceDetailPage({super.key, required this.device, this.scanResult});
|
||||
/// 后端设备长名称(如 MC700PLUS-CN-JS-1760408682847-0000002A-A9),
|
||||
/// 由列表页跳转时传入;为空时回退到蓝牙广播名
|
||||
final String? displayName;
|
||||
|
||||
const BleDeviceDetailPage({
|
||||
super.key,
|
||||
required this.device,
|
||||
this.scanResult,
|
||||
this.displayName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<BleDeviceDetailPage> createState() => _BleDeviceDetailPageState();
|
||||
@@ -59,6 +68,14 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
super.initState();
|
||||
_initConnectionListener();
|
||||
_syncInitialState();
|
||||
|
||||
// 🔥 页面加载完成后自动连接设备(如果尚未连接且未在连接中)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
if (!_isConnected && !_isConnecting) {
|
||||
_connect();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 监听 BleManager 的全局连接状态流
|
||||
@@ -290,32 +307,22 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
);
|
||||
|
||||
try {
|
||||
final getAllDevicesUseCase = GetIt.instance<GetAllDevicesUseCase>();
|
||||
final devices = await getAllDevicesUseCase.execute();
|
||||
// 用蓝牙名(时间戳)作为 code,调 getDeviceBySpiltCode 精确查询设备
|
||||
final getDeviceBySpiltCodeUseCase =
|
||||
GetIt.instance<GetDeviceBySpiltCodeUseCase>();
|
||||
final DeviceEntity? matchedDevice =
|
||||
await getDeviceBySpiltCodeUseCase.execute(bleName);
|
||||
|
||||
if (loadingCtx != null && Navigator.canPop(loadingCtx!)) {
|
||||
Navigator.pop(loadingCtx!);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'>>> [BindDevice] bleName=$bleName, devices=${devices.length}',
|
||||
'>>> [BindDevice] bleName=$bleName, matched=${matchedDevice == null ? "未查到" : matchedDevice.name}',
|
||||
);
|
||||
for (int i = 0; i < devices.length && i < 10; i++) {
|
||||
debugPrint(
|
||||
'>>> [BindDevice] device[$i]: id=${devices[i].deviceId}, name=${devices[i].name}',
|
||||
);
|
||||
}
|
||||
|
||||
// 用蓝牙名(时间戳)匹配设备名中包含该时间戳的设备
|
||||
DeviceEntity? matchedDevice;
|
||||
for (final device in devices) {
|
||||
if (device.name.contains(bleName)) {
|
||||
matchedDevice = device;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedDevice == null) {
|
||||
// 接口未查到(返回无 data)→ 与之前一致弹窗提示
|
||||
if (matchedDevice == null || matchedDevice.name.isEmpty) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
@@ -334,11 +341,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
// 复用扫一扫的绑定页面流程
|
||||
// 复用扫一扫的绑定页面流程(scanResult 传序列号 serialNumber)
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BindDevicePage(scanResult: matchedDevice!.name),
|
||||
builder: (_) => BindDevicePage(scanResult: matchedDevice.name),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -517,7 +524,9 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
final advData = widget.scanResult?.advertisementData;
|
||||
// 优先使用广告数据中的名称(含扫描响应),手机蓝牙列表也是这样显示的
|
||||
final name =
|
||||
_resolvedName ??
|
||||
(widget.displayName?.isNotEmpty == true
|
||||
? widget.displayName!
|
||||
: _resolvedName) ??
|
||||
(advData?.advName.isNotEmpty == true
|
||||
? advData!.advName
|
||||
: (device.platformName.isNotEmpty
|
||||
@@ -549,13 +558,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
_buildDeviceInfoCard(name, scanResult),
|
||||
const SizedBox(height: 16),
|
||||
_buildConnectionControl(),
|
||||
if (_isConnected && _services.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildServicesList(),
|
||||
],
|
||||
if (_isConnected) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildCommandSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildDeviceConfigCard(),
|
||||
const SizedBox(height: 16),
|
||||
@@ -611,14 +614,6 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
color: context.appColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.device.remoteId}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: context.appColors.textTertiary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -629,7 +624,6 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
Divider(height: 1, color: context.appColors.divider),
|
||||
const SizedBox(height: 12),
|
||||
_buildInfoRow('设备名称', name),
|
||||
_buildInfoRow('设备ID', '${widget.device.remoteId}'),
|
||||
if (scanResult != null) ...[
|
||||
_buildInfoRow('信号强度', '${scanResult.rssi} dBm'),
|
||||
_buildInfoRow(
|
||||
@@ -807,6 +801,27 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 读配置 按钮(未连接时灰色禁用)
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isConnected ? _sendReadConfig : null,
|
||||
icon: const Icon(Icons.settings, size: 18),
|
||||
label: const Text('读配置'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: _isConnected
|
||||
? context.appColors.primary
|
||||
: context.appColors.fillBackground,
|
||||
foregroundColor: _isConnected
|
||||
? Colors.white
|
||||
: context.appColors.textTertiary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -999,7 +1014,11 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
}
|
||||
|
||||
Widget _buildCommLogSection() {
|
||||
final pushes = _latestPushByCommand.values.toList();
|
||||
final pushes = _latestPushByCommand.values
|
||||
.where((p) =>
|
||||
p.command != MachineProtocolConstants.cmdRemoteControl &&
|
||||
p.command != MachineProtocolConstants.cmdReadConfig)
|
||||
.toList();
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -1114,23 +1133,6 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: colors.badge,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'0x${p.command.toRadixString(16).toUpperCase().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
fontFamily: 'monospace',
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
name,
|
||||
@@ -1237,7 +1239,7 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'设备配置 (0x05)',
|
||||
'设备配置',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
@@ -1487,33 +1489,6 @@ class _BleDeviceDetailPageState extends State<BleDeviceDetailPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
// 权限通过提示
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: context.appColors.success,
|
||||
size: 28,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text('权限通过'),
|
||||
],
|
||||
),
|
||||
content: const Text('您有权限操作此设备'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogCtx),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
// 0. 连接状态诊断
|
||||
developer.log(
|
||||
'[WriteConfig] 🏥 连接状态: isConnected=${BleManager.instance.isConnected}, '
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
@@ -11,6 +12,7 @@ import '../../../../../components/tcp_status_indicator.dart';
|
||||
import '../../../../../components/device_status_modal.dart';
|
||||
import '../../../../v2/site/presentation/cubit/site_cubit.dart';
|
||||
import '../../../site/presentation/widgets/site_selector_widget.dart';
|
||||
import '../../../../main_container/domain/tab_config.dart';
|
||||
import '../bloc/device_status_bloc.dart' as DeviceListBloc;
|
||||
import '../bloc/device_status_event.dart' as DeviceListEvent;
|
||||
import '../bloc/device_status_state.dart' as DeviceListState;
|
||||
@@ -18,6 +20,7 @@ import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_statu
|
||||
import '../bloc/drone_station_bloc.dart';
|
||||
import '../bloc/drone_station_event.dart';
|
||||
import '../bloc/drone_station_state.dart';
|
||||
import 'ble_device_detail_page.dart';
|
||||
import '../bloc/robot_list_bloc.dart'; // 🔥 添加 RobotListBloc 导入
|
||||
import '../bloc/robot_list_event.dart'; // 🔥 添加 RobotListEvent 导入
|
||||
import '../bloc/robot_list_state.dart'; // 🔥 添加 RobotListState 导入
|
||||
@@ -71,9 +74,29 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
/// 当前缓存的 siteId,用于判断是否真的发生了变化
|
||||
int? _currentSiteId;
|
||||
|
||||
/// 🔥 下拉刷新计数器:变更时联动重建"全部"标签下的机器人/无人机场内嵌列表,
|
||||
/// 使其与统计卡片(DeviceStatusBloc)同步刷新,避免统计数字与实际卡片对不上
|
||||
int _refreshTick = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 🔥 个人角色(roleKey == 'personal')只有一个场站,自动默认选中第一个
|
||||
final roleKey = sl<AppUserCubit>().state.user?.roleKey;
|
||||
if (roleKey == TabConfig.personalRoleKey) {
|
||||
final siteCubit = sl<SiteCubit>();
|
||||
if (siteCubit.state.sites.isEmpty) {
|
||||
// 场站列表为空则先加载
|
||||
siteCubit.loadSites();
|
||||
} else if (siteCubit.state.selectedSite == null) {
|
||||
// 还没有选中场站则自动选中第一个
|
||||
final firstSite = siteCubit.state.sites.first;
|
||||
debugPrint(
|
||||
'🔥 [DeviceStatus] 个人角色自动选中首个场站: ${firstSite.siteName} (id=${firstSite.id})',
|
||||
);
|
||||
siteCubit.selectSite(firstSite);
|
||||
}
|
||||
}
|
||||
_currentSiteId = sl<SiteCubit>().state.selectedSite?.id;
|
||||
_siteSub = sl<SiteCubit>().stream.listen((siteState) {
|
||||
if (!mounted) return;
|
||||
@@ -210,6 +233,10 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
// 🔥 个人角色(roleKey == 'personal')隐藏场站选择器
|
||||
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
|
||||
final showSiteSelector = roleKey != TabConfig.personalRoleKey;
|
||||
|
||||
return Container(
|
||||
height: 44.0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
@@ -218,8 +245,10 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
const Flexible(child: SiteSelectorWidget(compact: true)),
|
||||
const SizedBox(width: 8),
|
||||
if (showSiteSelector) ...[
|
||||
const Flexible(child: SiteSelectorWidget(compact: true)),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Container(
|
||||
width: 1,
|
||||
height: 20,
|
||||
@@ -397,24 +426,40 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
}
|
||||
|
||||
Widget _buildTypeFilterBar(BuildContext context) {
|
||||
final typeCodes = [
|
||||
'all',
|
||||
'robot',
|
||||
'drone_station',
|
||||
'inverter',
|
||||
'combiner_box',
|
||||
'module',
|
||||
'monitor',
|
||||
];
|
||||
final typeLabels = [
|
||||
AppLocalizations.of(context).translate('device_list_v2.all'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.robot'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.drone_station'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.inverter'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.combiner_box'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.module'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.monitor'),
|
||||
];
|
||||
// 🔥 个人角色(roleKey == 'personal')只显示「全部、机器人、无人机」3 个 tab
|
||||
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
|
||||
final isPersonal = roleKey == TabConfig.personalRoleKey;
|
||||
|
||||
List<String> typeCodes;
|
||||
List<String> typeLabels;
|
||||
|
||||
if (isPersonal) {
|
||||
typeCodes = ['all', 'robot', 'drone_station'];
|
||||
typeLabels = [
|
||||
AppLocalizations.of(context).translate('device_list_v2.all'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.robot'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.drone_station'),
|
||||
];
|
||||
} else {
|
||||
typeCodes = [
|
||||
'all',
|
||||
'robot',
|
||||
'drone_station',
|
||||
'inverter',
|
||||
'combiner_box',
|
||||
'module',
|
||||
'monitor',
|
||||
];
|
||||
typeLabels = [
|
||||
AppLocalizations.of(context).translate('device_list_v2.all'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.robot'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.drone_station'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.inverter'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.combiner_box'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.module'),
|
||||
AppLocalizations.of(context).translate('device_list_v2.monitor'),
|
||||
];
|
||||
}
|
||||
|
||||
return BlocBuilder<
|
||||
DeviceListBloc.DeviceStatusBloc,
|
||||
@@ -426,56 +471,81 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
selectedType = state.selectedType;
|
||||
}
|
||||
|
||||
// 获取当前选中的索引
|
||||
int selectedIndex = typeCodes.indexOf(selectedType);
|
||||
if (selectedIndex == -1) selectedIndex = 0; // 默认为 'all'
|
||||
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
itemCount: typeCodes.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 24.0),
|
||||
itemBuilder: (context, index) {
|
||||
final typeCode = typeCodes[index];
|
||||
final typeLabel = typeLabels[index];
|
||||
final isSelected = selectedType == typeCode;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: (ScrollNotification notification) {
|
||||
// 🔥 监听滑动结束事件,自动切换选中状态
|
||||
if (notification is ScrollEndNotification &&
|
||||
notification.metrics.axis == Axis.horizontal) {
|
||||
final position = notification.metrics.pixels;
|
||||
final itemWidth = 56.0; // 预估每个 tab 的宽度(包括间距)
|
||||
final newIndex = (position / itemWidth).round();
|
||||
|
||||
if (newIndex >= 0 &&
|
||||
newIndex < typeCodes.length &&
|
||||
newIndex != selectedIndex) {
|
||||
final newTypeCode = typeCodes[newIndex];
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusChangeType(typeCode),
|
||||
DeviceListEvent.DeviceStatusChangeType(newTypeCode),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: isSelected
|
||||
? context.appColors.primary
|
||||
: context.appColors.textSecondary,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 20,
|
||||
height: 2,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.appColors.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
itemCount: typeCodes.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 24.0),
|
||||
itemBuilder: (context, index) {
|
||||
final typeCode = typeCodes[index];
|
||||
final typeLabel = typeLabels[index];
|
||||
final isSelected = selectedType == typeCode;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
DeviceListEvent.DeviceStatusChangeType(typeCode),
|
||||
);
|
||||
},
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
typeLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: isSelected
|
||||
? context.appColors.primary
|
||||
: context.appColors.textSecondary,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 20,
|
||||
height: 2,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? context.appColors.primary
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -550,9 +620,16 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
// 🔥 同步刷新:统计卡片(DeviceStatusBloc) + 下方机器人列表(RobotListBloc)
|
||||
// + 无人机场列表(DroneStationBloc)。三者数据源相互独立,只刷统计会导致
|
||||
// 数字与实际卡片对不上,故用 _refreshTick 变更内嵌列表的 ValueKey 强制
|
||||
// 重建对应 BlocProvider,使其重新拉取数据。
|
||||
context.read<DeviceListBloc.DeviceStatusBloc>().add(
|
||||
const DeviceListEvent.DeviceStatusRefresh(),
|
||||
);
|
||||
if (state.selectedType == 'all') {
|
||||
setState(() => _refreshTick++);
|
||||
}
|
||||
},
|
||||
color: context.appColors.primary,
|
||||
child: ListView(
|
||||
@@ -573,7 +650,7 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
_buildDroneStationList(
|
||||
context,
|
||||
embedded: true,
|
||||
key: ValueKey('embedded_drone_station_$_currentSiteId'),
|
||||
key: ValueKey('embedded_drone_station_${_currentSiteId}_$_refreshTick'),
|
||||
),
|
||||
// 🔥 普通设备列表(仅类型 tab 生效,"全部"模式下 filteredByType 为空)
|
||||
...filteredDevices
|
||||
@@ -608,7 +685,7 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
|
||||
return BlocProvider(
|
||||
// 🔥 电站切换时通过 key 变化强制重建 BlocProvider,重新用新 siteId 加载
|
||||
key: ValueKey('embedded_robot_$siteId'),
|
||||
key: ValueKey('embedded_robot_${siteId}_$_refreshTick'),
|
||||
create: (_) =>
|
||||
sl<RobotListBloc>()..add(RobotListLoadData(siteId: siteId)),
|
||||
child: BlocConsumer<RobotListBloc, RobotListState>(
|
||||
@@ -677,6 +754,36 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
debugPrint(
|
||||
'🔴🔴🔴 [全部-选中机器人] name: ${robot.name}, id: ${robot.id}, type: ${robot.type}',
|
||||
);
|
||||
debugPrint(
|
||||
'🔴🔴🔴 [全部-选中机器人] hasHost: ${robot.hasHost}',
|
||||
);
|
||||
|
||||
// 🔥 hasHost 字段判定:false → 蓝牙连接流程
|
||||
if (!robot.hasHost) {
|
||||
debugPrint('⚠️ [全部-EmbeddedRobotList] hasHost=false,进入蓝牙连接流程');
|
||||
|
||||
// 从设备名中提取时间戳(如 MC700PRO-CN-JS-1751525588120-0000000A-9527 → 1751525588120)
|
||||
final timestamp = _extractTimestamp(robot.name);
|
||||
if (timestamp == null || timestamp.isEmpty) {
|
||||
debugPrint('❌ [全部-EmbeddedRobotList] 未提取到时间戳');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('设备名称异常,暂时无法连接'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('✅ [全部-EmbeddedRobotList] 提取到时间戳: $timestamp');
|
||||
|
||||
// 调用蓝牙连接流程
|
||||
_connectToDeviceViaBluetooth(context, robot.name, timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
// hasHost == true → 走原来的逻辑
|
||||
debugPrint('✅ [全部-EmbeddedRobotList] hasHost=true,走正常控制流程');
|
||||
|
||||
// 1. 将当前机器人设置为全局待控制设备
|
||||
final device = DeviceEntity(
|
||||
@@ -871,12 +978,12 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
BuildContext context,
|
||||
DeviceListState.DeviceStatusLoaded state,
|
||||
) {
|
||||
// 从实际设备列表计算统计数据(不再使用硬编码的假数据)
|
||||
// 从实际设备列表计算统计数据(设备状态仅有"在线/离线"两种,接口不会返回"异常",
|
||||
// 故不再统计恒为 0 的"告警"项)
|
||||
final devices = state.devices;
|
||||
final totalCount = devices.length;
|
||||
final onlineCount = devices.where((d) => d.status == '在线').length;
|
||||
final exceptionCount = devices.where((d) => d.status == '异常').length;
|
||||
final offlineCount = totalCount - onlineCount - exceptionCount;
|
||||
final offlineCount = totalCount - onlineCount;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
@@ -912,15 +1019,6 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
textColor: context.appColors.success,
|
||||
),
|
||||
Container(width: 1, height: 40, color: context.appColors.divider),
|
||||
_buildStatItem(
|
||||
value: exceptionCount.toString(),
|
||||
label: AppLocalizations.of(
|
||||
context,
|
||||
).translate('device_list_v2.alarm'),
|
||||
dotColor: context.appColors.warning,
|
||||
textColor: context.appColors.warning,
|
||||
),
|
||||
Container(width: 1, height: 40, color: context.appColors.divider),
|
||||
_buildStatItem(
|
||||
value: offlineCount.toString(),
|
||||
label: AppLocalizations.of(
|
||||
@@ -1234,4 +1332,186 @@ class _DeviceStatusViewState extends State<DeviceStatusView> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 从设备名中提取时间戳
|
||||
String? _extractTimestamp(String deviceName) {
|
||||
final regex = RegExp(r'(\d{13})');
|
||||
final match = regex.firstMatch(deviceName);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
/// 🔥 蓝牙连接流程
|
||||
Future<void> _connectToDeviceViaBluetooth(
|
||||
BuildContext context,
|
||||
String deviceName,
|
||||
String timestamp,
|
||||
) async {
|
||||
final bleManager = BleManager.instance;
|
||||
|
||||
// 🔥 0. 优先检查:如果该设备已经连接,直接跳转详情页,不再扫描
|
||||
final connectedDevice = bleManager.connectedDevice;
|
||||
if (connectedDevice != null) {
|
||||
final connectedName = bleManager.connectedDeviceName ?? '';
|
||||
debugPrint('🔍 [蓝牙连接] 已连接设备名: $connectedName,匹配时间戳: $timestamp');
|
||||
if (connectedName.contains(timestamp)) {
|
||||
debugPrint('✅ [蓝牙连接] 设备已连接,直接跳转详情页: $connectedName');
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(
|
||||
device: connectedDevice,
|
||||
displayName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 检查蓝牙状态
|
||||
final isBluetoothOn = await bleManager.checkBluetooth();
|
||||
if (!isBluetoothOn) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('请先打开手机蓝牙'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. 显示加载圈
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('正在搜索蓝牙设备...', style: TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 启动蓝牙扫描
|
||||
await bleManager.startScan(continuous: false);
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
// 4. 关闭加载圈
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// 5. 获取扫描结果并查找匹配设备
|
||||
final scanResults = bleManager.getScanResults();
|
||||
BluetoothDevice? matchedDevice;
|
||||
for (final result in scanResults) {
|
||||
final devName = result.device.platformName.isNotEmpty
|
||||
? result.device.platformName
|
||||
: result.device.advName;
|
||||
if (devName.contains(timestamp)) {
|
||||
matchedDevice = result.device;
|
||||
debugPrint('✅ [蓝牙连接] 找到匹配设备: $devName');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedDevice != null) {
|
||||
debugPrint('🚀 [蓝牙连接] 跳转到 BLE 详情页并连接');
|
||||
final device = matchedDevice;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(
|
||||
device: device,
|
||||
displayName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint('⚠️ [蓝牙连接] 未在扫描列表中找到设备,弹窗提示并后台监测');
|
||||
_showNotFoundDialogAndMonitor(context, deviceName, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 弹窗提示并后台持续监测设备(单例模式,防止重复弹窗)
|
||||
void _showNotFoundDialogAndMonitor(
|
||||
BuildContext context,
|
||||
String deviceName,
|
||||
String timestamp,
|
||||
) {
|
||||
// 🔥 检查是否已有弹窗存在,如果有则先关闭
|
||||
if (Navigator.of(context).canPop()) {
|
||||
debugPrint('⚠️ [后台监测] 检测到已有弹窗,先关闭上一个');
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
bool dialogShown = true;
|
||||
StreamSubscription? subscription;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
dialogShown = true;
|
||||
return AlertDialog(
|
||||
title: const Text('未找到设备'),
|
||||
content: const Text('暂未匹配到该设备,稍后重试'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
dialogShown = false;
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
dialogShown = false;
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// 启动后台监测
|
||||
final bleManager = BleManager.instance;
|
||||
subscription = bleManager.scanResults.listen((results) {
|
||||
if (!dialogShown) {
|
||||
subscription?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
for (final result in results) {
|
||||
final devName = result.device.platformName.isEmpty
|
||||
? result.device.advName
|
||||
: result.device.platformName;
|
||||
|
||||
if (devName.contains(timestamp)) {
|
||||
debugPrint('✅ [后台监测] 匹配成功!设备名: $devName');
|
||||
subscription?.cancel();
|
||||
|
||||
// 🔥 弹窗可能已关闭,需要先 pop 当前弹窗再跳转
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(
|
||||
device: result.device,
|
||||
displayName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:maibu_satabot_v2/core/bluetooth/ble_manager.dart';
|
||||
import 'package:maibu_satabot_v2/core/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
|
||||
import '../../../../../core/di/injection.dart';
|
||||
import '../../../../../core/logging/i_logger_service.dart';
|
||||
import '../../../../devices/presentation/bloc/devices_cubit.dart';
|
||||
import '../../../../devices/domain/entities/device_entity.dart';
|
||||
import '../../../../devices/presentation/bloc/device_status_bloc.dart';
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
import '../../../../v2/site/presentation/cubit/site_cubit.dart';
|
||||
import '../../data/models/robot_data_model.dart';
|
||||
@@ -14,6 +19,7 @@ import '../bloc/robot_list_bloc.dart';
|
||||
import '../bloc/robot_list_event.dart';
|
||||
import '../bloc/robot_list_state.dart';
|
||||
import '../widgets/robot_item_card.dart';
|
||||
import 'ble_device_detail_page.dart';
|
||||
import 'robot_control_page.dart';
|
||||
import 'cleaning_weeding_robot_task_page.dart';
|
||||
|
||||
@@ -87,7 +93,8 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
children: [
|
||||
_buildStatsCard(state),
|
||||
_buildQuickActions(state),
|
||||
_buildCurrentTask(state),
|
||||
// 🔥 个人角色暂时注释掉当前任务卡片
|
||||
// _buildCurrentTask(state),
|
||||
..._buildRobotList(state),
|
||||
],
|
||||
),
|
||||
@@ -646,6 +653,38 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
debugPrint(
|
||||
'🔴🔴🔴 [选中机器人] status: ${robot.status}, battery: ${robot.battery}, task: ${robot.task}',
|
||||
);
|
||||
debugPrint(
|
||||
'🔴🔴🔴 [选中机器人] hasHost: ${robot.hasHost}',
|
||||
);
|
||||
|
||||
// 🔥 hasHost 字段判定:false → 蓝牙连接流程,true 走原逻辑
|
||||
if (!robot.hasHost) {
|
||||
// hasHost == false 或接口未返回该字段 → 蓝牙连接流程
|
||||
debugPrint('⚠️ [RobotListPage] hasHost=false,进入蓝牙连接流程');
|
||||
debugPrint('📱 [RobotListPage] 机器人名称: ${robot.name}');
|
||||
|
||||
// 从设备名中提取时间戳(例如:MC700PRO-CN-JS-1751525588120-0000000A-9527)
|
||||
final timestamp = _extractTimestamp(robot.name);
|
||||
if (timestamp == null || timestamp.isEmpty) {
|
||||
debugPrint('❌ [RobotListPage] 无法从设备名中提取时间戳: ${robot.name}');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('设备名称异常,暂时无法连接'),
|
||||
backgroundColor: context.appColors.warning,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('✅ [RobotListPage] 提取到时间戳: $timestamp');
|
||||
|
||||
// 调用蓝牙连接流程
|
||||
_connectToDeviceViaBluetooth(context, robot.name, timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
// hasHost == true → 走原来的逻辑
|
||||
debugPrint('✅ [RobotListPage] hasHost=true,走正常控制流程');
|
||||
|
||||
// 1. 将当前机器人设置为全局待控制设备(用 robot.name 作为 deviceName)
|
||||
final device = DeviceEntity(
|
||||
@@ -687,4 +726,228 @@ class _RobotListViewState extends State<RobotListView> {
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 🔥 从设备名中提取时间戳
|
||||
/// 例如:MC700PRO-CN-JS-1751525588120-0000000A-9527 → 1751525588120
|
||||
String? _extractTimestamp(String deviceName) {
|
||||
try {
|
||||
// 使用正则表达式匹配中间的一串数字(13位时间戳)
|
||||
final regex = RegExp(r'(\d{13})');
|
||||
final match = regex.firstMatch(deviceName);
|
||||
if (match != null) {
|
||||
return match.group(1);
|
||||
}
|
||||
debugPrint('⚠️ [_extractTimestamp] 未找到时间戳格式,返回 null');
|
||||
return null;
|
||||
} catch (e) {
|
||||
debugPrint('❌ [_extractTimestamp] 解析失败: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 蓝牙连接流程
|
||||
/// 1. 检查蓝牙状态
|
||||
/// 2. 在 BLE 扫描列表中查找包含时间戳的设备
|
||||
/// 3. 如果找到则连接并跳转详情页
|
||||
/// 4. 如果没找到则弹窗提示并后台持续监测
|
||||
Future<void> _connectToDeviceViaBluetooth(
|
||||
BuildContext context,
|
||||
String deviceName,
|
||||
String timestamp,
|
||||
) async {
|
||||
final bleManager = BleManager.instance;
|
||||
|
||||
// 🔥 0. 优先检查:如果该设备已经连接,直接跳转详情页,不再扫描
|
||||
final connectedDevice = bleManager.connectedDevice;
|
||||
if (connectedDevice != null) {
|
||||
final connectedName = bleManager.connectedDeviceName ?? '';
|
||||
debugPrint('🔍 [蓝牙连接] 已连接设备名: $connectedName,匹配时间戳: $timestamp');
|
||||
if (connectedName.contains(timestamp)) {
|
||||
debugPrint('✅ [蓝牙连接] 设备已连接,直接跳转详情页: $connectedName');
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(
|
||||
device: connectedDevice,
|
||||
displayName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 检查蓝牙是否打开
|
||||
final isBluetoothOn = await bleManager.checkBluetooth();
|
||||
if (!isBluetoothOn) {
|
||||
debugPrint('❌ [蓝牙连接] 蓝牙未打开,提示用户');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text('请先打开蓝牙'),
|
||||
backgroundColor: context.appColors.warning,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('✅ [蓝牙连接] 蓝牙已打开,开始搜索设备: $deviceName');
|
||||
|
||||
// 2. 显示加载圈
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('正在搜索蓝牙设备...', style: TextStyle(color: Colors.white)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. 启动蓝牙扫描(只扫一次,不要连续扫)
|
||||
try {
|
||||
await bleManager.startScan(continuous: false);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [蓝牙连接] 启动扫描失败: $e');
|
||||
}
|
||||
|
||||
// 4. 等待一下让扫描结果出现
|
||||
await Future.delayed(const Duration(milliseconds: 1500));
|
||||
|
||||
// 5. 关闭加载圈
|
||||
Navigator.of(context).pop();
|
||||
|
||||
// 6. 获取当前扫描结果列表
|
||||
final scanResults = bleManager.getScanResults();
|
||||
debugPrint('🔍 [蓝牙连接] 扫描到 ${scanResults.length} 个设备');
|
||||
|
||||
// 5. 查找包含时间戳的设备
|
||||
BluetoothDevice? matchedDevice;
|
||||
for (final result in scanResults) {
|
||||
final dev = result.device;
|
||||
final devName = dev.platformName.isEmpty
|
||||
? (dev.advName.isEmpty ? dev.remoteId.toString() : dev.advName)
|
||||
: dev.platformName;
|
||||
|
||||
debugPrint(' 📱 发现设备: $devName (包含时间戳?: ${devName.contains(timestamp)})');
|
||||
|
||||
// 检查设备名是否包含时间戳
|
||||
if (devName.contains(timestamp)) {
|
||||
debugPrint('✅ [蓝牙连接] 匹配成功!设备名: $devName');
|
||||
matchedDevice = dev;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedDevice != null) {
|
||||
debugPrint('🚀 [蓝牙连接] 跳转到 BLE 详情页并连接');
|
||||
// 跳转到 BLE 设备详情页(详情页会自动连接)
|
||||
final device = matchedDevice;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(
|
||||
device: device,
|
||||
displayName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
debugPrint('⚠️ [蓝牙连接] 未在扫描列表中找到设备,弹窗提示并后台监测');
|
||||
// 6. 没找到设备,弹窗提示并后台持续监测
|
||||
_showNotFoundDialogAndMonitor(context, deviceName, timestamp);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔥 弹窗提示并后台持续监测设备(单例模式,防止重复弹窗)
|
||||
void _showNotFoundDialogAndMonitor(
|
||||
BuildContext context,
|
||||
String deviceName,
|
||||
String timestamp,
|
||||
) {
|
||||
// 🔥 检查是否已有弹窗存在,如果有则先关闭
|
||||
if (Navigator.of(context).canPop()) {
|
||||
debugPrint('⚠️ [后台监测] 检测到已有弹窗,先关闭上一个');
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
bool dialogShown = true;
|
||||
StreamSubscription? subscription;
|
||||
|
||||
// 先显示对话框
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
dialogShown = true;
|
||||
return AlertDialog(
|
||||
title: const Text('未找到设备'),
|
||||
content: const Text('暂未匹配到该设备,稍后重试'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
dialogShown = false;
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
dialogShown = false;
|
||||
Navigator.pop(dialogContext);
|
||||
},
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((_) {
|
||||
// 对话框关闭后不停止监测,继续运行
|
||||
});
|
||||
|
||||
// 启动后台监测 BLE 扫描列表
|
||||
final bleManager = BleManager.instance;
|
||||
subscription = bleManager.scanResults.listen((results) {
|
||||
if (!dialogShown) {
|
||||
// 对话框已关闭,停止监测
|
||||
subscription?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('🔍 [后台监测] 扫描到 ${results.length} 个设备,搜索时间戳: $timestamp');
|
||||
|
||||
for (final result in results) {
|
||||
final dev = result.device;
|
||||
final devName = dev.platformName.isEmpty
|
||||
? (dev.advName.isEmpty ? dev.remoteId.toString() : dev.advName)
|
||||
: dev.platformName;
|
||||
|
||||
if (devName.contains(timestamp)) {
|
||||
debugPrint('✅ [后台监测] 匹配成功!设备名: $devName');
|
||||
subscription?.cancel();
|
||||
|
||||
// 🔥 弹窗可能已关闭,需要先 pop 当前弹窗再跳转
|
||||
if (Navigator.of(context).canPop()) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => BleDeviceDetailPage(
|
||||
device: dev!,
|
||||
displayName: deviceName,
|
||||
),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/power_tre
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/plant_overview_card.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/home/presentation/widgets/tcp_status_indicator.dart';
|
||||
import 'package:maibu_satabot_v2/components/device_status_modal.dart';
|
||||
import 'package:maibu_satabot_v2/components/agora_video_player.dart';
|
||||
import 'package:maibu_satabot_v2/features/devices/presentation/bloc/device_status_bloc.dart';
|
||||
|
||||
import '../../../../remote_control/presentation/bloc/remote_control_cubit.dart';
|
||||
@@ -31,6 +32,14 @@ class HomeV2Page extends StatefulWidget {
|
||||
class _HomeV2PageState extends State<HomeV2Page> {
|
||||
late HomeV2Bloc _bloc;
|
||||
|
||||
// ===== 电站实时视频(声网)默认测试配置 =====
|
||||
// 弹窗打开就用这组值自动开播,输入框仍保留,可随时改完点「重新加载」
|
||||
// TODO: 接口对接后改为后端下发的真实值
|
||||
static const String _agoraAppId = '875f6494d87548e783fefbf33f598c97';
|
||||
static const String _agoraChannel = '36test-01';
|
||||
static const String _agoraToken =
|
||||
'007eJxTYLhsYtaz3e2gTMKZ27JzrOs/KB3OTFaYKfGX2+/Y6zem5TMUGCzMTdPMTCxNUoAME4tUcwvjtNS0pDRj4zRTS4tkS3P55PlZDYGMDGsfyTEyMkAgiM/JYGxWklpcomtgyMAAAN9rIEI=';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -203,6 +212,7 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
const SizedBox(height: 10),
|
||||
PlantOverviewCard(
|
||||
overview: state.homeData.plantOverview,
|
||||
onTap: _showPlantVideo,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
WorkOrderCard(stats: state.homeData.workOrderStats),
|
||||
@@ -247,6 +257,19 @@ class _HomeV2PageState extends State<HomeV2Page> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 点击「电站概览」卡片 → 弹出声网视频组件
|
||||
///
|
||||
/// 默认带上测试配置自动开播,同时保留三个输入框可手动修改
|
||||
void _showPlantVideo() {
|
||||
AgoraVideoPlayer.showManualInput(
|
||||
context,
|
||||
title: '电站实时视频',
|
||||
initialAppId: _agoraAppId,
|
||||
initialChannelId: _agoraChannel,
|
||||
initialToken: _agoraToken,
|
||||
);
|
||||
}
|
||||
|
||||
void _showPlantSelector() {
|
||||
if (_bloc.state is! HomeV2Loaded) return;
|
||||
|
||||
|
||||
@@ -6,10 +6,21 @@ import 'package:maibu_satabot_v2/features/v2/home/domain/entities/home_entity.da
|
||||
class PlantOverviewCard extends StatelessWidget {
|
||||
final PlantOverview overview;
|
||||
|
||||
const PlantOverviewCard({super.key, required this.overview});
|
||||
/// 点击整张卡片的回调(如弹出电站实时视频)
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const PlantOverviewCard({super.key, required this.overview, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: _buildContent(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
|
||||
@@ -53,12 +53,13 @@ class MyRemoteDataSourceImpl implements MyRemoteDataSource {
|
||||
title: '帮助与反馈',
|
||||
icon: 'help',
|
||||
),
|
||||
MenuItemModel(
|
||||
id: 'about_us',
|
||||
title: '关于我们',
|
||||
icon: 'info',
|
||||
subTitle: 'V1.0.1',
|
||||
),
|
||||
// 关于我们(已注释掉,其位置由“退出登录”占用)
|
||||
// MenuItemModel(
|
||||
// id: 'about_us',
|
||||
// title: '关于我们',
|
||||
// icon: 'info',
|
||||
// subTitle: 'V1.0.1',
|
||||
// ),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get_it/get_it.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/localization/app_localizations.dart';
|
||||
import 'package:maibu_satabot_v2/core/router/route_paths.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
|
||||
import 'package:maibu_satabot_v2/core/theme/theme_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/my/presentation/cubit/my_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/my/presentation/states/my_state.dart';
|
||||
import 'package:maibu_satabot_v2/features/v2/my/di/my_di.dart';
|
||||
@@ -68,7 +73,8 @@ class _MyPageContent extends StatelessWidget {
|
||||
final userName = user?.nickname ?? user?.username ?? '未知用户';
|
||||
final userRole = user?.email ?? user?.phone ?? '未设置';
|
||||
|
||||
return Column(
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// 用户信息卡片(延伸到状态栏)
|
||||
UserProfileCard(
|
||||
@@ -119,14 +125,17 @@ class _MyPageContent extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.5,
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: state.menuItems.length,
|
||||
// 末尾追加退出登录,占据“关于我们”原来的位置
|
||||
itemCount: state.menuItems.length + 1,
|
||||
itemBuilder: (context, index) {
|
||||
// 退出登录(列表最后一项,在可滚动区域内避免底部溢出)
|
||||
if (index == state.menuItems.length) {
|
||||
return _buildLogoutButton(context);
|
||||
}
|
||||
final item = state.menuItems[index];
|
||||
// 🔥 夜间模式开关值以全局 ThemeCubit 状态为准
|
||||
final displayItem = item.id == 'night_mode'
|
||||
@@ -150,6 +159,7 @@ class _MyPageContent extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -221,4 +231,61 @@ class _MyPageContent extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 退出登录(作为菜单列表最后一项,占据“关于我们”原来的位置)
|
||||
Widget _buildLogoutButton(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_showLogoutDialog(context);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Center(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('login.logout'),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFFF53F3F),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示退出登录确认对话框
|
||||
void _showLogoutDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context).translate('login.logout')),
|
||||
content: Text(
|
||||
AppLocalizations.of(context).translate('login.confirm_logout'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context).translate('my_v2.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
// 执行退出登录逻辑
|
||||
await GetIt.I<UserStorage>().deleteUser();
|
||||
GetIt.I<AuthCubit>().logout();
|
||||
context.go(RoutePaths.login);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFF53F3F),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('my_v2.confirm'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'package:maibu_satabot_v2/core/theme/AppTheme.dart';
|
||||
import 'package:maibu_satabot_v2/core/storage/user_storage.dart';
|
||||
import 'package:maibu_satabot_v2/features/auth/presentation/bloc/auth_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/main_container/presentation/cubit/tab_config_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/features/main_container/domain/tab_config.dart';
|
||||
import 'package:maibu_satabot_v2/core/app/app_user_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/core/update/update_cubit.dart';
|
||||
import 'package:maibu_satabot_v2/core/update/update_state.dart';
|
||||
@@ -77,9 +79,9 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
||||
// 版本更新
|
||||
_buildUpdateSection(),
|
||||
const SizedBox(height: 12),
|
||||
// 退出登录
|
||||
_buildLogoutButton(context),
|
||||
const SizedBox(height: 20),
|
||||
// 退出登录(已挪至“我的”页面最下边,此处注释掉)
|
||||
// _buildLogoutButton(context),
|
||||
// const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -146,6 +148,9 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
||||
|
||||
/// Tab 设置
|
||||
Widget _buildTabSettingsSection() {
|
||||
// 🔥 个人角色(roleKey == 'personal'):Tab 设置只列出「设备 + 我的」,且开关常开锁定
|
||||
final roleKey = context.watch<AppUserCubit>().state.user?.roleKey;
|
||||
final isPersonal = roleKey == TabConfig.personalRoleKey;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
@@ -177,7 +182,7 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final tabs = state.config.items;
|
||||
final tabs = state.config.settingItemsForRole(roleKey);
|
||||
|
||||
return ListView.builder(
|
||||
shrinkWrap: true,
|
||||
@@ -199,10 +204,13 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: tab.isEnabled,
|
||||
onChanged: (value) {
|
||||
context.read<TabConfigCubit>().toggleTab(tab.id);
|
||||
},
|
||||
// 个人角色:设备/我的 强制常开且不可关闭
|
||||
value: isPersonal ? true : tab.isEnabled,
|
||||
onChanged: isPersonal
|
||||
? null
|
||||
: (value) {
|
||||
context.read<TabConfigCubit>().toggleTab(tab.id);
|
||||
},
|
||||
activeColor: const Color(0xFF165DFF),
|
||||
),
|
||||
],
|
||||
@@ -421,70 +429,70 @@ class _SystemSettingsPageState extends State<SystemSettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 退出登录按钮
|
||||
Widget _buildLogoutButton(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
_showLogoutDialog(context);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: context.appColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: context.appColors.cardShadow,
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('login.logout'),
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFFF53F3F),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
/// 退出登录按钮(已挪至“我的”页面最下边,此处注释掉)
|
||||
// Widget _buildLogoutButton(BuildContext context) {
|
||||
// return GestureDetector(
|
||||
// onTap: () {
|
||||
// _showLogoutDialog(context);
|
||||
// },
|
||||
// child: Container(
|
||||
// padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
// decoration: BoxDecoration(
|
||||
// color: context.appColors.cardBackground,
|
||||
// borderRadius: BorderRadius.circular(12),
|
||||
// boxShadow: [
|
||||
// BoxShadow(
|
||||
// color: context.appColors.cardShadow,
|
||||
// blurRadius: 8,
|
||||
// offset: Offset(0, 2),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// child: Center(
|
||||
// child: Text(
|
||||
// AppLocalizations.of(context).translate('login.logout'),
|
||||
// style: const TextStyle(
|
||||
// fontSize: 16,
|
||||
// fontWeight: FontWeight.w500,
|
||||
// color: Color(0xFFF53F3F),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
|
||||
/// 显示退出登录确认对话框
|
||||
void _showLogoutDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(AppLocalizations.of(context).translate('login.logout')),
|
||||
content: Text(
|
||||
AppLocalizations.of(context).translate('login.confirm_logout'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(AppLocalizations.of(context).translate('my_v2.cancel')),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
// 执行退出登录逻辑
|
||||
await GetIt.I<UserStorage>().deleteUser();
|
||||
GetIt.I<AuthCubit>().logout();
|
||||
context.go(RoutePaths.login);
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: const Color(0xFFF53F3F),
|
||||
),
|
||||
child: Text(
|
||||
AppLocalizations.of(context).translate('my_v2.confirm'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
/// 显示退出登录确认对话框(已挪至“我的”页面最下边,此处注释掉)
|
||||
// void _showLogoutDialog(BuildContext context) {
|
||||
// showDialog(
|
||||
// context: context,
|
||||
// builder: (context) => AlertDialog(
|
||||
// title: Text(AppLocalizations.of(context).translate('login.logout')),
|
||||
// content: Text(
|
||||
// AppLocalizations.of(context).translate('login.confirm_logout'),
|
||||
// ),
|
||||
// actions: [
|
||||
// TextButton(
|
||||
// onPressed: () => Navigator.pop(context),
|
||||
// child: Text(AppLocalizations.of(context).translate('my_v2.cancel')),
|
||||
// ),
|
||||
// TextButton(
|
||||
// onPressed: () async {
|
||||
// Navigator.pop(context);
|
||||
// // 执行退出登录逻辑
|
||||
// await GetIt.I<UserStorage>().deleteUser();
|
||||
// GetIt.I<AuthCubit>().logout();
|
||||
// context.go(RoutePaths.login);
|
||||
// },
|
||||
// style: TextButton.styleFrom(
|
||||
// foregroundColor: const Color(0xFFF53F3F),
|
||||
// ),
|
||||
// child: Text(
|
||||
// AppLocalizations.of(context).translate('my_v2.confirm'),
|
||||
// ),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
}
|
||||
|
||||
204
package-lock.json
generated
Normal file
204
package-lock.json
generated
Normal file
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"name": "flutter-app-02",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"docx": "^9.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
|
||||
"integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.7.1.tgz",
|
||||
"integrity": "sha512-ilXFf9Moz47ABjFpDiA5s1w9lpb4EFSp7+5iiJSbfyYDM+bpZdAgLlSr7fW4aXhVe/E+F6QCv0EvRVFEd5CsWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "5.1.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz",
|
||||
"integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"docx": "^9.7.1"
|
||||
}
|
||||
}
|
||||
BIN
satabot-user-guide/_shared/fonts/Outfit-Bold.ttf
Normal file
BIN
satabot-user-guide/_shared/fonts/Outfit-Bold.ttf
Normal file
Binary file not shown.
93
satabot-user-guide/_shared/fonts/Outfit-OFL.txt
Normal file
93
satabot-user-guide/_shared/fonts/Outfit-OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2021 The Outfit Project Authors (https://github.com/Outfitio/Outfit-Fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://openfontlicense.org
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
BIN
satabot-user-guide/_shared/fonts/Outfit-Regular.ttf
Normal file
BIN
satabot-user-guide/_shared/fonts/Outfit-Regular.ttf
Normal file
Binary file not shown.
1200
satabot-user-guide/satabot-user-guide.html
Normal file
1200
satabot-user-guide/satabot-user-guide.html
Normal file
File diff suppressed because it is too large
Load Diff
57
test/ble_protocol_decoder_test.dart
Normal file
57
test/ble_protocol_decoder_test.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:maibu_satabot_v2/core/bluetooth/ble_protocol_decoder.dart';
|
||||
import 'package:maibu_satabot_v2/core/protocol/machine_protocol_constants.dart';
|
||||
|
||||
void main() {
|
||||
// 协议表示例:24 个逗号字段,控制模式=2(蓝牙),障碍物=1(有障碍)
|
||||
const sheet =
|
||||
'52.2,2000,2000,190,190,45.2,46.6,40.5,40.5,40,50,5,5,32,5,1,'
|
||||
'120.772,32.078,2026-09-10 12:34:56,2000,2,98,12.65,1';
|
||||
|
||||
Uint8List payloadWith(String body, List<int> tail) =>
|
||||
Uint8List.fromList([...utf8.encode(body), ...tail]);
|
||||
|
||||
String valueOf(BleDecodedResult r, String label) =>
|
||||
r.fields.firstWhere((f) => f.label == label).value;
|
||||
|
||||
test('0x02 带2字节CRC:控制模式/障碍物解析正确', () {
|
||||
final r = BleProtocolDecoder.decode(
|
||||
MachineProtocolConstants.cmdStatusInfo,
|
||||
payloadWith(sheet, [0x12, 0x34]), // 尾部 CRC16 小端
|
||||
);
|
||||
expect(valueOf(r, '控制模式'), '蓝牙');
|
||||
expect(valueOf(r, '障碍物'), '有障碍');
|
||||
expect(valueOf(r, '电压'), '52.20 V'); // _tryParseDouble 保留两位小数
|
||||
expect(valueOf(r, '电量'), '98%');
|
||||
});
|
||||
|
||||
test('0x02 串尾带\\0再加CRC:仍解析正确(不乱码)', () {
|
||||
final r = BleProtocolDecoder.decode(
|
||||
MachineProtocolConstants.cmdStatusInfo,
|
||||
payloadWith('$sheet\x00', [0x12, 0x34]),
|
||||
);
|
||||
expect(valueOf(r, '控制模式'), '蓝牙');
|
||||
expect(valueOf(r, '障碍物'), '有障碍');
|
||||
});
|
||||
|
||||
test('0x02 控制模式带空格(如" 3"):仍映射为文字 TCP', () {
|
||||
final spaced = sheet.replaceFirst(',2000,2,98', ',2000, 3,98');
|
||||
final r = BleProtocolDecoder.decode(
|
||||
MachineProtocolConstants.cmdStatusInfo,
|
||||
payloadWith(spaced, [0x12, 0x34]),
|
||||
);
|
||||
expect(valueOf(r, '控制模式'), 'TCP');
|
||||
});
|
||||
|
||||
test('0x02 控制模式为协议外码6:显示 未知(6) 而非裸6', () {
|
||||
final bad = sheet.replaceFirst(',2000,2,98', ',2000,6,98');
|
||||
final r = BleProtocolDecoder.decode(
|
||||
MachineProtocolConstants.cmdStatusInfo,
|
||||
payloadWith(bad, [0x12, 0x34]),
|
||||
);
|
||||
expect(valueOf(r, '控制模式'), '未知(6)');
|
||||
});
|
||||
}
|
||||
@@ -7,7 +7,6 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <agora_rtc_engine/agora_rtc_engine_plugin.h>
|
||||
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <flutter_blue_plus_winrt/flutter_blue_plus_plugin.h>
|
||||
#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
|
||||
@@ -21,8 +20,6 @@
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
AgoraRtcEnginePluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("AgoraRtcEnginePlugin"));
|
||||
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FlutterBluePlusPluginRegisterWithRegistrar(
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
agora_rtc_engine
|
||||
connectivity_plus
|
||||
file_selector_windows
|
||||
flutter_blue_plus_winrt
|
||||
flutter_webrtc
|
||||
|
||||
BIN
草坪作业规划APP功能使用手册.docx
Normal file
BIN
草坪作业规划APP功能使用手册.docx
Normal file
Binary file not shown.
BIN
草坪作业规划APP功能使用手册_v2.docx
Normal file
BIN
草坪作业规划APP功能使用手册_v2.docx
Normal file
Binary file not shown.
BIN
草坪作业规划APP功能使用手册_v3.docx
Normal file
BIN
草坪作业规划APP功能使用手册_v3.docx
Normal file
Binary file not shown.
Reference in New Issue
Block a user