Compare commits

15 Commits

Author SHA1 Message Date
208147cdf2 启动动画和登录页面的动画 2026-09-23 16:52:18 +08:00
b3a700491a 启动动画和登录页面的动画 2026-09-23 16:50:48 +08:00
d230279f4f 启动动画和登录页面的动画 2026-09-23 16:48:48 +08:00
32397f8899 AIR版的功能添加 2026-09-20 16:31:15 +08:00
c8b597f683 蓝牙和上位机和非上位机的功能的适配 2026-09-16 15:38:41 +08:00
a1e01da241 多角色的配置适配 2026-09-14 12:35:20 +08:00
3547f80383 修改UI 2026-09-11 16:12:44 +08:00
4b88a291a5 ios蓝牙修复写配置 2026-09-11 15:37:37 +08:00
877ff932d4 修复iOS蓝牙扫描问题和权限问题
- iOS 跳过 Android 专属权限检查(bluetoothConnect/bluetoothScan)
- iOS 跳过定位服务检查(iOS 蓝牙扫描不需要定位)
- Info.plist 添加 NSBluetoothAlwaysUsageDescription 蓝牙权限
- Podfile 修复被污染内容并添加 CDN 源
- project.pbxproj 临时修改 Bundle ID 和 Team(适配当前开发环境)
2026-09-11 15:22:53 +08:00
43c0b68e1a 遗漏的文件 2026-09-11 09:07:18 +08:00
356c2a1925 蓝牙增强+个更换绑定逻辑接口 2026-09-11 09:06:20 +08:00
3ba480f63f config文件 2026-09-10 09:43:27 +08:00
85e9632326 退出登陆的重新设计到我的页面;优化高清地图的加载和显示(冷启动稍慢,再次秒开);声网通用组件遗漏文件; 2026-09-10 09:02:23 +08:00
b4bedd9737 修复路径规划任务的取消接口的入参的结构去掉deviceid,测试通过。 2026-09-10 08:58:04 +08:00
d76bcdcdf2 声网通用组件开发测试通过 2026-09-10 08:55:56 +08:00
56 changed files with 5528 additions and 677 deletions

21
.gitignore vendored
View File

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

View File

@@ -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
View File

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

4
dist/manifest.json vendored
View File

@@ -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
View File

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

View File

@@ -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'

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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();

View File

@@ -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,
};
}

View File

@@ -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";

View File

@@ -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()),
);

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -1,4 +1,7 @@
class RoutePaths {
/// 启动页(App 第一个路由)
static const splash = '/splash';
/// 账号相关页面
static const login = '/auth/login';
static const register = '/auth/register';

View File

@@ -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),
);
}
}

View 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,
),
),
),
),
);
}
}

View File

@@ -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(),

View File

@@ -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');

View File

@@ -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,
);

View File

@@ -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('🧹 清除当前任务');
}

View File

@@ -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,

View File

@@ -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(),
),
),

View File

@@ -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;
}

View File

@@ -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(() {});
});
},
),

View File

@@ -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);
}

View File

@@ -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(),
),
],
),
],
),
),
);
},

View File

@@ -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,

View File

@@ -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,
);
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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}, '

View File

@@ -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;
}
}
});
}
}

View File

@@ -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;
}
}
});
}
}

View File

@@ -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;

View File

@@ -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: [

View File

@@ -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',
// ),
];
}

View File

@@ -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'),
),
),
],
),
);
}
}

View File

@@ -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
View 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
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"docx": "^9.7.1"
}
}

Binary file not shown.

View 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.

Binary file not shown.

File diff suppressed because it is too large Load Diff

View 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)');
});
}

View File

@@ -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(

View File

@@ -4,7 +4,6 @@
list(APPEND FLUTTER_PLUGIN_LIST
agora_rtc_engine
connectivity_plus
file_selector_windows
flutter_blue_plus_winrt
flutter_webrtc

Binary file not shown.

Binary file not shown.

Binary file not shown.