feat: Flutter 客户端 App + Web UI APK 下载入口
- 新增 Flutter 跨平台客户端项目 (clients/flutter/)
- 29 个 Dart 文件: 服务层/状态管理/5个页面/BLE配网
- BLE 蓝牙配网: 扫描设备、写入WiFi凭据、配网状态监听
- HTTP API 客户端: 覆盖全部端点 (播放/场景/WiFi/视频/配置/文件/插件)
- WebSocket 实时通信: 事件流 + 自动重连
- 暗色主题 Material 3 UI, 中文界面
- Android 配置: minSdkVersion 21, BLE/网络权限
- PRD 产品需求文档 + 开发任务看板
- Web UI 添加 APK 下载入口 (routes.rs)
- 下载弹窗 + 二维码 + /download/{filename} 静态文件路由
- BLE 插件增加自动重连循环 (ble/mod.rs)
- BLE 默认设备名修正为 'Showen' (config.rs)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2508,3 +2508,20 @@ ScreenPlugin 已重构为 DevicePlugin 的 thin wrapper,但 main.rs 中没有
|
|||||||
|
|
||||||
P0 问题已解决,DevicePlugin 现已完全集成到 ShowenV2 运行时!🎉
|
P0 问题已解决,DevicePlugin 现已完全集成到 ShowenV2 运行时!🎉
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[2026-03-14 当前] 刘建国(PM) → 陈逸飞(CEO), 张婉琳(产品总监), Flutter 项目相关成员: Flutter 客户端立项文档已启动并完成首版产出。
|
||||||
|
|
||||||
|
本次输出:
|
||||||
|
- 已创建 `clients/flutter/PRD.md`:定义 Flutter App 的产品目标、用户场景、五大页面、BLE 配网协议、HTTP/WebSocket 能力范围、非功能需求、风险与验收标准。
|
||||||
|
- 已创建 `clients/flutter/TASKS.md`:按基础架构、设备发现、BLE、HTTP、WebSocket、页面开发、配置/视频管理、测试发布 8 个模块拆解任务,标注优先级、依赖与预计工时。
|
||||||
|
|
||||||
|
关键决策:
|
||||||
|
- Flutter App 定位为 Phase 2 移动端主交付,优先完成“首次接入 + 高频控制 + 实时状态”三条主链路。
|
||||||
|
- 设备发现同时支持 BLE 扫描与手动输入 IP,避免首次联网与局域网复连割裂。
|
||||||
|
- BLE 客户端实现按 read + notify 双模式兼容,降低服务端 notify 语义不完整带来的联调风险。
|
||||||
|
- 设置页中的视频上传标记为 P2,首发优先保证配置查看/提交、视频列表与删除能力。
|
||||||
|
|
||||||
|
项目判断:
|
||||||
|
- 当前文档已足够支撑 Flutter 技术方案设计、UI 细化与开发排期。
|
||||||
|
- 后续需要在正式开发前冻结客户端共享数据模型,并安排一次 API/WebSocket/BLE 联调校准。
|
||||||
|
|||||||
12
clients/flutter/.gitignore
vendored
Normal file
12
clients/flutter/.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
.dart_tool/
|
||||||
|
.flutter-plugins
|
||||||
|
.flutter-plugins-dependencies
|
||||||
|
.packages
|
||||||
|
.pub/
|
||||||
|
.pub-cache/
|
||||||
|
/build/
|
||||||
|
/coverage/
|
||||||
|
android/.gradle/
|
||||||
|
android/local.properties
|
||||||
|
ios/Flutter/Flutter.framework
|
||||||
|
ios/Flutter/Flutter.podspec
|
||||||
411
clients/flutter/PRD.md
Normal file
411
clients/flutter/PRD.md
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
# Showen Flutter App PRD
|
||||||
|
|
||||||
|
## 1. 文档信息
|
||||||
|
|
||||||
|
- 产品名称:Showen Flutter App
|
||||||
|
- 版本:v0.1
|
||||||
|
- 阶段:Phase 2 立项准备
|
||||||
|
- 负责人:刘建国(PM)
|
||||||
|
- 目标平台:iOS / Android
|
||||||
|
- 技术栈:Flutter 3.x + Dart 3.x
|
||||||
|
|
||||||
|
## 2. 产品背景
|
||||||
|
|
||||||
|
ShowenV2 当前已具备 HTTP API、WebSocket 推送与 BLE GATT 配网能力,能够支撑移动端从首次接入到日常远程控制的完整链路。现阶段需要为 Flutter 客户端形成统一产品定义与开发拆解,降低多端接入成本,并为 iOS/Android 双平台提供同一套体验与交付节奏。
|
||||||
|
|
||||||
|
Flutter App 定位为 Showen 设备的移动控制器,重点解决三类问题:
|
||||||
|
|
||||||
|
1. 首次接入困难:用户不知道设备 IP,必须通过 BLE 近场配网完成入网。
|
||||||
|
2. 日常控制分散:播放控制、状态机触发、场景切换、WiFi/视频/配置管理需要统一入口。
|
||||||
|
3. 状态反馈滞后:必须通过 WebSocket 获得设备状态、状态机、WiFi 状态的实时变化。
|
||||||
|
|
||||||
|
## 3. 产品目标
|
||||||
|
|
||||||
|
### 3.1 业务目标
|
||||||
|
|
||||||
|
- 建立移动端首次接入闭环:BLE 发现 -> WiFi 配网 -> 获取设备 IP -> HTTP/WebSocket 连接。
|
||||||
|
- 建立移动端高频控制闭环:首页快捷控制 + 播放控制 + 状态机触发。
|
||||||
|
- 建立移动端设备管理闭环:设备发现、连接、切换、网络设置、配置与视频管理。
|
||||||
|
|
||||||
|
### 3.2 用户目标
|
||||||
|
|
||||||
|
- 3 分钟内完成新设备首次配网。
|
||||||
|
- 1 次点击完成播放/暂停等高频控制。
|
||||||
|
- 2 秒内看到设备状态变化反馈。
|
||||||
|
|
||||||
|
### 3.3 成功指标
|
||||||
|
|
||||||
|
- 首次配网成功率 >= 85%。
|
||||||
|
- 核心控制接口成功率 >= 99%(局域网正常环境)。
|
||||||
|
- WebSocket 断线自动重连成功率 >= 95%。
|
||||||
|
- 首页高频操作平均响应感知时间 <= 800ms。
|
||||||
|
|
||||||
|
## 4. 用户与场景
|
||||||
|
|
||||||
|
### 4.1 目标用户
|
||||||
|
|
||||||
|
- 设备主人:日常使用 Showen 设备的普通用户。
|
||||||
|
- 调试人员:需要快速完成设备联网、视频切换、配置更新的部署人员。
|
||||||
|
- 高级用户:需要手动输入 IP、管理视频和配置的深度用户。
|
||||||
|
|
||||||
|
### 4.2 核心使用场景
|
||||||
|
|
||||||
|
1. 新设备首次开机,用户通过 BLE 扫描到名为 `Showen` 的设备并完成 WiFi 配网。
|
||||||
|
2. 用户在局域网内通过首页直接查看设备状态并做播放控制。
|
||||||
|
3. 用户进入状态机页触发 `trigger` 或切换 `scene`,立即看到状态变化。
|
||||||
|
4. 用户进入网络设置页查看 WiFi 状态、扫描网络、切换网络,必要时使用 BLE 近场控制。
|
||||||
|
5. 用户进入设置页查看/更新配置,管理视频资源。
|
||||||
|
|
||||||
|
## 5. 产品范围
|
||||||
|
|
||||||
|
### 5.1 本期范围(MVP)
|
||||||
|
|
||||||
|
- BLE 蓝牙配网
|
||||||
|
- 设备发现(BLE + 手动输入 IP)
|
||||||
|
- HTTP 远程控制
|
||||||
|
- WebSocket 实时状态订阅
|
||||||
|
- 首页、播放控制页、状态机页、网络设置页、设置页
|
||||||
|
- WiFi 管理、视频管理、配置管理
|
||||||
|
- 多设备本地记录与快速切换
|
||||||
|
|
||||||
|
### 5.2 暂不纳入本期
|
||||||
|
|
||||||
|
- 用户账号体系与云端同步
|
||||||
|
- 设备远程广域网访问
|
||||||
|
- 推送通知
|
||||||
|
- 视频预览/在线播放
|
||||||
|
- 固件升级 OTA
|
||||||
|
- 平板/桌面专属布局优化
|
||||||
|
|
||||||
|
## 6. 依赖与约束
|
||||||
|
|
||||||
|
### 6.1 服务端依赖
|
||||||
|
|
||||||
|
- HTTP Base URL:`http://<device-ip>:8080/api`
|
||||||
|
- WebSocket:`ws://<device-ip>:8080/ws`
|
||||||
|
- 当前局域网环境默认无认证
|
||||||
|
- 依赖服务端已支持:播放、场景、触发器、配置、视频、WiFi、BLE 状态相关接口
|
||||||
|
|
||||||
|
### 6.2 BLE GATT 协议
|
||||||
|
|
||||||
|
- Service UUID:`12345678-1234-5678-1234-56789abcdef0`
|
||||||
|
- SSID 特征:`12345678-1234-5678-1234-56789abcdef1`(write)
|
||||||
|
- Password 特征:`12345678-1234-5678-1234-56789abcdef2`(write)
|
||||||
|
- Command 特征:`12345678-1234-5678-1234-56789abcdef3`(write)
|
||||||
|
- Status 特征:`12345678-1234-5678-1234-56789abcdef4`(read/notify)
|
||||||
|
|
||||||
|
Command 写入格式:
|
||||||
|
|
||||||
|
- 配网:`connect:ssid:password`
|
||||||
|
- 控制:`play` / `pause` / `next` / `prev`
|
||||||
|
|
||||||
|
Status JSON:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"action": "connect",
|
||||||
|
"state": "connected",
|
||||||
|
"error": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 信息架构
|
||||||
|
|
||||||
|
底部导航建议 5 个 Tab:
|
||||||
|
|
||||||
|
1. 首页
|
||||||
|
2. 播放控制
|
||||||
|
3. 状态机
|
||||||
|
4. 网络设置
|
||||||
|
5. 设置
|
||||||
|
|
||||||
|
全局能力:
|
||||||
|
|
||||||
|
- 顶部当前设备切换入口
|
||||||
|
- 全局连接状态提示
|
||||||
|
- 全局 Toast / 错误弹层
|
||||||
|
- 全局 WebSocket 重连状态条
|
||||||
|
|
||||||
|
## 8. 功能需求
|
||||||
|
|
||||||
|
### 8.1 设备发现与连接
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
让用户能够发现附近设备、手动接入已有设备,并在 App 内维护最近连接设备列表。
|
||||||
|
|
||||||
|
#### 功能点
|
||||||
|
|
||||||
|
- BLE 扫描发现广播名为 `Showen` 的设备。
|
||||||
|
- 支持显示扫描中、扫描失败、未发现设备状态。
|
||||||
|
- 支持手动输入设备 IP 直接连接。
|
||||||
|
- 支持保存最近使用设备(名称、IP、上次连接时间、连接方式)。
|
||||||
|
- 支持切换当前控制设备。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- BLE 扫描 10 秒内可展示发现结果。
|
||||||
|
- 手动输入 IP 成功后可自动拉取 `/api/status` 验证设备可达。
|
||||||
|
- 最近设备列表支持至少 10 条本地缓存。
|
||||||
|
|
||||||
|
### 8.2 BLE 蓝牙配网
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
在设备未联网或未知 IP 时,让用户通过手机蓝牙完成 WiFi 入网。
|
||||||
|
|
||||||
|
#### 功能点
|
||||||
|
|
||||||
|
- 扫描并连接 BLE 设备。
|
||||||
|
- 展示手机权限状态(蓝牙、定位)。
|
||||||
|
- 输入或选择 WiFi SSID、输入密码。
|
||||||
|
- 按协议写入 SSID/Password/Command 特征值。
|
||||||
|
- 监听 Status 特征值读取或 notify,显示配网中/成功/失败。
|
||||||
|
- 配网成功后提示用户切换到 WiFi 控制模式,并记录返回状态。
|
||||||
|
|
||||||
|
#### 交互流程
|
||||||
|
|
||||||
|
1. 用户进入网络设置页 -> 点击 BLE 配网。
|
||||||
|
2. App 扫描附近 `Showen` 设备并展示列表。
|
||||||
|
3. 用户选择设备,输入 WiFi SSID/密码。
|
||||||
|
4. App 写入 GATT 特征并发送 `connect:ssid:password`。
|
||||||
|
5. App 监听 `Status` 返回:
|
||||||
|
- 成功:提示联网成功,并引导发现/填写设备 IP。
|
||||||
|
- 失败:显示错误原因并支持重试。
|
||||||
|
|
||||||
|
#### 验收标准
|
||||||
|
|
||||||
|
- BLE 配网流程在弱网/失败情况下有明确错误提示。
|
||||||
|
- 配网状态页能展示至少 4 类状态:连接中、发送中、成功、失败。
|
||||||
|
- 重试不需要重新进入页面。
|
||||||
|
|
||||||
|
### 8.3 首页
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
作为高频控制入口,快速查看设备状态并执行最常用操作。
|
||||||
|
|
||||||
|
#### 功能点
|
||||||
|
|
||||||
|
- 展示设备在线状态、当前 IP、WiFi 状态、WebSocket 状态。
|
||||||
|
- 展示播放状态:运行中、暂停中、当前视频、当前索引。
|
||||||
|
- 提供快捷按钮:播放、暂停、上一首、下一首。
|
||||||
|
- 展示最近一次状态机状态。
|
||||||
|
- 展示关键告警:未连接、WiFi 异常、WebSocket 断开。
|
||||||
|
|
||||||
|
#### 相关接口
|
||||||
|
|
||||||
|
- `GET /api/status`
|
||||||
|
- `GET /api/wifi/status`
|
||||||
|
- WebSocket:`status_update`、`state_update`、`wifi_update`
|
||||||
|
|
||||||
|
### 8.4 播放控制页
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
提供完整播放控制和播放列表跳转能力。
|
||||||
|
|
||||||
|
#### 功能点
|
||||||
|
|
||||||
|
- 大按钮控制:播放、暂停、上一个、下一个。
|
||||||
|
- 播放状态信息:当前视频、总数、当前索引。
|
||||||
|
- 播放列表展示与点击跳转。
|
||||||
|
- 支持 `goto(index)` 精准切换。
|
||||||
|
- 支持下拉刷新播放状态。
|
||||||
|
|
||||||
|
#### 相关接口
|
||||||
|
|
||||||
|
- `POST /api/play`
|
||||||
|
- `POST /api/pause`
|
||||||
|
- `POST /api/next`
|
||||||
|
- `POST /api/previous`
|
||||||
|
- `POST /api/goto/:index`
|
||||||
|
- `GET /api/playlist`
|
||||||
|
- `GET /api/status`
|
||||||
|
|
||||||
|
### 8.5 状态机页
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
让用户能够感知当前状态并显式触发状态机行为。
|
||||||
|
|
||||||
|
#### 功能点
|
||||||
|
|
||||||
|
- 展示当前状态、最近状态变化时间。
|
||||||
|
- 展示可用 `trigger` 列表,支持无值与带值触发。
|
||||||
|
- 展示常用 `scene` 列表,支持快速切换。
|
||||||
|
- 显示触发结果反馈和状态更新记录。
|
||||||
|
|
||||||
|
#### 相关接口
|
||||||
|
|
||||||
|
- `POST /api/trigger/:name`
|
||||||
|
- `POST /api/trigger/:name/:value`
|
||||||
|
- `POST /api/scene/:name`
|
||||||
|
- WebSocket:`state_update`
|
||||||
|
|
||||||
|
### 8.6 网络设置页
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
统一管理 BLE 配网与 WiFi 能力,降低设备联网操作门槛。
|
||||||
|
|
||||||
|
#### 功能点
|
||||||
|
|
||||||
|
- BLE 配网入口。
|
||||||
|
- 显示当前 WiFi 连接状态、SSID、IP。
|
||||||
|
- 扫描可用 WiFi 列表。
|
||||||
|
- 选择 WiFi 并通过 HTTP 发起连接。
|
||||||
|
- 启动/关闭热点。
|
||||||
|
- 显示 BLE 运行状态。
|
||||||
|
- 提供 BLE 简单控制命令入口(如 play/pause/next/prev)用于近场调试。
|
||||||
|
|
||||||
|
#### 相关接口
|
||||||
|
|
||||||
|
- `GET /api/wifi/status`
|
||||||
|
- `GET /api/wifi/scan`
|
||||||
|
- `POST /api/wifi/connect`
|
||||||
|
- `POST /api/wifi/ap/start`
|
||||||
|
- `POST /api/wifi/ap/stop`
|
||||||
|
- `GET /api/ble/status`
|
||||||
|
- WebSocket:`wifi_update`
|
||||||
|
|
||||||
|
### 8.7 设置页
|
||||||
|
|
||||||
|
#### 目标
|
||||||
|
|
||||||
|
承载低频但必要的管理能力。
|
||||||
|
|
||||||
|
#### 功能点
|
||||||
|
|
||||||
|
- 查看完整配置。
|
||||||
|
- 编辑并提交配置。
|
||||||
|
- 查看视频列表。
|
||||||
|
- 上传视频(若移动端能力与后端联调稳定则纳入;否则本期先只读 + 删除)。
|
||||||
|
- 删除视频。
|
||||||
|
- 展示设备信息、App 版本、接口地址。
|
||||||
|
- 关于页与排障说明。
|
||||||
|
|
||||||
|
#### 相关接口
|
||||||
|
|
||||||
|
- `GET /api/config`
|
||||||
|
- `POST /api/config`
|
||||||
|
- `GET /api/videos`
|
||||||
|
- `POST /api/videos/upload`
|
||||||
|
- `DELETE /api/videos/:filename`
|
||||||
|
|
||||||
|
## 9. 实时状态设计
|
||||||
|
|
||||||
|
### 9.1 WebSocket 事件
|
||||||
|
|
||||||
|
- `status_update`:播放状态更新
|
||||||
|
- `state_update`:状态机状态更新
|
||||||
|
- `wifi_update`:WiFi 状态更新
|
||||||
|
|
||||||
|
### 9.2 客户端处理要求
|
||||||
|
|
||||||
|
- 建立单例 WebSocket 连接。
|
||||||
|
- 支持自动重连(退避策略)。
|
||||||
|
- 断线期间显示弱提示,不阻塞已缓存页面操作。
|
||||||
|
- 收到推送后更新首页、播放控制页、状态机页、网络设置页的对应状态。
|
||||||
|
|
||||||
|
## 10. 非功能需求
|
||||||
|
|
||||||
|
### 10.1 性能
|
||||||
|
|
||||||
|
- 首页首屏可交互时间 <= 2s(已连接设备场景)。
|
||||||
|
- 单次控制请求超时默认 5s。
|
||||||
|
- BLE 扫描页进入后 1s 内显示扫描中状态。
|
||||||
|
|
||||||
|
### 10.2 可用性
|
||||||
|
|
||||||
|
- 所有关键操作必须有加载态、成功态、失败态。
|
||||||
|
- 断网、设备离线、接口超时必须有用户可理解提示。
|
||||||
|
- 关键页面支持下拉刷新或重试。
|
||||||
|
|
||||||
|
### 10.3 兼容性
|
||||||
|
|
||||||
|
- Android 10+
|
||||||
|
- iOS 15+
|
||||||
|
- 适配主流手机尺寸
|
||||||
|
|
||||||
|
### 10.4 安全与隐私
|
||||||
|
|
||||||
|
- WiFi 密码仅在本地临时使用,不长期明文持久化。
|
||||||
|
- 本地仅缓存必要设备信息,不缓存敏感配置副本。
|
||||||
|
- 预留后续接入 Token/Auth 的网络层扩展点。
|
||||||
|
|
||||||
|
## 11. 设计要求
|
||||||
|
|
||||||
|
基于 `clients/docs/DESIGN.md`,Flutter App 延续以下方向:
|
||||||
|
|
||||||
|
- 默认暗色科技风。
|
||||||
|
- 强调状态反馈与动效流畅。
|
||||||
|
- 移动优先、触控友好。
|
||||||
|
- 大按钮、清晰卡片分组、底部导航稳定。
|
||||||
|
|
||||||
|
同时补充移动端实现约束:
|
||||||
|
|
||||||
|
- 首页、播放控制页优先单手操作。
|
||||||
|
- 关键按钮点击区域 >= 48x48dp。
|
||||||
|
- 状态颜色必须与文案双重表达,避免只靠颜色区分。
|
||||||
|
|
||||||
|
## 12. 埋点与日志
|
||||||
|
|
||||||
|
MVP 至少记录以下本地日志事件,用于调试和测试:
|
||||||
|
|
||||||
|
- BLE 扫描开始/结束/失败
|
||||||
|
- BLE 连接成功/失败
|
||||||
|
- 配网命令发送结果
|
||||||
|
- HTTP 控制请求成功/失败
|
||||||
|
- WebSocket 连接/断开/重连
|
||||||
|
- 设备切换
|
||||||
|
|
||||||
|
## 13. 风险与应对
|
||||||
|
|
||||||
|
### 13.1 技术风险
|
||||||
|
|
||||||
|
1. BLE notify 行为可能依赖服务端实现完整性。
|
||||||
|
- 应对:客户端同时支持 read + notify 两种状态获取模式。
|
||||||
|
2. WebSocket 推送事件命名与文档存在潜在偏差。
|
||||||
|
- 应对:建立兼容解析层,并在联调阶段冻结事件模型。
|
||||||
|
3. `/api/playlist`、配置热重载等能力可能与服务端行为存在细微差异。
|
||||||
|
- 应对:MVP 先以“展示 + 控制成功反馈”为主,复杂行为以联调结果修正文档。
|
||||||
|
|
||||||
|
### 13.2 产品风险
|
||||||
|
|
||||||
|
1. 首次配网流程过长导致流失。
|
||||||
|
- 应对:提供分步引导、权限解释、失败重试。
|
||||||
|
2. 设备 IP 获取链路不清晰。
|
||||||
|
- 应对:BLE 配网成功后明确提示“请在路由器管理页或设备屏幕查看 IP”,并支持手动输入。
|
||||||
|
|
||||||
|
## 14. 验收标准
|
||||||
|
|
||||||
|
### 14.1 功能验收
|
||||||
|
|
||||||
|
- 能发现并连接 BLE `Showen` 设备。
|
||||||
|
- 能完成 WiFi SSID/密码写入与 `connect` 命令发送。
|
||||||
|
- 能通过 HTTP 完成播放控制、状态机触发、场景切换、WiFi 管理、视频管理、配置管理。
|
||||||
|
- 能通过 BLE 扫描与手动 IP 两种方式建立设备连接。
|
||||||
|
- 能通过 WebSocket 实时接收并刷新 `status_update` / `state_update` / `wifi_update`。
|
||||||
|
|
||||||
|
### 14.2 体验验收
|
||||||
|
|
||||||
|
- 五个核心页面链路完整,无死路由。
|
||||||
|
- 核心控制操作均有反馈。
|
||||||
|
- 网络异常和权限异常均有可理解提示。
|
||||||
|
|
||||||
|
## 15. 里程碑建议
|
||||||
|
|
||||||
|
- M1:项目脚手架 + 基础架构 + 设备发现
|
||||||
|
- M2:BLE 配网 + HTTP 控制闭环
|
||||||
|
- M3:WebSocket 实时状态 + 五大页面完成
|
||||||
|
- M4:视频/配置管理 + 稳定性优化 + 测试发布
|
||||||
|
|
||||||
|
## 16. 开发协作说明
|
||||||
|
|
||||||
|
- 产品文档:`clients/flutter/PRD.md`
|
||||||
|
- 开发看板:`clients/flutter/TASKS.md`
|
||||||
|
- 团队沟通记录:`.showen/TEAM_CHAT.md`
|
||||||
|
|
||||||
|
本 PRD 用于 Flutter App 立项、设计、研发与测试的统一输入,后续联调如发现 API 差异,以服务端实际行为校正文档并增量更新版本记录。
|
||||||
164
clients/flutter/TASKS.md
Normal file
164
clients/flutter/TASKS.md
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
# Showen Flutter App 开发任务看板
|
||||||
|
|
||||||
|
## 说明
|
||||||
|
|
||||||
|
- 估时单位:人时(h)/ 人日(d)
|
||||||
|
- 优先级:P0 = MVP 必须完成,P1 = 首发建议完成,P2 = 可后续迭代
|
||||||
|
- 状态:当前统一标记为 `待开始`
|
||||||
|
|
||||||
|
## 总体节奏
|
||||||
|
|
||||||
|
| 阶段 | 目标 | 预计工时 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Sprint 1 | 脚手架、架构、设备发现、基础网络层 | 4-5d |
|
||||||
|
| Sprint 2 | BLE 配网、HTTP 控制、首页/播放控制页 | 5-6d |
|
||||||
|
| Sprint 3 | 状态机页、网络设置页、WebSocket 实时状态 | 4-5d |
|
||||||
|
| Sprint 4 | 设置页、视频/配置管理、测试与发布准备 | 4-5d |
|
||||||
|
|
||||||
|
预计总工时:17-21 人日
|
||||||
|
|
||||||
|
## 模块一:项目基础架构
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| A1 | 创建 Flutter 工程基础目录、环境区分、包管理方案 | P0 | 4h | 无 | 待开始 |
|
||||||
|
| A2 | 搭建路由、主题、全局错误处理、全局 Toast 能力 | P0 | 6h | A1 | 待开始 |
|
||||||
|
| A3 | 建立分层架构(presentation/application/domain/data) | P0 | 6h | A1 | 待开始 |
|
||||||
|
| A4 | 建立统一 API Client、错误模型、超时/重试策略 | P0 | 6h | A3 | 待开始 |
|
||||||
|
| A5 | 建立本地存储方案(设备历史、设置项) | P0 | 4h | A3 | 待开始 |
|
||||||
|
| A6 | 建立日志与调试开关 | P1 | 3h | A3 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:29h
|
||||||
|
|
||||||
|
## 模块二:设备发现与连接管理
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| D1 | 设计设备实体与连接状态模型 | P0 | 3h | A3 | 待开始 |
|
||||||
|
| D2 | 实现手动输入 IP 连接与可达性校验 | P0 | 4h | A4 | 待开始 |
|
||||||
|
| D3 | 实现最近设备列表的本地存储、切换、删除 | P0 | 5h | A5,D1 | 待开始 |
|
||||||
|
| D4 | 实现全局当前设备上下文与切换机制 | P0 | 5h | D1,D3 | 待开始 |
|
||||||
|
| D5 | 设备连接失败、离线、超时提示与兜底页 | P1 | 4h | D2,D4 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:21h
|
||||||
|
|
||||||
|
## 模块三:BLE 蓝牙配网
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| B1 | 选型并接入 Flutter BLE 插件,完成 iOS/Android 权限配置 | P0 | 6h | A1 | 待开始 |
|
||||||
|
| B2 | 实现扫描 `Showen` 设备列表与连接流程 | P0 | 8h | B1 | 待开始 |
|
||||||
|
| B3 | 实现 GATT Service/Characteristic 封装 | P0 | 6h | B1 | 待开始 |
|
||||||
|
| B4 | 实现 SSID/Password/Command 写入与配网命令发送 | P0 | 6h | B3 | 待开始 |
|
||||||
|
| B5 | 实现 Status 特征 read/notify 双模式监听与 JSON 解析 | P0 | 8h | B3 | 待开始 |
|
||||||
|
| B6 | 实现 BLE 配网页 UI、失败重试、权限引导 | P0 | 8h | B2,B4,B5 | 待开始 |
|
||||||
|
| B7 | 实现 BLE 简单控制命令(play/pause/next/prev) | P1 | 4h | B3 | 待开始 |
|
||||||
|
| B8 | BLE 异常专项测试(权限、断连、超时、重复配网) | P1 | 6h | B6 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:52h
|
||||||
|
|
||||||
|
## 模块四:HTTP API 控制层
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| H1 | 封装播放控制接口(play/pause/next/previous/goto) | P0 | 4h | A4 | 待开始 |
|
||||||
|
| H2 | 封装状态机接口(trigger/scene) | P0 | 4h | A4 | 待开始 |
|
||||||
|
| H3 | 封装 WiFi 接口(status/scan/connect/ap start/stop) | P0 | 5h | A4 | 待开始 |
|
||||||
|
| H4 | 封装配置接口(get config/post config) | P0 | 4h | A4 | 待开始 |
|
||||||
|
| H5 | 封装视频接口(list/upload/delete) | P1 | 6h | A4 | 待开始 |
|
||||||
|
| H6 | 封装 BLE 状态接口(get ble status) | P1 | 2h | A4 | 待开始 |
|
||||||
|
| H7 | 建立统一响应校验、错误码映射与空响应处理 | P0 | 4h | H1,H2,H3,H4 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:29h
|
||||||
|
|
||||||
|
## 模块五:WebSocket 实时状态
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| W1 | 建立 WebSocket 连接管理器与事件分发器 | P0 | 6h | A3 | 待开始 |
|
||||||
|
| W2 | 解析 `status_update` / `state_update` / `wifi_update` 事件 | P0 | 5h | W1 | 待开始 |
|
||||||
|
| W3 | 实现自动重连、退避、前后台切换恢复 | P0 | 6h | W1 | 待开始 |
|
||||||
|
| W4 | 将实时状态同步到首页、播放页、状态机页、网络页 | P0 | 5h | W2 | 待开始 |
|
||||||
|
| W5 | 实现连接状态提示条与调试日志面板 | P1 | 4h | W3 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:26h
|
||||||
|
|
||||||
|
## 模块六:页面开发
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| UI1 | 实现首页:设备状态、快捷控制、当前播放信息 | P0 | 8h | D4,H1,W4 | 待开始 |
|
||||||
|
| UI2 | 实现播放控制页:大按钮、播放列表、goto 操作 | P0 | 8h | H1,W4 | 待开始 |
|
||||||
|
| UI3 | 实现状态机页:当前状态、trigger、scene | P0 | 8h | H2,W4 | 待开始 |
|
||||||
|
| UI4 | 实现网络设置页:BLE 配网、WiFi 管理、BLE 控制 | P0 | 10h | B6,H3,H6,W4 | 待开始 |
|
||||||
|
| UI5 | 实现设置页:配置管理、视频管理、关于 | P0 | 10h | H4,H5 | 待开始 |
|
||||||
|
| UI6 | 实现底部导航、设备切换入口、通用空态/异常态 | P0 | 6h | A2,D4 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:50h
|
||||||
|
|
||||||
|
## 模块七:配置与视频管理增强
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| C1 | 配置查看器:JSON 格式化、复制、刷新 | P1 | 4h | H4 | 待开始 |
|
||||||
|
| C2 | 配置编辑器:表单/文本双模式评估并实现 MVP | P1 | 8h | C1 | 待开始 |
|
||||||
|
| C3 | 视频列表管理:删除确认、刷新状态回显 | P1 | 4h | H5 | 待开始 |
|
||||||
|
| C4 | 视频上传(选文件、进度、失败处理) | P2 | 8h | H5 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:24h
|
||||||
|
|
||||||
|
## 模块八:质量保障与发布准备
|
||||||
|
|
||||||
|
| ID | 任务 | 优先级 | 预计工时 | 依赖 | 状态 |
|
||||||
|
|----|------|--------|----------|------|------|
|
||||||
|
| Q1 | 编写核心单元测试(模型、解析、状态管理) | P0 | 8h | A3,H1,H2,H3,W2 | 待开始 |
|
||||||
|
| Q2 | 编写 Widget 测试(首页、播放页、网络页关键交互) | P1 | 8h | UI1,UI2,UI4 | 待开始 |
|
||||||
|
| Q3 | 真机联调清单(Android/iOS、BLE、HTTP、WebSocket) | P0 | 6h | B6,UI1,UI2,UI3,UI4,UI5 | 待开始 |
|
||||||
|
| Q4 | 发布前缺陷收敛、性能优化、崩溃排查 | P0 | 8h | Q1,Q2,Q3 | 待开始 |
|
||||||
|
| Q5 | 产出接入说明、测试报告、已知问题列表 | P1 | 4h | Q3,Q4 | 待开始 |
|
||||||
|
|
||||||
|
模块小计:34h
|
||||||
|
|
||||||
|
## 并行建议
|
||||||
|
|
||||||
|
### 可并行推进
|
||||||
|
|
||||||
|
1. 基础架构(A1-A5)完成后,可并行推进 D 模块、B1-B3、H 模块、W1。
|
||||||
|
2. HTTP 封装和 WebSocket 管理器完成后,UI1-UI5 可拆给不同开发者并行开发。
|
||||||
|
3. BLE 配网页(B6)与网络设置页(UI4)可先做骨架,再在 BLE 联调完成后收口。
|
||||||
|
|
||||||
|
### 串行关键链路
|
||||||
|
|
||||||
|
1. `A1 -> A3 -> A4` 是全局基础链路,必须优先完成。
|
||||||
|
2. `B1 -> B3 -> B4/B5 -> B6` 是 BLE 配网主链路,不能跳步。
|
||||||
|
3. `W1 -> W2 -> W4` 是实时状态闭环核心链路。
|
||||||
|
|
||||||
|
## 关键里程碑验收
|
||||||
|
|
||||||
|
### 里程碑 M1:基础可运行
|
||||||
|
|
||||||
|
- 工程可运行
|
||||||
|
- 手动输入 IP 可连接设备
|
||||||
|
- 基础 HTTP 请求可成功
|
||||||
|
|
||||||
|
### 里程碑 M2:首次接入闭环
|
||||||
|
|
||||||
|
- BLE 扫描、连接、配网可用
|
||||||
|
- 网络设置页完成基本闭环
|
||||||
|
|
||||||
|
### 里程碑 M3:控制闭环
|
||||||
|
|
||||||
|
- 首页、播放页、状态机页可完成核心控制
|
||||||
|
- WebSocket 状态实时更新可用
|
||||||
|
|
||||||
|
### 里程碑 M4:首发就绪
|
||||||
|
|
||||||
|
- 设置页、视频/配置管理完成
|
||||||
|
- 真机联调完成
|
||||||
|
- 已知问题可控并形成发布清单
|
||||||
|
|
||||||
|
## 风险备注
|
||||||
|
|
||||||
|
- BLE 插件在 iOS/Android 权限与后台行为差异大,需预留专项联调时间。
|
||||||
|
- `/api/videos/upload`、配置热重载等能力依赖服务端联调结果,建议先保证查看/删除/提交基础能力。
|
||||||
|
- 若服务端 WebSocket 事件字段与文档不一致,优先修正客户端解析兼容层,不阻塞主流程。
|
||||||
37
clients/flutter/android/app/build.gradle
Normal file
37
clients/flutter/android/app/build.gradle
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'org.jetbrains.kotlin.android'
|
||||||
|
id 'dev.flutter.flutter-gradle-plugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.showen.flutter'
|
||||||
|
compileSdk flutter.compileSdkVersion
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId 'com.showen.flutter'
|
||||||
|
minSdkVersion 21
|
||||||
|
targetSdkVersion flutter.targetSdkVersion
|
||||||
|
versionCode flutter.versionCode
|
||||||
|
versionName flutter.versionName
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = '17'
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
signingConfig signingConfigs.debug
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
flutter {
|
||||||
|
source '../..'
|
||||||
|
}
|
||||||
34
clients/flutter/android/app/src/main/AndroidManifest.xml
Normal file
34
clients/flutter/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="ShowenV2"
|
||||||
|
android:name="${applicationName}"
|
||||||
|
android:icon="@android:drawable/sym_def_app_icon">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:launchMode="singleTop"
|
||||||
|
android:theme="@style/LaunchTheme"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
android:hardwareAccelerated="true"
|
||||||
|
android:windowSoftInputMode="adjustResize">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="flutterEmbedding"
|
||||||
|
android:value="2" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package com.showen.flutter
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||||
|
<item name="android:windowBackground">@android:color/black</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
15
clients/flutter/android/build.gradle
Normal file
15
clients/flutter/android/build.gradle
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.buildDir = '../build'
|
||||||
|
subprojects {
|
||||||
|
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.register('clean', Delete) {
|
||||||
|
delete rootProject.buildDir
|
||||||
|
}
|
||||||
3
clients/flutter/android/gradle.properties
Normal file
3
clients/flutter/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=1G -XX:+HeapDumpOnOutOfMemoryError
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.enableJetifier=true
|
||||||
25
clients/flutter/android/settings.gradle
Normal file
25
clients/flutter/android/settings.gradle
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
pluginManagement {
|
||||||
|
def flutterSdkPath = {
|
||||||
|
def properties = new Properties()
|
||||||
|
file('local.properties').withInputStream { properties.load(it) }
|
||||||
|
def flutterSdkPath = properties.getProperty('flutter.sdk')
|
||||||
|
assert flutterSdkPath != null, 'flutter.sdk not set in local.properties'
|
||||||
|
return flutterSdkPath
|
||||||
|
}()
|
||||||
|
|
||||||
|
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'dev.flutter.flutter-plugin-loader' version '1.0.0'
|
||||||
|
id 'com.android.application' version '8.3.2' apply false
|
||||||
|
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
|
||||||
|
}
|
||||||
|
|
||||||
|
include ':app'
|
||||||
130
clients/flutter/lib/main.dart
Normal file
130
clients/flutter/lib/main.dart
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'providers/device_provider.dart';
|
||||||
|
import 'providers/player_provider.dart';
|
||||||
|
import 'providers/wifi_provider.dart';
|
||||||
|
import 'screens/app_shell.dart';
|
||||||
|
import 'screens/ble_provision_screen.dart';
|
||||||
|
import 'screens/home_screen.dart';
|
||||||
|
import 'screens/network_screen.dart';
|
||||||
|
import 'screens/playback_screen.dart';
|
||||||
|
import 'screens/settings_screen.dart';
|
||||||
|
import 'screens/trigger_screen.dart';
|
||||||
|
import 'services/http_api_service.dart';
|
||||||
|
import 'services/web_socket_service.dart';
|
||||||
|
import 'theme/app_theme.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
final httpApiService = HttpApiService(baseUrl: 'http://127.0.0.1:8080');
|
||||||
|
final webSocketService = WebSocketService();
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<DeviceProvider>(
|
||||||
|
create: (_) => DeviceProvider(
|
||||||
|
httpApiService: httpApiService,
|
||||||
|
webSocketService: webSocketService,
|
||||||
|
)..initialize(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<PlayerProvider>(
|
||||||
|
create: (_) => PlayerProvider(
|
||||||
|
httpApiService: httpApiService,
|
||||||
|
webSocketService: webSocketService,
|
||||||
|
)
|
||||||
|
..bootstrap(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<WifiProvider>(
|
||||||
|
create: (_) => WifiProvider(
|
||||||
|
httpApiService: httpApiService,
|
||||||
|
webSocketService: webSocketService,
|
||||||
|
)
|
||||||
|
..bootstrap(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
child: const ShowenApp(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final GoRouter _router = GoRouter(
|
||||||
|
initialLocation: '/',
|
||||||
|
routes: [
|
||||||
|
StatefulShellRoute.indexedStack(
|
||||||
|
builder: (context, state, navigationShell) =>
|
||||||
|
AppShell(navigationShell: navigationShell),
|
||||||
|
branches: [
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/',
|
||||||
|
name: 'home',
|
||||||
|
builder: (context, state) => const HomeScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/playback',
|
||||||
|
name: 'playback',
|
||||||
|
builder: (context, state) => const PlaybackScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/trigger',
|
||||||
|
name: 'trigger',
|
||||||
|
builder: (context, state) => const TriggerScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/network',
|
||||||
|
name: 'network',
|
||||||
|
builder: (context, state) => const NetworkScreen(),
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: 'ble-provision',
|
||||||
|
name: 'ble-provision',
|
||||||
|
builder: (context, state) => const BleProvisionScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
StatefulShellBranch(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/settings',
|
||||||
|
name: 'settings',
|
||||||
|
builder: (context, state) => const SettingsScreen(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
class ShowenApp extends StatelessWidget {
|
||||||
|
const ShowenApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp.router(
|
||||||
|
title: 'ShowenV2',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
themeMode: ThemeMode.dark,
|
||||||
|
darkTheme: AppTheme.dark(),
|
||||||
|
theme: AppTheme.dark(),
|
||||||
|
routerConfig: _router,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
clients/flutter/lib/models/api_response.dart
Normal file
22
clients/flutter/lib/models/api_response.dart
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
class ApiResponse {
|
||||||
|
const ApiResponse({required this.status, required this.message});
|
||||||
|
|
||||||
|
final String status;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
bool get isOk => status == 'ok';
|
||||||
|
|
||||||
|
factory ApiResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ApiResponse(
|
||||||
|
status: json['status'] as String? ?? 'error',
|
||||||
|
message: json['message'] as String? ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'status': status,
|
||||||
|
'message': message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
24
clients/flutter/lib/models/app_event.dart
Normal file
24
clients/flutter/lib/models/app_event.dart
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
class AppEvent {
|
||||||
|
const AppEvent({required this.type, required this.payload});
|
||||||
|
|
||||||
|
final String type;
|
||||||
|
final Map<String, dynamic> payload;
|
||||||
|
|
||||||
|
factory AppEvent.fromJson(Map<String, dynamic> json) {
|
||||||
|
final dynamic rawPayload = json['data'] ?? json['payload'] ?? const <String, dynamic>{};
|
||||||
|
return AppEvent(
|
||||||
|
type: json['type'] as String? ?? 'unknown',
|
||||||
|
payload: _normalizePayload(rawPayload),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic> _normalizePayload(dynamic payload) {
|
||||||
|
if (payload is Map<String, dynamic>) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (payload is Map) {
|
||||||
|
return Map<String, dynamic>.from(payload);
|
||||||
|
}
|
||||||
|
return <String, dynamic>{'value': payload};
|
||||||
|
}
|
||||||
|
}
|
||||||
69
clients/flutter/lib/models/ble_models.dart
Normal file
69
clients/flutter/lib/models/ble_models.dart
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
class BleDevice {
|
||||||
|
const BleDevice({
|
||||||
|
required this.name,
|
||||||
|
required this.id,
|
||||||
|
required this.rssi,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String id;
|
||||||
|
final int rssi;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BleStatus {
|
||||||
|
const BleStatus({
|
||||||
|
required this.ok,
|
||||||
|
required this.action,
|
||||||
|
this.state,
|
||||||
|
this.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory BleStatus.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BleStatus(
|
||||||
|
ok: json['ok'] == true,
|
||||||
|
action: (json['action'] ?? '').toString(),
|
||||||
|
state: json['state']?.toString(),
|
||||||
|
error: json['error']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory BleStatus.fromRawJson(String source) {
|
||||||
|
final dynamic decoded = jsonDecode(source);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException('BLE status payload is not a JSON object');
|
||||||
|
}
|
||||||
|
return BleStatus.fromJson(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const BleStatus idle = BleStatus(ok: true, action: 'idle');
|
||||||
|
|
||||||
|
final bool ok;
|
||||||
|
final String action;
|
||||||
|
final String? state;
|
||||||
|
final String? error;
|
||||||
|
|
||||||
|
bool get isQueued => state == 'queued';
|
||||||
|
|
||||||
|
bool get isSuccess => ok && !isQueued;
|
||||||
|
|
||||||
|
String get message {
|
||||||
|
if ((error ?? '').isNotEmpty) {
|
||||||
|
return error!;
|
||||||
|
}
|
||||||
|
if ((state ?? '').isNotEmpty) {
|
||||||
|
return state!;
|
||||||
|
}
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProvisioningState {
|
||||||
|
scanning,
|
||||||
|
connecting,
|
||||||
|
writingCredentials,
|
||||||
|
connectingWifi,
|
||||||
|
success,
|
||||||
|
failed,
|
||||||
|
}
|
||||||
23
clients/flutter/lib/models/ble_status.dart
Normal file
23
clients/flutter/lib/models/ble_status.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class BleServiceStatus {
|
||||||
|
const BleServiceStatus({
|
||||||
|
required this.running,
|
||||||
|
required this.embedded,
|
||||||
|
this.deviceName,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool running;
|
||||||
|
final bool embedded;
|
||||||
|
final String? deviceName;
|
||||||
|
|
||||||
|
factory BleServiceStatus.initial() {
|
||||||
|
return const BleServiceStatus(running: false, embedded: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory BleServiceStatus.fromJson(Map<String, dynamic> json) {
|
||||||
|
return BleServiceStatus(
|
||||||
|
running: json['running'] as bool? ?? false,
|
||||||
|
embedded: json['embedded'] as bool? ?? false,
|
||||||
|
deviceName: json['device_name'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
clients/flutter/lib/models/device_status.dart
Normal file
54
clients/flutter/lib/models/device_status.dart
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import 'ble_status.dart';
|
||||||
|
import 'player_status.dart';
|
||||||
|
import 'wifi_status.dart';
|
||||||
|
|
||||||
|
class DeviceStatus {
|
||||||
|
const DeviceStatus({
|
||||||
|
required this.connected,
|
||||||
|
required this.connectionType,
|
||||||
|
this.deviceName,
|
||||||
|
this.ipAddress,
|
||||||
|
this.playerStatus,
|
||||||
|
this.wifiStatus,
|
||||||
|
this.bleStatus,
|
||||||
|
this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool connected;
|
||||||
|
final String connectionType;
|
||||||
|
final String? deviceName;
|
||||||
|
final String? ipAddress;
|
||||||
|
final PlayerStatus? playerStatus;
|
||||||
|
final WifiStatus? wifiStatus;
|
||||||
|
final BleServiceStatus? bleStatus;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
|
||||||
|
factory DeviceStatus.initial() {
|
||||||
|
return const DeviceStatus(
|
||||||
|
connected: false,
|
||||||
|
connectionType: 'offline',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceStatus copyWith({
|
||||||
|
bool? connected,
|
||||||
|
String? connectionType,
|
||||||
|
String? deviceName,
|
||||||
|
String? ipAddress,
|
||||||
|
PlayerStatus? playerStatus,
|
||||||
|
WifiStatus? wifiStatus,
|
||||||
|
BleServiceStatus? bleStatus,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
}) {
|
||||||
|
return DeviceStatus(
|
||||||
|
connected: connected ?? this.connected,
|
||||||
|
connectionType: connectionType ?? this.connectionType,
|
||||||
|
deviceName: deviceName ?? this.deviceName,
|
||||||
|
ipAddress: ipAddress ?? this.ipAddress,
|
||||||
|
playerStatus: playerStatus ?? this.playerStatus,
|
||||||
|
wifiStatus: wifiStatus ?? this.wifiStatus,
|
||||||
|
bleStatus: bleStatus ?? this.bleStatus,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
clients/flutter/lib/models/player_status.dart
Normal file
68
clients/flutter/lib/models/player_status.dart
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
class PlayerStatus {
|
||||||
|
const PlayerStatus({
|
||||||
|
required this.running,
|
||||||
|
required this.paused,
|
||||||
|
required this.inTransition,
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.playlistLength,
|
||||||
|
this.currentVideo,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool running;
|
||||||
|
final bool paused;
|
||||||
|
final bool inTransition;
|
||||||
|
final int currentIndex;
|
||||||
|
final int playlistLength;
|
||||||
|
final String? currentVideo;
|
||||||
|
|
||||||
|
factory PlayerStatus.initial() {
|
||||||
|
return const PlayerStatus(
|
||||||
|
running: false,
|
||||||
|
paused: false,
|
||||||
|
inTransition: false,
|
||||||
|
currentIndex: 0,
|
||||||
|
playlistLength: 0,
|
||||||
|
currentVideo: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory PlayerStatus.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PlayerStatus(
|
||||||
|
running: json['running'] as bool? ?? false,
|
||||||
|
paused: json['paused'] as bool? ?? false,
|
||||||
|
inTransition: json['in_transition'] as bool? ?? false,
|
||||||
|
currentIndex: json['current_index'] as int? ?? 0,
|
||||||
|
playlistLength: json['playlist_length'] as int? ?? 0,
|
||||||
|
currentVideo: json['current_video'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{
|
||||||
|
'running': running,
|
||||||
|
'paused': paused,
|
||||||
|
'in_transition': inTransition,
|
||||||
|
'current_index': currentIndex,
|
||||||
|
'playlist_length': playlistLength,
|
||||||
|
'current_video': currentVideo,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerStatus copyWith({
|
||||||
|
bool? running,
|
||||||
|
bool? paused,
|
||||||
|
bool? inTransition,
|
||||||
|
int? currentIndex,
|
||||||
|
int? playlistLength,
|
||||||
|
String? currentVideo,
|
||||||
|
}) {
|
||||||
|
return PlayerStatus(
|
||||||
|
running: running ?? this.running,
|
||||||
|
paused: paused ?? this.paused,
|
||||||
|
inTransition: inTransition ?? this.inTransition,
|
||||||
|
currentIndex: currentIndex ?? this.currentIndex,
|
||||||
|
playlistLength: playlistLength ?? this.playlistLength,
|
||||||
|
currentVideo: currentVideo ?? this.currentVideo,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
clients/flutter/lib/models/video_item.dart
Normal file
23
clients/flutter/lib/models/video_item.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class VideoItem {
|
||||||
|
const VideoItem({required this.name, required this.size});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final int size;
|
||||||
|
|
||||||
|
factory VideoItem.fromJson(Map<String, dynamic> json) {
|
||||||
|
return VideoItem(
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
size: json['size'] as int? ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get sizeLabel {
|
||||||
|
if (size >= 1024 * 1024) {
|
||||||
|
return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
if (size >= 1024) {
|
||||||
|
return '${(size / 1024).toStringAsFixed(1)} KB';
|
||||||
|
}
|
||||||
|
return '$size B';
|
||||||
|
}
|
||||||
|
}
|
||||||
21
clients/flutter/lib/models/wifi_network.dart
Normal file
21
clients/flutter/lib/models/wifi_network.dart
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
class WifiNetwork {
|
||||||
|
const WifiNetwork({
|
||||||
|
required this.ssid,
|
||||||
|
required this.signal,
|
||||||
|
required this.security,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String ssid;
|
||||||
|
final int signal;
|
||||||
|
final String security;
|
||||||
|
|
||||||
|
factory WifiNetwork.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WifiNetwork(
|
||||||
|
ssid: json['ssid'] as String? ?? '',
|
||||||
|
signal: json['signal'] as int? ?? 0,
|
||||||
|
security: json['security'] as String? ?? 'Unknown',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String get signalLabel => '$signal dBm';
|
||||||
|
}
|
||||||
23
clients/flutter/lib/models/wifi_status.dart
Normal file
23
clients/flutter/lib/models/wifi_status.dart
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
class WifiStatus {
|
||||||
|
const WifiStatus({
|
||||||
|
required this.connected,
|
||||||
|
this.ssid,
|
||||||
|
this.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool connected;
|
||||||
|
final String? ssid;
|
||||||
|
final String? ip;
|
||||||
|
|
||||||
|
factory WifiStatus.disconnected() {
|
||||||
|
return const WifiStatus(connected: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory WifiStatus.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WifiStatus(
|
||||||
|
connected: json['connected'] as bool? ?? false,
|
||||||
|
ssid: json['ssid'] as String?,
|
||||||
|
ip: json['ip'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
196
clients/flutter/lib/providers/ble_provider.dart
Normal file
196
clients/flutter/lib/providers/ble_provider.dart
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/ble_models.dart';
|
||||||
|
import '../services/ble_service.dart';
|
||||||
|
|
||||||
|
class BleProvider extends ChangeNotifier {
|
||||||
|
BleProvider({BleService? bleService}) : _bleService = bleService ?? BleService();
|
||||||
|
|
||||||
|
final BleService _bleService;
|
||||||
|
|
||||||
|
StreamSubscription<List<BleDevice>>? _scanSubscription;
|
||||||
|
StreamSubscription<BleStatus>? _statusSubscription;
|
||||||
|
|
||||||
|
List<BleDevice> _devices = const <BleDevice>[];
|
||||||
|
BleDevice? _selectedDevice;
|
||||||
|
BleStatus? _latestStatus;
|
||||||
|
ProvisioningState _provisioningState = ProvisioningState.scanning;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _isScanning = false;
|
||||||
|
bool _isConnecting = false;
|
||||||
|
bool _isProvisioning = false;
|
||||||
|
bool _isConnected = false;
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
|
List<BleDevice> get devices => _devices;
|
||||||
|
BleDevice? get selectedDevice => _selectedDevice;
|
||||||
|
BleStatus? get latestStatus => _latestStatus;
|
||||||
|
ProvisioningState get provisioningState => _provisioningState;
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
bool get isScanning => _isScanning;
|
||||||
|
bool get isConnecting => _isConnecting;
|
||||||
|
bool get isProvisioning => _isProvisioning;
|
||||||
|
bool get isConnected => _isConnected;
|
||||||
|
|
||||||
|
Future<void> startScan() async {
|
||||||
|
_errorMessage = null;
|
||||||
|
_selectedDevice = null;
|
||||||
|
_isConnected = false;
|
||||||
|
_provisioningState = ProvisioningState.scanning;
|
||||||
|
_isScanning = true;
|
||||||
|
_notifySafely();
|
||||||
|
|
||||||
|
await _scanSubscription?.cancel();
|
||||||
|
_scanSubscription = _bleService
|
||||||
|
.scanForShowenDevices()
|
||||||
|
.listen((List<BleDevice> scannedDevices) {
|
||||||
|
_devices = scannedDevices;
|
||||||
|
_notifySafely();
|
||||||
|
}, onError: (Object error, StackTrace stackTrace) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
_isScanning = false;
|
||||||
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
_notifySafely();
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void>.delayed(const Duration(seconds: 6), () {
|
||||||
|
if (_isScanning) {
|
||||||
|
_isScanning = false;
|
||||||
|
_notifySafely();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connectToDevice(BleDevice device) async {
|
||||||
|
_selectedDevice = device;
|
||||||
|
_errorMessage = null;
|
||||||
|
_isConnecting = true;
|
||||||
|
_isScanning = false;
|
||||||
|
_provisioningState = ProvisioningState.connecting;
|
||||||
|
_notifySafely();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _bleService.connectToDevice(device);
|
||||||
|
await _subscribeToStatus();
|
||||||
|
_isConnected = true;
|
||||||
|
} catch (error) {
|
||||||
|
_isConnected = false;
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_isConnecting = false;
|
||||||
|
_notifySafely();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> provisionWifi(String ssid, String password) async {
|
||||||
|
_errorMessage = null;
|
||||||
|
_latestStatus = null;
|
||||||
|
_isProvisioning = true;
|
||||||
|
_provisioningState = ProvisioningState.writingCredentials;
|
||||||
|
_notifySafely();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final Future<BleStatus> operation = _bleService.provisionWifi(
|
||||||
|
ssid,
|
||||||
|
password,
|
||||||
|
timeout: const Duration(seconds: 30),
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void>.delayed(const Duration(milliseconds: 400), () {
|
||||||
|
if (_isProvisioning &&
|
||||||
|
_provisioningState == ProvisioningState.writingCredentials) {
|
||||||
|
_provisioningState = ProvisioningState.connectingWifi;
|
||||||
|
_notifySafely();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final BleStatus result = await operation;
|
||||||
|
_latestStatus = result;
|
||||||
|
_provisioningState = result.ok
|
||||||
|
? ProvisioningState.success
|
||||||
|
: ProvisioningState.failed;
|
||||||
|
if (!result.ok) {
|
||||||
|
_errorMessage = result.error ?? 'WiFi provisioning failed';
|
||||||
|
}
|
||||||
|
} on TimeoutException {
|
||||||
|
_errorMessage = 'BLE 配网超时(30 秒)';
|
||||||
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
rethrow;
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_isProvisioning = false;
|
||||||
|
_notifySafely();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
await _scanSubscription?.cancel();
|
||||||
|
await _statusSubscription?.cancel();
|
||||||
|
_scanSubscription = null;
|
||||||
|
_statusSubscription = null;
|
||||||
|
await _bleService.disconnect();
|
||||||
|
_isConnected = false;
|
||||||
|
_isConnecting = false;
|
||||||
|
_isProvisioning = false;
|
||||||
|
_selectedDevice = null;
|
||||||
|
_notifySafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> retryScan() async {
|
||||||
|
await disconnect();
|
||||||
|
_devices = const <BleDevice>[];
|
||||||
|
_latestStatus = null;
|
||||||
|
_errorMessage = null;
|
||||||
|
_provisioningState = ProvisioningState.scanning;
|
||||||
|
_notifySafely();
|
||||||
|
await startScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _subscribeToStatus() async {
|
||||||
|
await _statusSubscription?.cancel();
|
||||||
|
final Stream<BleStatus> stream = await _bleService.subscribeToStatus();
|
||||||
|
_statusSubscription = stream.listen((BleStatus status) {
|
||||||
|
_latestStatus = status;
|
||||||
|
if (!status.ok) {
|
||||||
|
_errorMessage = status.error ?? 'BLE status returned an error';
|
||||||
|
}
|
||||||
|
if (status.action == 'connect') {
|
||||||
|
if (!status.ok) {
|
||||||
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
} else if (!status.isQueued) {
|
||||||
|
_provisioningState = ProvisioningState.success;
|
||||||
|
} else if (_isProvisioning) {
|
||||||
|
_provisioningState = ProvisioningState.connectingWifi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_notifySafely();
|
||||||
|
}, onError: (Object error, StackTrace stackTrace) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
_provisioningState = ProvisioningState.failed;
|
||||||
|
_notifySafely();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_isDisposed = true;
|
||||||
|
unawaited(_scanSubscription?.cancel());
|
||||||
|
unawaited(_statusSubscription?.cancel());
|
||||||
|
unawaited(_bleService.dispose());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _notifySafely() {
|
||||||
|
if (_isDisposed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
233
clients/flutter/lib/providers/device_provider.dart
Normal file
233
clients/flutter/lib/providers/device_provider.dart
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/ble_status.dart';
|
||||||
|
import '../models/device_status.dart';
|
||||||
|
import '../models/player_status.dart';
|
||||||
|
import '../models/wifi_status.dart';
|
||||||
|
import '../services/http_api_service.dart';
|
||||||
|
import '../services/web_socket_service.dart';
|
||||||
|
|
||||||
|
class DeviceProvider extends ChangeNotifier {
|
||||||
|
DeviceProvider({
|
||||||
|
required HttpApiService httpApiService,
|
||||||
|
required WebSocketService webSocketService,
|
||||||
|
String initialDeviceIp = '127.0.0.1',
|
||||||
|
}) : _httpApiService = httpApiService,
|
||||||
|
_webSocketService = webSocketService,
|
||||||
|
_deviceIp = _normalizeDeviceIp(initialDeviceIp) {
|
||||||
|
_httpApiService.baseUrl = 'http://$_deviceIp:8080';
|
||||||
|
_connectionSubscription = _webSocketService.onConnectionChanged.listen(
|
||||||
|
_handleConnectionChanged,
|
||||||
|
);
|
||||||
|
_statusSubscription = _webSocketService.onStatusUpdate.listen(_handleStatusUpdate);
|
||||||
|
_wifiSubscription = _webSocketService.onWifiUpdate.listen(_handleWifiUpdate);
|
||||||
|
_bleSubscription = _webSocketService.onBleUpdate.listen(_handleBleUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
final HttpApiService _httpApiService;
|
||||||
|
final WebSocketService _webSocketService;
|
||||||
|
|
||||||
|
late final StreamSubscription<SocketConnectionStatus> _connectionSubscription;
|
||||||
|
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
||||||
|
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
|
||||||
|
late final StreamSubscription<Map<String, dynamic>> _bleSubscription;
|
||||||
|
|
||||||
|
DeviceStatus _status = DeviceStatus.initial();
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _webSocketConnected = false;
|
||||||
|
String _deviceIp;
|
||||||
|
|
||||||
|
DeviceStatus get status => _status;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
bool get webSocketConnected => _webSocketConnected;
|
||||||
|
String get deviceIp => _deviceIp;
|
||||||
|
HttpApiService get httpApiService => _httpApiService;
|
||||||
|
|
||||||
|
Future<void> initialize() async {
|
||||||
|
await refresh();
|
||||||
|
await connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<dynamic>([
|
||||||
|
_httpApiService.getPlaybackStatus(),
|
||||||
|
_httpApiService.getWifiStatus(),
|
||||||
|
_httpApiService.getBleStatus(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
_status = _buildStatus(
|
||||||
|
playerStatus: results[0] as PlayerStatus,
|
||||||
|
wifiStatus: results[1] as WifiStatus,
|
||||||
|
bleStatus: results[2] as BleServiceStatus,
|
||||||
|
);
|
||||||
|
_errorMessage = null;
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
_status = _status.copyWith(
|
||||||
|
connected: false,
|
||||||
|
connectionType: 'offline',
|
||||||
|
ipAddress: _deviceIp,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadDeviceOverview() => refresh();
|
||||||
|
|
||||||
|
Future<void> connect() async {
|
||||||
|
try {
|
||||||
|
await _webSocketService.connect(_deviceIp);
|
||||||
|
_webSocketConnected = _webSocketService.isConnected;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error) {
|
||||||
|
_webSocketConnected = false;
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updateDeviceIp(String ip) async {
|
||||||
|
final normalized = _normalizeDeviceIp(ip);
|
||||||
|
_deviceIp = normalized;
|
||||||
|
_httpApiService.baseUrl = 'http://$_deviceIp:8080';
|
||||||
|
_status = _status.copyWith(ipAddress: normalized, updatedAt: DateTime.now());
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
|
await _webSocketService.disconnect();
|
||||||
|
await initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startBle({String? deviceName}) async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
await _httpApiService.startBle(deviceName);
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopBle() async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
await _httpApiService.stopBle();
|
||||||
|
await refresh();
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleConnectionChanged(SocketConnectionStatus connectionStatus) {
|
||||||
|
_webSocketConnected = connectionStatus == SocketConnectionStatus.connected;
|
||||||
|
if (!_webSocketConnected) {
|
||||||
|
_status = _status.copyWith(connectionType: _status.connectionType);
|
||||||
|
}
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleStatusUpdate(Map<String, dynamic> payload) {
|
||||||
|
final playerStatus = PlayerStatus.fromJson(payload);
|
||||||
|
_status = _buildStatus(
|
||||||
|
playerStatus: playerStatus,
|
||||||
|
wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(),
|
||||||
|
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleWifiUpdate(Map<String, dynamic> payload) {
|
||||||
|
final wifiStatus = WifiStatus.fromJson(payload);
|
||||||
|
_status = _buildStatus(
|
||||||
|
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
|
||||||
|
wifiStatus: wifiStatus,
|
||||||
|
bleStatus: _status.bleStatus ?? BleServiceStatus.initial(),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBleUpdate(Map<String, dynamic> payload) {
|
||||||
|
final normalized = <String, dynamic>{
|
||||||
|
'running': payload['running'] ?? payload['ready'] ?? false,
|
||||||
|
'embedded': payload['embedded'] ?? false,
|
||||||
|
'device_name': payload['device_name'] ?? payload['name'],
|
||||||
|
};
|
||||||
|
final bleStatus = BleServiceStatus.fromJson(normalized);
|
||||||
|
_status = _buildStatus(
|
||||||
|
playerStatus: _status.playerStatus ?? PlayerStatus.initial(),
|
||||||
|
wifiStatus: _status.wifiStatus ?? WifiStatus.disconnected(),
|
||||||
|
bleStatus: bleStatus,
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
DeviceStatus _buildStatus({
|
||||||
|
required PlayerStatus playerStatus,
|
||||||
|
required WifiStatus wifiStatus,
|
||||||
|
required BleServiceStatus bleStatus,
|
||||||
|
}) {
|
||||||
|
final connectionType = wifiStatus.connected
|
||||||
|
? 'wifi'
|
||||||
|
: bleStatus.running
|
||||||
|
? 'ble'
|
||||||
|
: 'offline';
|
||||||
|
|
||||||
|
return DeviceStatus(
|
||||||
|
connected: wifiStatus.connected || bleStatus.running || _webSocketConnected,
|
||||||
|
connectionType: connectionType,
|
||||||
|
deviceName: bleStatus.deviceName ?? 'ShowenV2',
|
||||||
|
ipAddress: wifiStatus.ip ?? _deviceIp,
|
||||||
|
playerStatus: playerStatus,
|
||||||
|
wifiStatus: wifiStatus,
|
||||||
|
bleStatus: bleStatus,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setLoading(bool value) {
|
||||||
|
_isLoading = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeDeviceIp(String input) {
|
||||||
|
var value = input.trim();
|
||||||
|
if (value.startsWith('http://')) {
|
||||||
|
value = value.substring(7);
|
||||||
|
} else if (value.startsWith('https://')) {
|
||||||
|
value = value.substring(8);
|
||||||
|
} else if (value.startsWith('ws://')) {
|
||||||
|
value = value.substring(5);
|
||||||
|
}
|
||||||
|
final slashIndex = value.indexOf('/');
|
||||||
|
if (slashIndex >= 0) {
|
||||||
|
value = value.substring(0, slashIndex);
|
||||||
|
}
|
||||||
|
final colonIndex = value.indexOf(':');
|
||||||
|
if (colonIndex >= 0) {
|
||||||
|
value = value.substring(0, colonIndex);
|
||||||
|
}
|
||||||
|
return value.isEmpty ? '127.0.0.1' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_connectionSubscription.cancel());
|
||||||
|
unawaited(_statusSubscription.cancel());
|
||||||
|
unawaited(_wifiSubscription.cancel());
|
||||||
|
unawaited(_bleSubscription.cancel());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
184
clients/flutter/lib/providers/player_provider.dart
Normal file
184
clients/flutter/lib/providers/player_provider.dart
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/player_status.dart';
|
||||||
|
import '../services/http_api_service.dart';
|
||||||
|
import '../services/web_socket_service.dart';
|
||||||
|
|
||||||
|
class PlayerProvider extends ChangeNotifier {
|
||||||
|
PlayerProvider({
|
||||||
|
required HttpApiService httpApiService,
|
||||||
|
required WebSocketService webSocketService,
|
||||||
|
}) : _httpApiService = httpApiService,
|
||||||
|
_webSocketService = webSocketService {
|
||||||
|
_statusSubscription = _webSocketService.onStatusUpdate.listen((payload) {
|
||||||
|
_status = PlayerStatus.fromJson(payload);
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
_stateSubscription = _webSocketService.onStateUpdate.listen((payload) {
|
||||||
|
_currentState = payload['new_state']?.toString() ?? _currentState;
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
_configSubscription = _webSocketService.onConfigUpdate.listen((payload) {
|
||||||
|
_updateSceneOptions(payload);
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final HttpApiService _httpApiService;
|
||||||
|
final WebSocketService _webSocketService;
|
||||||
|
late final StreamSubscription<Map<String, dynamic>> _statusSubscription;
|
||||||
|
late final StreamSubscription<Map<String, dynamic>> _stateSubscription;
|
||||||
|
late final StreamSubscription<Map<String, dynamic>> _configSubscription;
|
||||||
|
|
||||||
|
PlayerStatus _status = PlayerStatus.initial();
|
||||||
|
List<String> _playlist = const <String>[];
|
||||||
|
List<String> _sceneOptions = const <String>['idle', 'intro', 'loop'];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
String? _currentState;
|
||||||
|
|
||||||
|
PlayerStatus get status => _status;
|
||||||
|
List<String> get playlist => _playlist;
|
||||||
|
List<String> get sceneOptions => _sceneOptions;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
String get currentState => _currentState ?? _status.currentVideo ?? 'idle';
|
||||||
|
|
||||||
|
Future<void> bootstrap() async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<dynamic>([
|
||||||
|
_httpApiService.getPlaybackStatus(),
|
||||||
|
_httpApiService.getPlaylist(),
|
||||||
|
_httpApiService.getConfig(),
|
||||||
|
]);
|
||||||
|
_status = results[0] as PlayerStatus;
|
||||||
|
_playlist = results[1] as List<String>;
|
||||||
|
_updateSceneOptions(results[2] as Map<String, dynamic>);
|
||||||
|
_errorMessage = null;
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchStatus() async {
|
||||||
|
try {
|
||||||
|
_status = await _httpApiService.getPlaybackStatus();
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPlaylist() async {
|
||||||
|
try {
|
||||||
|
_playlist = await _httpApiService.getPlaylist();
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> play() => _runCommand(_httpApiService.play);
|
||||||
|
|
||||||
|
Future<void> pause() => _runCommand(_httpApiService.pause);
|
||||||
|
|
||||||
|
Future<void> next() => _runCommand(_httpApiService.next);
|
||||||
|
|
||||||
|
Future<void> previous() => _runCommand(_httpApiService.previous);
|
||||||
|
|
||||||
|
Future<void> gotoIndex(int index) async {
|
||||||
|
await _runCommand(() => _httpApiService.goto(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> switchScene(String name) async {
|
||||||
|
await _runCommand(() => _httpApiService.changeScene(name));
|
||||||
|
_currentState = name;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> triggerEvent(String name, {String? value}) {
|
||||||
|
return _runCommand(() => _httpApiService.trigger(name, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> togglePlayPause() async {
|
||||||
|
if (_status.running && !_status.paused) {
|
||||||
|
await pause();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await play();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runCommand(Future<dynamic> Function() action) async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
await Future.wait<void>([
|
||||||
|
fetchStatus(),
|
||||||
|
fetchPlaylist(),
|
||||||
|
]);
|
||||||
|
_errorMessage = null;
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateSceneOptions(Map<String, dynamic> config) {
|
||||||
|
final candidates = <String>{};
|
||||||
|
final scenes = config['scenes'];
|
||||||
|
if (scenes is List) {
|
||||||
|
for (final scene in scenes) {
|
||||||
|
final value = scene.toString();
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
candidates.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final stateMachine = config['state_machine'];
|
||||||
|
if (stateMachine is Map) {
|
||||||
|
final states = stateMachine['states'];
|
||||||
|
if (states is Map) {
|
||||||
|
for (final entry in states.keys) {
|
||||||
|
final value = entry.toString();
|
||||||
|
if (value.isNotEmpty) {
|
||||||
|
candidates.add(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final initialState = stateMachine['initial_state']?.toString();
|
||||||
|
if (initialState != null && initialState.isNotEmpty) {
|
||||||
|
candidates.add(initialState);
|
||||||
|
_currentState ??= initialState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isNotEmpty) {
|
||||||
|
_sceneOptions = candidates.toList(growable: false)..sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setLoading(bool value) {
|
||||||
|
_isLoading = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_statusSubscription.cancel());
|
||||||
|
unawaited(_stateSubscription.cancel());
|
||||||
|
unawaited(_configSubscription.cancel());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
132
clients/flutter/lib/providers/wifi_provider.dart
Normal file
132
clients/flutter/lib/providers/wifi_provider.dart
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import '../models/wifi_network.dart';
|
||||||
|
import '../models/wifi_status.dart';
|
||||||
|
import '../services/http_api_service.dart';
|
||||||
|
import '../services/web_socket_service.dart';
|
||||||
|
|
||||||
|
class WifiProvider extends ChangeNotifier {
|
||||||
|
WifiProvider({
|
||||||
|
required HttpApiService httpApiService,
|
||||||
|
required WebSocketService webSocketService,
|
||||||
|
}) : _httpApiService = httpApiService,
|
||||||
|
_webSocketService = webSocketService {
|
||||||
|
_wifiSubscription = _webSocketService.onWifiUpdate.listen((payload) {
|
||||||
|
_status = WifiStatus.fromJson(payload);
|
||||||
|
notifyListeners();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final HttpApiService _httpApiService;
|
||||||
|
final WebSocketService _webSocketService;
|
||||||
|
late final StreamSubscription<Map<String, dynamic>> _wifiSubscription;
|
||||||
|
|
||||||
|
WifiStatus _status = WifiStatus.disconnected();
|
||||||
|
List<WifiNetwork> _networks = const <WifiNetwork>[];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
bool _hotspotEnabled = false;
|
||||||
|
|
||||||
|
WifiStatus get status => _status;
|
||||||
|
List<WifiNetwork> get networks => _networks;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
bool get hotspotEnabled => _hotspotEnabled;
|
||||||
|
|
||||||
|
Future<void> bootstrap() async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<dynamic>([
|
||||||
|
_httpApiService.getWifiStatus(),
|
||||||
|
_httpApiService.scanWifi(),
|
||||||
|
]);
|
||||||
|
_status = results[0] as WifiStatus;
|
||||||
|
_networks = results[1] as List<WifiNetwork>;
|
||||||
|
_errorMessage = null;
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refreshStatus() async {
|
||||||
|
try {
|
||||||
|
_status = await _httpApiService.getWifiStatus();
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> scanNetworks() async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
_networks = await _httpApiService.scanWifi();
|
||||||
|
_errorMessage = null;
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connect({required String ssid, required String password}) async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
await _httpApiService.connectWifi(ssid, password);
|
||||||
|
await refreshStatus();
|
||||||
|
_hotspotEnabled = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> startHotspot({String? ssid, String? password}) async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
await _httpApiService.startAP(ssid, password);
|
||||||
|
_hotspotEnabled = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopHotspot() async {
|
||||||
|
_setLoading(true);
|
||||||
|
try {
|
||||||
|
await _httpApiService.stopAP();
|
||||||
|
_hotspotEnabled = false;
|
||||||
|
_errorMessage = null;
|
||||||
|
notifyListeners();
|
||||||
|
} catch (error) {
|
||||||
|
_errorMessage = error.toString();
|
||||||
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setLoading(bool value) {
|
||||||
|
_isLoading = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
unawaited(_wifiSubscription.cancel());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
51
clients/flutter/lib/screens/app_shell.dart
Normal file
51
clients/flutter/lib/screens/app_shell.dart
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
class AppShell extends StatelessWidget {
|
||||||
|
const AppShell({required this.navigationShell, super.key});
|
||||||
|
|
||||||
|
final StatefulNavigationShell navigationShell;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: navigationShell,
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: navigationShell.currentIndex,
|
||||||
|
onDestinationSelected: (index) {
|
||||||
|
navigationShell.goBranch(
|
||||||
|
index,
|
||||||
|
initialLocation: index == navigationShell.currentIndex,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.home_outlined),
|
||||||
|
selectedIcon: Icon(Icons.home),
|
||||||
|
label: '首页',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.play_circle_outline),
|
||||||
|
selectedIcon: Icon(Icons.play_circle),
|
||||||
|
label: '播放',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.bolt_outlined),
|
||||||
|
selectedIcon: Icon(Icons.bolt),
|
||||||
|
label: '触发',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.wifi_outlined),
|
||||||
|
selectedIcon: Icon(Icons.wifi),
|
||||||
|
label: '网络',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.settings_outlined),
|
||||||
|
selectedIcon: Icon(Icons.settings),
|
||||||
|
label: '设置',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
597
clients/flutter/lib/screens/ble_provision_screen.dart
Normal file
597
clients/flutter/lib/screens/ble_provision_screen.dart
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/ble_models.dart';
|
||||||
|
import '../providers/ble_provider.dart';
|
||||||
|
|
||||||
|
class BleProvisionScreen extends StatefulWidget {
|
||||||
|
const BleProvisionScreen({super.key, this.provider});
|
||||||
|
|
||||||
|
final BleProvider? provider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BleProvisionScreen> createState() => _BleProvisionScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BleProvisionScreenState extends State<BleProvisionScreen> {
|
||||||
|
late final BleProvider _provider;
|
||||||
|
late final bool _ownsProvider;
|
||||||
|
final TextEditingController _ssidController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_ownsProvider = widget.provider == null;
|
||||||
|
_provider = widget.provider ?? BleProvider();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_provider.startScan();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ssidController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
if (_ownsProvider) {
|
||||||
|
_provider.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Showen BLE 配网')),
|
||||||
|
body: AnimatedBuilder(
|
||||||
|
animation: _provider,
|
||||||
|
builder: (BuildContext context, _) {
|
||||||
|
return Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: <Color>[Color(0xFF0F172A), Color(0xFF111827)],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: <Widget>[
|
||||||
|
_StatusBanner(provider: _provider),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_ProgressCard(state: _provider.provisioningState),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDevicesCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildWifiCard(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildResultCard(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDevicesCard() {
|
||||||
|
return _GlassCard(
|
||||||
|
title: '1. 扫描 Showen 设备',
|
||||||
|
trailing: TextButton.icon(
|
||||||
|
onPressed: _provider.isProvisioning ? null : _provider.retryScan,
|
||||||
|
icon: _provider.isScanning
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.refresh),
|
||||||
|
label: Text(_provider.isScanning ? '扫描中' : '重新扫描'),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
if (_provider.devices.isEmpty)
|
||||||
|
const _EmptyState(
|
||||||
|
icon: Icons.bluetooth_searching,
|
||||||
|
title: '未发现 Showen 设备',
|
||||||
|
subtitle: '请确认设备已开机,且蓝牙广播名称包含 Showen。',
|
||||||
|
)
|
||||||
|
else
|
||||||
|
..._provider.devices.map((BleDevice device) {
|
||||||
|
final bool selected = _provider.selectedDevice?.id == device.id;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
onTap: _provider.isConnecting || _provider.isProvisioning
|
||||||
|
? null
|
||||||
|
: () => _handleDeviceSelected(device),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected
|
||||||
|
? const Color(0xFF22C55E)
|
||||||
|
: Colors.white24,
|
||||||
|
),
|
||||||
|
color: selected
|
||||||
|
? const Color(0x1A22C55E)
|
||||||
|
: const Color(0x141E293B),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(
|
||||||
|
selected ? Icons.bluetooth_connected : Icons.bluetooth,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
device.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
device.id,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFF94A3B8),
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_SignalBadge(rssi: device.rssi),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWifiCard() {
|
||||||
|
return _GlassCard(
|
||||||
|
title: '2. 输入 WiFi 凭据',
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
TextFormField(
|
||||||
|
controller: _ssidController,
|
||||||
|
enabled: !_provider.isProvisioning,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration('WiFi SSID', Icons.wifi),
|
||||||
|
validator: (String? value) {
|
||||||
|
if ((value ?? '').trim().isEmpty) {
|
||||||
|
return '请输入 WiFi 名称';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: _passwordController,
|
||||||
|
enabled: !_provider.isProvisioning,
|
||||||
|
obscureText: true,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
decoration: _inputDecoration('WiFi 密码', Icons.lock_outline),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: (_provider.selectedDevice == null ||
|
||||||
|
!_provider.isConnected ||
|
||||||
|
_provider.isProvisioning)
|
||||||
|
? null
|
||||||
|
: _handleProvisioning,
|
||||||
|
icon: _provider.isProvisioning
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.router_outlined),
|
||||||
|
label: Text(_provider.isProvisioning ? '配网中...' : '开始配网'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultCard() {
|
||||||
|
final BleStatus? status = _provider.latestStatus;
|
||||||
|
final bool success = _provider.provisioningState == ProvisioningState.success;
|
||||||
|
final bool failed = _provider.provisioningState == ProvisioningState.failed;
|
||||||
|
|
||||||
|
return _GlassCard(
|
||||||
|
title: '3. 配网结果',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(
|
||||||
|
success
|
||||||
|
? Icons.check_circle_outline
|
||||||
|
: failed
|
||||||
|
? Icons.error_outline
|
||||||
|
: Icons.info_outline,
|
||||||
|
color: success
|
||||||
|
? const Color(0xFF22C55E)
|
||||||
|
: failed
|
||||||
|
? const Color(0xFFF87171)
|
||||||
|
: const Color(0xFF38BDF8),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
success
|
||||||
|
? 'WiFi 已连接成功'
|
||||||
|
: failed
|
||||||
|
? (_provider.errorMessage ?? '配网失败')
|
||||||
|
: '等待设备返回状态',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (status != null) ...<Widget>[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.18),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.white12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'action: ${status.action}\nstate: ${status.state ?? '-'}\nerror: ${status.error ?? '-'}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Color(0xFFCBD5E1),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (failed) ...<Widget>[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _provider.retryScan,
|
||||||
|
icon: const Icon(Icons.replay),
|
||||||
|
label: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDeviceSelected(BleDevice device) async {
|
||||||
|
try {
|
||||||
|
await _provider.connectToDevice(device);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('已连接 ${device.name}')),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('连接失败: $error')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleProvisioning() async {
|
||||||
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _provider.provisionWifi(
|
||||||
|
_ssidController.text.trim(),
|
||||||
|
_passwordController.text,
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('设备已完成 WiFi 配网')),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('配网失败: $error')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String label, IconData icon) {
|
||||||
|
return InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFFCBD5E1)),
|
||||||
|
prefixIcon: Icon(icon, color: const Color(0xFF38BDF8)),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0x141E293B),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.white24),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFF38BDF8), width: 1.2),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFF87171)),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFF87171), width: 1.2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusBanner extends StatelessWidget {
|
||||||
|
const _StatusBanner({required this.provider});
|
||||||
|
|
||||||
|
final BleProvider provider;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool connected = provider.isConnected;
|
||||||
|
final String headline = connected
|
||||||
|
? '已连接 ${provider.selectedDevice?.name ?? 'Showen 设备'}'
|
||||||
|
: provider.isScanning
|
||||||
|
? '正在扫描附近 Showen 设备'
|
||||||
|
: '选择设备后即可开始 BLE 配网';
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: <Color>[Color(0xFF0EA5E9), Color(0xFF22C55E)],
|
||||||
|
),
|
||||||
|
boxShadow: const <BoxShadow>[
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x330EA5E9),
|
||||||
|
blurRadius: 24,
|
||||||
|
offset: Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
const Text(
|
||||||
|
'ShowenV2 BLE Provisioning',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
headline,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProgressCard extends StatelessWidget {
|
||||||
|
const _ProgressCard({required this.state});
|
||||||
|
|
||||||
|
final ProvisioningState state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final List<_StepMeta> steps = <_StepMeta>[
|
||||||
|
_StepMeta('扫描设备', state.index >= ProvisioningState.scanning.index),
|
||||||
|
_StepMeta('连接设备', state.index >= ProvisioningState.connecting.index),
|
||||||
|
_StepMeta('写入凭据', state.index >= ProvisioningState.writingCredentials.index),
|
||||||
|
_StepMeta('连接 WiFi', state.index >= ProvisioningState.connectingWifi.index),
|
||||||
|
_StepMeta('完成', state == ProvisioningState.success),
|
||||||
|
];
|
||||||
|
|
||||||
|
return _GlassCard(
|
||||||
|
title: '当前进度',
|
||||||
|
child: Wrap(
|
||||||
|
runSpacing: 12,
|
||||||
|
spacing: 12,
|
||||||
|
children: steps.map(( _StepMeta step) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: step.active
|
||||||
|
? const Color(0x1A22C55E)
|
||||||
|
: Colors.black.withOpacity(0.14),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(
|
||||||
|
color: step.active ? const Color(0xFF22C55E) : Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
step.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
step.active ? const Color(0xFFF8FAFC) : const Color(0xFF94A3B8),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StepMeta {
|
||||||
|
const _StepMeta(this.label, this.active);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final bool active;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GlassCard extends StatelessWidget {
|
||||||
|
const _GlassCard({
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xB31E293B),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SignalBadge extends StatelessWidget {
|
||||||
|
const _SignalBadge({required this.rssi});
|
||||||
|
|
||||||
|
final int rssi;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final IconData icon = switch (rssi) {
|
||||||
|
>= -60 => Icons.network_wifi,
|
||||||
|
>= -75 => Icons.network_wifi_3_bar,
|
||||||
|
>= -90 => Icons.network_wifi_2_bar,
|
||||||
|
_ => Icons.network_wifi_1_bar,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(icon, color: const Color(0xFF38BDF8), size: 18),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'$rssi dBm',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyState extends StatelessWidget {
|
||||||
|
const _EmptyState({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.white10),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(icon, color: const Color(0xFF38BDF8), size: 32),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Color(0xFF94A3B8), height: 1.5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
146
clients/flutter/lib/screens/home_screen.dart
Normal file
146
clients/flutter/lib/screens/home_screen.dart
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/device_provider.dart';
|
||||||
|
import '../providers/player_provider.dart';
|
||||||
|
import '../providers/wifi_provider.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/control_button.dart';
|
||||||
|
import '../widgets/status_card.dart';
|
||||||
|
|
||||||
|
class HomeScreen extends StatelessWidget {
|
||||||
|
const HomeScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final deviceProvider = context.watch<DeviceProvider>();
|
||||||
|
final playerProvider = context.watch<PlayerProvider>();
|
||||||
|
final wifiProvider = context.watch<WifiProvider>();
|
||||||
|
final device = deviceProvider.status;
|
||||||
|
final player = playerProvider.status;
|
||||||
|
final wifi = wifiProvider.status;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('ShowenV2 控制台')),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await Future.wait<void>([
|
||||||
|
context.read<DeviceProvider>().refresh(),
|
||||||
|
context.read<PlayerProvider>().bootstrap(),
|
||||||
|
context.read<WifiProvider>().bootstrap(),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
children: [
|
||||||
|
StatusCard(
|
||||||
|
title: '设备连接',
|
||||||
|
value: device.connected ? '已连接' : '未连接',
|
||||||
|
subtitle: '${device.ipAddress ?? deviceProvider.deviceIp} · ${device.connectionType.toUpperCase()}',
|
||||||
|
icon: Icons.devices_rounded,
|
||||||
|
accentColor: device.connected ? AppColors.success : AppColors.warning,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
StatusCard(
|
||||||
|
title: '当前播放状态',
|
||||||
|
value: player.currentVideo ?? '暂无播放视频',
|
||||||
|
subtitle: player.running
|
||||||
|
? (player.paused ? '已暂停' : '播放中')
|
||||||
|
: '等待播放',
|
||||||
|
icon: Icons.play_circle_outline_rounded,
|
||||||
|
accentColor: player.paused ? AppColors.warning : AppColors.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
StatusCard(
|
||||||
|
title: 'WiFi 摘要',
|
||||||
|
value: wifi.connected ? (wifi.ssid ?? '已连接') : '未连接网络',
|
||||||
|
subtitle: wifi.ip ?? '可通过热点或 BLE 配网',
|
||||||
|
icon: Icons.wifi_rounded,
|
||||||
|
accentColor: wifi.connected ? AppColors.info : AppColors.border,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Text('快捷控制', style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: player.running && !player.paused ? '暂停' : '播放',
|
||||||
|
icon: player.running && !player.paused
|
||||||
|
? Icons.pause_rounded
|
||||||
|
: Icons.play_arrow_rounded,
|
||||||
|
onPressed: playerProvider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().togglePlayPause(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '上一个',
|
||||||
|
icon: Icons.skip_previous_rounded,
|
||||||
|
isFilled: false,
|
||||||
|
onPressed: playerProvider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().previous(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '下一个',
|
||||||
|
icon: Icons.skip_next_rounded,
|
||||||
|
isFilled: false,
|
||||||
|
onPressed: playerProvider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().next(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('连接详情', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
_InfoRow(label: '设备 IP', value: device.ipAddress ?? deviceProvider.deviceIp),
|
||||||
|
_InfoRow(label: '连接方式', value: device.connectionType.toUpperCase()),
|
||||||
|
_InfoRow(
|
||||||
|
label: '实时通道',
|
||||||
|
value: deviceProvider.webSocketConnected ? 'WebSocket 已连接' : 'WebSocket 重连中',
|
||||||
|
),
|
||||||
|
_InfoRow(label: '播放索引', value: '${player.currentIndex + 1}/${player.playlistLength}'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoRow extends StatelessWidget {
|
||||||
|
const _InfoRow({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||||
|
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
clients/flutter/lib/screens/network_screen.dart
Normal file
201
clients/flutter/lib/screens/network_screen.dart
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/device_provider.dart';
|
||||||
|
import '../providers/wifi_provider.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/control_button.dart';
|
||||||
|
import '../widgets/status_card.dart';
|
||||||
|
import '../widgets/wifi_list_tile.dart';
|
||||||
|
|
||||||
|
class NetworkScreen extends StatefulWidget {
|
||||||
|
const NetworkScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NetworkScreen> createState() => _NetworkScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NetworkScreenState extends State<NetworkScreen> {
|
||||||
|
final TextEditingController _ssidController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _apSsidController = TextEditingController(text: 'showen');
|
||||||
|
final TextEditingController _apPasswordController = TextEditingController(text: '12345678');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ssidController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
|
_apSsidController.dispose();
|
||||||
|
_apPasswordController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final wifiProvider = context.watch<WifiProvider>();
|
||||||
|
final deviceProvider = context.watch<DeviceProvider>();
|
||||||
|
final wifiStatus = wifiProvider.status;
|
||||||
|
final bleStatus = deviceProvider.status.bleStatus;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('网络设置')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
children: [
|
||||||
|
StatusCard(
|
||||||
|
title: 'WiFi 状态',
|
||||||
|
value: wifiStatus.connected ? (wifiStatus.ssid ?? '已连接') : '未连接',
|
||||||
|
subtitle: wifiStatus.ip ?? '尚未获取 IP 地址',
|
||||||
|
icon: Icons.router_rounded,
|
||||||
|
accentColor: wifiStatus.connected ? AppColors.success : AppColors.warning,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('连接 WiFi', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _ssidController,
|
||||||
|
decoration: const InputDecoration(labelText: 'WiFi 名称'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _passwordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(labelText: 'WiFi 密码'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: wifiProvider.isLoading ? null : _handleConnectWifi,
|
||||||
|
icon: const Icon(Icons.wifi_password_rounded),
|
||||||
|
label: const Text('连接当前 WiFi'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '扫描 WiFi',
|
||||||
|
icon: Icons.wifi_find_rounded,
|
||||||
|
onPressed: wifiProvider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<WifiProvider>().scanNetworks(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: bleStatus?.running == true ? 'BLE 已就绪' : '启动 BLE',
|
||||||
|
icon: Icons.bluetooth_rounded,
|
||||||
|
isFilled: false,
|
||||||
|
onPressed: deviceProvider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<DeviceProvider>().startBle(deviceName: 'showen'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Text('扫描结果', style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
if (wifiProvider.networks.isEmpty)
|
||||||
|
const Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Text('暂无扫描结果'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...wifiProvider.networks.map(
|
||||||
|
(network) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||||
|
child: WifiListTile(
|
||||||
|
network: network,
|
||||||
|
onTap: () {
|
||||||
|
_ssidController.text = network.ssid;
|
||||||
|
setState(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text('热点开关', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: wifiProvider.hotspotEnabled,
|
||||||
|
onChanged: (enabled) {
|
||||||
|
if (enabled) {
|
||||||
|
context.read<WifiProvider>().startHotspot(
|
||||||
|
ssid: _apSsidController.text.trim(),
|
||||||
|
password: _apPasswordController.text,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.read<WifiProvider>().stopHotspot();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
TextField(
|
||||||
|
controller: _apSsidController,
|
||||||
|
decoration: const InputDecoration(labelText: '热点 SSID'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _apPasswordController,
|
||||||
|
obscureText: true,
|
||||||
|
decoration: const InputDecoration(labelText: '热点密码'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: () => context.push('/network/ble-provision'),
|
||||||
|
child: const Text('进入 BLE 配网页面'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleConnectWifi() {
|
||||||
|
final ssid = _ssidController.text.trim();
|
||||||
|
if (ssid.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请输入 WiFi 名称')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.read<WifiProvider>().connect(
|
||||||
|
ssid: ssid,
|
||||||
|
password: _passwordController.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
182
clients/flutter/lib/screens/playback_screen.dart
Normal file
182
clients/flutter/lib/screens/playback_screen.dart
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/player_provider.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/control_button.dart';
|
||||||
|
|
||||||
|
class PlaybackScreen extends StatefulWidget {
|
||||||
|
const PlaybackScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<PlaybackScreen> createState() => _PlaybackScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PlaybackScreenState extends State<PlaybackScreen> {
|
||||||
|
final TextEditingController _indexController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_indexController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<PlayerProvider>();
|
||||||
|
final status = provider.status;
|
||||||
|
final playlist = provider.playlist;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('播放控制')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
status.currentVideo ?? '暂无播放内容',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
SizedBox(
|
||||||
|
width: 132,
|
||||||
|
height: 132,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
onPressed: provider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().togglePlayPause(),
|
||||||
|
iconSize: 56,
|
||||||
|
color: Colors.white,
|
||||||
|
icon: Icon(
|
||||||
|
status.running && !status.paused
|
||||||
|
? Icons.pause_rounded
|
||||||
|
: Icons.play_arrow_rounded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Text(
|
||||||
|
status.running ? (status.paused ? '已暂停' : '播放中') : '未开始播放',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '上一个',
|
||||||
|
icon: Icons.skip_previous_rounded,
|
||||||
|
isFilled: false,
|
||||||
|
onPressed: provider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().previous(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: ControlButton(
|
||||||
|
label: '下一个',
|
||||||
|
icon: Icons.skip_next_rounded,
|
||||||
|
isFilled: false,
|
||||||
|
onPressed: provider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().next(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('跳转到指定索引', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _indexController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '输入 0 开始的索引',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: provider.isLoading ? null : _handleGoto,
|
||||||
|
child: const Text('跳转'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Text('播放列表', style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
if (playlist.isEmpty)
|
||||||
|
const Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Text('当前没有可播放视频'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...playlist.asMap().entries.map((entry) {
|
||||||
|
final selected = entry.key == status.currentIndex;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||||
|
child: Card(
|
||||||
|
color: selected ? AppColors.primary.withOpacity(0.16) : null,
|
||||||
|
child: ListTile(
|
||||||
|
onTap: provider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().gotoIndex(entry.key),
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: selected ? AppColors.primary : AppColors.border,
|
||||||
|
child: Text('${entry.key + 1}'),
|
||||||
|
),
|
||||||
|
title: Text(entry.value),
|
||||||
|
subtitle: Text(selected ? '当前播放' : '点击跳转'),
|
||||||
|
trailing: Icon(
|
||||||
|
selected ? Icons.equalizer_rounded : Icons.chevron_right_rounded,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleGoto() {
|
||||||
|
final index = int.tryParse(_indexController.text.trim());
|
||||||
|
if (index == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请输入有效索引')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.read<PlayerProvider>().gotoIndex(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
338
clients/flutter/lib/screens/settings_screen.dart
Normal file
338
clients/flutter/lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/device_provider.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
const SettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
final TextEditingController _ipController = TextEditingController();
|
||||||
|
final TextEditingController _titleController = TextEditingController();
|
||||||
|
final TextEditingController _rotationController = TextEditingController();
|
||||||
|
final TextEditingController _widthController = TextEditingController();
|
||||||
|
final TextEditingController _heightController = TextEditingController();
|
||||||
|
final TextEditingController _hsvMinController = TextEditingController();
|
||||||
|
final TextEditingController _hsvMaxController = TextEditingController();
|
||||||
|
final TextEditingController _pointsController = TextEditingController();
|
||||||
|
|
||||||
|
Map<String, dynamic>? _fullConfig;
|
||||||
|
List<String> _availableConfigs = const <String>[];
|
||||||
|
String? _activeConfig;
|
||||||
|
bool _isFullscreen = false;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ipController.dispose();
|
||||||
|
_titleController.dispose();
|
||||||
|
_rotationController.dispose();
|
||||||
|
_widthController.dispose();
|
||||||
|
_heightController.dispose();
|
||||||
|
_hsvMinController.dispose();
|
||||||
|
_hsvMaxController.dispose();
|
||||||
|
_pointsController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<DeviceProvider>();
|
||||||
|
final status = provider.status;
|
||||||
|
_ipController.text = _ipController.text.isEmpty ? provider.deviceIp : _ipController.text;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('设置')),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: ListView(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('设备 IP 配置', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _ipController,
|
||||||
|
decoration: const InputDecoration(labelText: '设备 IP 地址'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await context.read<DeviceProvider>().updateDeviceIp(
|
||||||
|
_ipController.text.trim(),
|
||||||
|
);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _loadData();
|
||||||
|
},
|
||||||
|
child: const Text('保存并重连'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('可用配置文件', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: _activeConfig,
|
||||||
|
items: _availableConfigs
|
||||||
|
.map(
|
||||||
|
(item) => DropdownMenuItem<String>(
|
||||||
|
value: item,
|
||||||
|
child: Text(item),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
onChanged: (value) => setState(() => _activeConfig = value),
|
||||||
|
decoration: const InputDecoration(labelText: '当前配置'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: _activeConfig == null ? null : _handleSwitchConfig,
|
||||||
|
child: const Text('切换配置'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('显示设置', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: _isFullscreen,
|
||||||
|
onChanged: (value) => setState(() => _isFullscreen = value),
|
||||||
|
title: const Text('全屏模式'),
|
||||||
|
),
|
||||||
|
TextField(
|
||||||
|
controller: _titleController,
|
||||||
|
decoration: const InputDecoration(labelText: '窗口标题'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _rotationController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: '旋转角度'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _widthController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: '渲染宽度'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _heightController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(labelText: '渲染高度'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _hsvMinController,
|
||||||
|
decoration: const InputDecoration(labelText: '色键下限 HSV (逗号分隔)'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _hsvMaxController,
|
||||||
|
decoration: const InputDecoration(labelText: '色键上限 HSV (逗号分隔)'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _pointsController,
|
||||||
|
minLines: 3,
|
||||||
|
maxLines: 5,
|
||||||
|
decoration: const InputDecoration(labelText: '透视点 JSON'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _handleSaveDisplayConfig,
|
||||||
|
child: const Text('保存显示设置'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('关于信息', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
_InfoRow(label: '设备名称', value: status.deviceName ?? 'ShowenV2'),
|
||||||
|
_InfoRow(label: '连接方式', value: status.connectionType.toUpperCase()),
|
||||||
|
_InfoRow(label: '设备地址', value: status.ipAddress ?? provider.deviceIp),
|
||||||
|
_InfoRow(
|
||||||
|
label: '实时通道',
|
||||||
|
value: provider.webSocketConnected ? '已连接' : '未连接',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadData() async {
|
||||||
|
final service = context.read<DeviceProvider>().httpApiService;
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final results = await Future.wait<dynamic>([
|
||||||
|
service.getConfig(),
|
||||||
|
service.getAvailableConfigs(),
|
||||||
|
]);
|
||||||
|
_fullConfig = Map<String, dynamic>.from(results[0] as Map<String, dynamic>);
|
||||||
|
final available = Map<String, dynamic>.from(results[1] as Map<String, dynamic>);
|
||||||
|
_availableConfigs = (available['configs'] as List<dynamic>? ?? const <dynamic>[])
|
||||||
|
.map((item) => item.toString())
|
||||||
|
.toList(growable: false);
|
||||||
|
_activeConfig = available['active']?.toString();
|
||||||
|
_applyDisplayConfig(Map<String, dynamic>.from(_fullConfig?['display'] as Map? ?? const <String, dynamic>{}));
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSwitchConfig() async {
|
||||||
|
final activeConfig = _activeConfig;
|
||||||
|
if (activeConfig == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.read<DeviceProvider>().httpApiService.switchConfig(activeConfig);
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _loadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleSaveDisplayConfig() async {
|
||||||
|
final config = _fullConfig;
|
||||||
|
if (config == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final nextConfig = Map<String, dynamic>.from(config);
|
||||||
|
nextConfig['display'] = <String, dynamic>{
|
||||||
|
...Map<String, dynamic>.from(config['display'] as Map? ?? const <String, dynamic>{}),
|
||||||
|
'fullscreen': _isFullscreen,
|
||||||
|
'window_title': _titleController.text.trim(),
|
||||||
|
'rotation': int.tryParse(_rotationController.text.trim()) ?? 0,
|
||||||
|
'render_width': int.tryParse(_widthController.text.trim()) ?? 1024,
|
||||||
|
'render_height': int.tryParse(_heightController.text.trim()) ?? 1024,
|
||||||
|
'chroma_key': <String, dynamic>{
|
||||||
|
'hsv_min': _parseIntList(_hsvMinController.text),
|
||||||
|
'hsv_max': _parseIntList(_hsvMaxController.text),
|
||||||
|
},
|
||||||
|
'perspective_correction': <String, dynamic>{
|
||||||
|
'points': jsonDecode(_pointsController.text.trim().isEmpty ? '[]' : _pointsController.text.trim()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await context.read<DeviceProvider>().httpApiService.updateConfig(nextConfig);
|
||||||
|
_fullConfig = nextConfig;
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('显示设置已保存')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyDisplayConfig(Map<String, dynamic> display) {
|
||||||
|
_isFullscreen = display['fullscreen'] as bool? ?? false;
|
||||||
|
_titleController.text = display['window_title']?.toString() ?? '';
|
||||||
|
_rotationController.text = '${display['rotation'] ?? 0}';
|
||||||
|
_widthController.text = '${display['render_width'] ?? 1024}';
|
||||||
|
_heightController.text = '${display['render_height'] ?? 1024}';
|
||||||
|
final chromaKey = Map<String, dynamic>.from(display['chroma_key'] as Map? ?? const <String, dynamic>{});
|
||||||
|
_hsvMinController.text = (chromaKey['hsv_min'] as List<dynamic>? ?? const <dynamic>[0, 0, 200]).join(',');
|
||||||
|
_hsvMaxController.text = (chromaKey['hsv_max'] as List<dynamic>? ?? const <dynamic>[180, 30, 255]).join(',');
|
||||||
|
final perspective = Map<String, dynamic>.from(display['perspective_correction'] as Map? ?? const <String, dynamic>{});
|
||||||
|
_pointsController.text = jsonEncode(perspective['points'] ?? const <dynamic>[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<int> _parseIntList(String raw) {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((item) => int.tryParse(item.trim()) ?? 0)
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoRow extends StatelessWidget {
|
||||||
|
const _InfoRow({required this.label, required this.value});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
|
||||||
|
Text(value, style: Theme.of(context).textTheme.bodyLarge),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
194
clients/flutter/lib/screens/trigger_screen.dart
Normal file
194
clients/flutter/lib/screens/trigger_screen.dart
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../providers/player_provider.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class TriggerScreen extends StatefulWidget {
|
||||||
|
const TriggerScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<TriggerScreen> createState() => _TriggerScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TriggerScreenState extends State<TriggerScreen> {
|
||||||
|
final TextEditingController _triggerController = TextEditingController();
|
||||||
|
final TextEditingController _valueController = TextEditingController();
|
||||||
|
String? _selectedScene;
|
||||||
|
|
||||||
|
static const List<_PresetTrigger> _presets = <_PresetTrigger>[
|
||||||
|
_PresetTrigger(label: '语音唤醒', name: 'wake', icon: Icons.mic_rounded),
|
||||||
|
_PresetTrigger(label: '按钮 1', name: 'button1', icon: Icons.filter_1_rounded),
|
||||||
|
_PresetTrigger(label: '按钮 2', name: 'button2', icon: Icons.filter_2_rounded),
|
||||||
|
_PresetTrigger(label: '触摸传感器', name: 'touch', icon: Icons.touch_app_rounded),
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_triggerController.dispose();
|
||||||
|
_valueController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<PlayerProvider>();
|
||||||
|
_selectedScene ??= provider.sceneOptions.isNotEmpty ? provider.sceneOptions.first : null;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('状态机触发')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
children: [
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('当前状态', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(
|
||||||
|
provider.currentState,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Text('预设触发器', style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
crossAxisSpacing: AppSpacing.md,
|
||||||
|
mainAxisSpacing: AppSpacing.md,
|
||||||
|
childAspectRatio: 1.35,
|
||||||
|
children: _presets.map((preset) {
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||||
|
onTap: provider.isLoading
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().triggerEvent(preset.name),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.card,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(preset.icon, color: AppColors.accent, size: 32),
|
||||||
|
const SizedBox(height: AppSpacing.sm),
|
||||||
|
Text(preset.label),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(growable: false),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('自定义触发器', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _triggerController,
|
||||||
|
decoration: const InputDecoration(labelText: '触发器名称'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
TextField(
|
||||||
|
controller: _valueController,
|
||||||
|
decoration: const InputDecoration(labelText: '可选参数值'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: provider.isLoading ? null : _handleCustomTrigger,
|
||||||
|
icon: const Icon(Icons.send_rounded),
|
||||||
|
label: const Text('发送触发器'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.lg),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('场景切换', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
value: provider.sceneOptions.contains(_selectedScene) ? _selectedScene : null,
|
||||||
|
items: provider.sceneOptions
|
||||||
|
.map(
|
||||||
|
(scene) => DropdownMenuItem<String>(
|
||||||
|
value: scene,
|
||||||
|
child: Text(scene),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
onChanged: (value) => setState(() => _selectedScene = value),
|
||||||
|
decoration: const InputDecoration(labelText: '选择场景'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppSpacing.md),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: FilledButton.tonal(
|
||||||
|
onPressed: provider.isLoading || _selectedScene == null
|
||||||
|
? null
|
||||||
|
: () => context.read<PlayerProvider>().switchScene(_selectedScene!),
|
||||||
|
child: const Text('切换场景'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleCustomTrigger() {
|
||||||
|
final name = _triggerController.text.trim();
|
||||||
|
final value = _valueController.text.trim();
|
||||||
|
if (name.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请输入触发器名称')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.read<PlayerProvider>().triggerEvent(
|
||||||
|
name,
|
||||||
|
value: value.isEmpty ? null : value,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PresetTrigger {
|
||||||
|
const _PresetTrigger({
|
||||||
|
required this.label,
|
||||||
|
required this.name,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final String name;
|
||||||
|
final IconData icon;
|
||||||
|
}
|
||||||
215
clients/flutter/lib/services/ble_service.dart
Normal file
215
clients/flutter/lib/services/ble_service.dart
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
|
|
||||||
|
import '../models/ble_models.dart';
|
||||||
|
|
||||||
|
class BleService {
|
||||||
|
static final Guid _provisionServiceUuid =
|
||||||
|
Guid('12345678-1234-5678-1234-56789abcdef0');
|
||||||
|
static final Guid _ssidCharacteristicUuid =
|
||||||
|
Guid('12345678-1234-5678-1234-56789abcdef1');
|
||||||
|
static final Guid _passwordCharacteristicUuid =
|
||||||
|
Guid('12345678-1234-5678-1234-56789abcdef2');
|
||||||
|
static final Guid _commandCharacteristicUuid =
|
||||||
|
Guid('12345678-1234-5678-1234-56789abcdef3');
|
||||||
|
static final Guid _statusCharacteristicUuid =
|
||||||
|
Guid('12345678-1234-5678-1234-56789abcdef4');
|
||||||
|
|
||||||
|
BluetoothDevice? _connectedDevice;
|
||||||
|
BluetoothCharacteristic? _ssidCharacteristic;
|
||||||
|
BluetoothCharacteristic? _passwordCharacteristic;
|
||||||
|
BluetoothCharacteristic? _commandCharacteristic;
|
||||||
|
BluetoothCharacteristic? _statusCharacteristic;
|
||||||
|
StreamSubscription<List<ScanResult>>? _scanSubscription;
|
||||||
|
|
||||||
|
Stream<List<BleDevice>> scanForShowenDevices({
|
||||||
|
Duration timeout = const Duration(seconds: 6),
|
||||||
|
}) {
|
||||||
|
final controller = StreamController<List<BleDevice>>();
|
||||||
|
final seen = <String, BleDevice>{};
|
||||||
|
|
||||||
|
unawaited(_scanSubscription?.cancel());
|
||||||
|
_scanSubscription = FlutterBluePlus.scanResults.listen((results) {
|
||||||
|
for (final result in results) {
|
||||||
|
final name = _deviceName(result);
|
||||||
|
if (name.isEmpty || !name.toLowerCase().contains('showen')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final id = result.device.remoteId.str;
|
||||||
|
seen[id] = BleDevice(name: name, id: id, rssi: result.rssi);
|
||||||
|
}
|
||||||
|
|
||||||
|
final devices = seen.values.toList(growable: false)
|
||||||
|
..sort((a, b) => b.rssi.compareTo(a.rssi));
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
controller.add(devices);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
unawaited(FlutterBluePlus.startScan(timeout: timeout));
|
||||||
|
Future<void>.delayed(timeout, () async {
|
||||||
|
await FlutterBluePlus.stopScan();
|
||||||
|
if (!controller.isClosed) {
|
||||||
|
await controller.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.onCancel = () async {
|
||||||
|
await FlutterBluePlus.stopScan();
|
||||||
|
await _scanSubscription?.cancel();
|
||||||
|
_scanSubscription = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return controller.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> connectToDevice(BleDevice device) async {
|
||||||
|
await disconnect();
|
||||||
|
final bluetoothDevice = BluetoothDevice.fromId(device.id);
|
||||||
|
await bluetoothDevice.connect(timeout: const Duration(seconds: 12));
|
||||||
|
_connectedDevice = bluetoothDevice;
|
||||||
|
await _discoverCharacteristics(bluetoothDevice);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Stream<BleStatus>> subscribeToStatus() async {
|
||||||
|
final characteristic = _statusCharacteristic;
|
||||||
|
if (characteristic == null) {
|
||||||
|
return Stream<BleStatus>.value(
|
||||||
|
const BleStatus(
|
||||||
|
ok: false,
|
||||||
|
action: 'status',
|
||||||
|
error: '未发现 BLE 状态特征值',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await characteristic.setNotifyValue(true);
|
||||||
|
return characteristic.lastValueStream
|
||||||
|
.where((value) => value.isNotEmpty)
|
||||||
|
.map((value) {
|
||||||
|
final raw = utf8.decode(value, allowMalformed: true);
|
||||||
|
try {
|
||||||
|
return BleStatus.fromRawJson(raw);
|
||||||
|
} catch (_) {
|
||||||
|
return BleStatus(
|
||||||
|
ok: true,
|
||||||
|
action: 'status',
|
||||||
|
state: raw,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<BleStatus> provisionWifi(
|
||||||
|
String ssid,
|
||||||
|
String password, {
|
||||||
|
Duration timeout = const Duration(seconds: 30),
|
||||||
|
}) async {
|
||||||
|
final ssidChar = _ssidCharacteristic;
|
||||||
|
final passwordChar = _passwordCharacteristic;
|
||||||
|
final command = _commandCharacteristic;
|
||||||
|
|
||||||
|
if (_connectedDevice == null) {
|
||||||
|
throw StateError('未连接 BLE 设备');
|
||||||
|
}
|
||||||
|
if (ssidChar == null || passwordChar == null || command == null) {
|
||||||
|
throw StateError('未发现 BLE 配网特征值');
|
||||||
|
}
|
||||||
|
|
||||||
|
final statusStream = await subscribeToStatus();
|
||||||
|
final completer = Completer<BleStatus>();
|
||||||
|
late final StreamSubscription<BleStatus> subscription;
|
||||||
|
subscription = statusStream.listen((status) {
|
||||||
|
if (!status.isQueued && !completer.isCompleted) {
|
||||||
|
completer.complete(status);
|
||||||
|
}
|
||||||
|
}, onError: (Object error, StackTrace stackTrace) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await ssidChar.write(
|
||||||
|
utf8.encode(ssid),
|
||||||
|
withoutResponse: ssidChar.properties.writeWithoutResponse,
|
||||||
|
);
|
||||||
|
await passwordChar.write(
|
||||||
|
utf8.encode(password),
|
||||||
|
withoutResponse: passwordChar.properties.writeWithoutResponse,
|
||||||
|
);
|
||||||
|
await command.write(
|
||||||
|
utf8.encode('connect'),
|
||||||
|
withoutResponse: command.properties.writeWithoutResponse,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await completer.future.timeout(timeout);
|
||||||
|
} finally {
|
||||||
|
await subscription.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
await _scanSubscription?.cancel();
|
||||||
|
_scanSubscription = null;
|
||||||
|
|
||||||
|
final device = _connectedDevice;
|
||||||
|
_connectedDevice = null;
|
||||||
|
_ssidCharacteristic = null;
|
||||||
|
_passwordCharacteristic = null;
|
||||||
|
_commandCharacteristic = null;
|
||||||
|
_statusCharacteristic = null;
|
||||||
|
|
||||||
|
if (device != null) {
|
||||||
|
await device.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() => disconnect();
|
||||||
|
|
||||||
|
Future<void> _discoverCharacteristics(BluetoothDevice device) async {
|
||||||
|
final services = await device.discoverServices();
|
||||||
|
|
||||||
|
BluetoothCharacteristic? ssid;
|
||||||
|
BluetoothCharacteristic? password;
|
||||||
|
BluetoothCharacteristic? command;
|
||||||
|
BluetoothCharacteristic? status;
|
||||||
|
|
||||||
|
for (final service in services) {
|
||||||
|
final shouldInspect = service.uuid == _provisionServiceUuid ||
|
||||||
|
services.length == 1 ||
|
||||||
|
service.characteristics.isNotEmpty;
|
||||||
|
if (!shouldInspect) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final characteristic in service.characteristics) {
|
||||||
|
if (characteristic.uuid == _ssidCharacteristicUuid) {
|
||||||
|
ssid = characteristic;
|
||||||
|
} else if (characteristic.uuid == _passwordCharacteristicUuid) {
|
||||||
|
password = characteristic;
|
||||||
|
} else if (characteristic.uuid == _commandCharacteristicUuid) {
|
||||||
|
command = characteristic;
|
||||||
|
} else if (characteristic.uuid == _statusCharacteristicUuid) {
|
||||||
|
status = characteristic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ssidCharacteristic = ssid;
|
||||||
|
_passwordCharacteristic = password;
|
||||||
|
_commandCharacteristic = command;
|
||||||
|
_statusCharacteristic = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _deviceName(ScanResult result) {
|
||||||
|
final platformName = result.device.platformName.trim();
|
||||||
|
if (platformName.isNotEmpty) {
|
||||||
|
return platformName;
|
||||||
|
}
|
||||||
|
final advertisedName = result.advertisementData.advName.trim();
|
||||||
|
return advertisedName;
|
||||||
|
}
|
||||||
|
}
|
||||||
503
clients/flutter/lib/services/http_api_service.dart
Normal file
503
clients/flutter/lib/services/http_api_service.dart
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
import '../models/api_response.dart';
|
||||||
|
import '../models/ble_status.dart';
|
||||||
|
import '../models/player_status.dart';
|
||||||
|
import '../models/video_item.dart';
|
||||||
|
import '../models/wifi_network.dart';
|
||||||
|
import '../models/wifi_status.dart';
|
||||||
|
|
||||||
|
class HttpApiService {
|
||||||
|
HttpApiService({required String baseUrl, http.Client? client})
|
||||||
|
: _baseUrl = _normalizeBaseUrl(baseUrl),
|
||||||
|
_client = client ?? http.Client();
|
||||||
|
|
||||||
|
final http.Client _client;
|
||||||
|
String _baseUrl;
|
||||||
|
|
||||||
|
String get baseUrl => _baseUrl;
|
||||||
|
|
||||||
|
set baseUrl(String value) {
|
||||||
|
_baseUrl = _normalizeBaseUrl(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _uri(String path, [Map<String, String>? queryParameters]) {
|
||||||
|
final normalizedPath = path.startsWith('/') ? path : '/$path';
|
||||||
|
return Uri.parse('$_baseUrl$normalizedPath').replace(
|
||||||
|
queryParameters: queryParameters == null || queryParameters.isEmpty
|
||||||
|
? null
|
||||||
|
: queryParameters,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> fetchConsolePage() async {
|
||||||
|
final response = await _client.get(_uri('/'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> fetchConsoleIndexHtml() async {
|
||||||
|
final response = await _client.get(_uri('/index.html'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<PlayerStatus> getStatus() => getPlaybackStatus();
|
||||||
|
|
||||||
|
Future<PlayerStatus> getPlaybackStatus() async {
|
||||||
|
final response = await _client.get(_uri('/api/status'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return PlayerStatus.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> play() => _postCommand('/api/play');
|
||||||
|
|
||||||
|
Future<ApiResponse> pause() => _postCommand('/api/pause');
|
||||||
|
|
||||||
|
Future<ApiResponse> next() => _postCommand('/api/next');
|
||||||
|
|
||||||
|
Future<ApiResponse> previous() => _postCommand('/api/previous');
|
||||||
|
|
||||||
|
Future<ApiResponse> goto(int index) => _postCommand('/api/goto/$index');
|
||||||
|
|
||||||
|
Future<ApiResponse> gotoIndex(int index) => goto(index);
|
||||||
|
|
||||||
|
Future<List<String>> getPlaylist() async {
|
||||||
|
final response = await _client.get(_uri('/api/playlist'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
final decoded = _decodeJson(response.body);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.map<String>((dynamic item) {
|
||||||
|
if (item is String) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
return item['name']?.toString() ?? jsonEncode(item);
|
||||||
|
}
|
||||||
|
if (item is Map) {
|
||||||
|
return item['name']?.toString() ?? jsonEncode(item);
|
||||||
|
}
|
||||||
|
return item.toString();
|
||||||
|
}).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> changeScene(String name) {
|
||||||
|
return _postCommand('/api/scene/${Uri.encodeComponent(name)}');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> switchScene(String name) => changeScene(name);
|
||||||
|
|
||||||
|
Future<ApiResponse> trigger(String name, [String? value]) {
|
||||||
|
final suffix = value == null || value.isEmpty
|
||||||
|
? ''
|
||||||
|
: '/${Uri.encodeComponent(value)}';
|
||||||
|
return _postCommand('/api/trigger/${Uri.encodeComponent(name)}$suffix');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> triggerEvent(String name, {String? value}) {
|
||||||
|
return trigger(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WifiStatus> getWifiStatus() async {
|
||||||
|
final response = await _client.get(_uri('/api/wifi/status'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return WifiStatus.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<WifiNetwork>> scanWifi() async {
|
||||||
|
try {
|
||||||
|
final response = await _client.get(_uri('/api/wifi/scan'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return _decodeWifiNetworks(response.body);
|
||||||
|
} on ApiException {
|
||||||
|
final response = await _client.post(_uri('/api/wifi/scan'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return _decodeWifiNetworks(response.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<WifiNetwork>> scanWifiViaGet() => scanWifi();
|
||||||
|
|
||||||
|
Future<List<WifiNetwork>> scanWifiViaPost() => scanWifi();
|
||||||
|
|
||||||
|
Future<ApiResponse> connectWifi(String ssid, String password) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/wifi/connect'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'ssid': ssid,
|
||||||
|
'password': password,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> startAP([String? ssid, String? password]) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
if (ssid != null && ssid.isNotEmpty) 'ssid': ssid,
|
||||||
|
if (password != null && password.isNotEmpty) 'password': password,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/wifi/ap/start'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
} on ApiException {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/wifi/hotspot/start'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> startAccessPoint({String? ssid, String? password}) {
|
||||||
|
return startAP(ssid, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> startHotspot({String? ssid, String? password}) {
|
||||||
|
return startAP(ssid, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> stopAP() async {
|
||||||
|
try {
|
||||||
|
return await _postCommand('/api/wifi/ap/stop');
|
||||||
|
} on ApiException {
|
||||||
|
return _postCommand('/api/wifi/hotspot/stop');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> stopAccessPoint() => stopAP();
|
||||||
|
|
||||||
|
Future<ApiResponse> stopHotspot() => stopAP();
|
||||||
|
|
||||||
|
Future<BleServiceStatus> getBleStatus() async {
|
||||||
|
final response = await _client.get(_uri('/api/ble/status'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return BleServiceStatus.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> startBle([String? deviceName]) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/ble/start'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
if (deviceName != null && deviceName.isNotEmpty)
|
||||||
|
'device_name': deviceName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> stopBle() => _postCommand('/api/ble/stop');
|
||||||
|
|
||||||
|
Future<List<VideoItem>> getVideos() async {
|
||||||
|
final response = await _client.get(_uri('/api/videos'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
final decoded = _decodeJson(response.body);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const <VideoItem>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.map<VideoItem>((dynamic item) {
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
return VideoItem.fromJson(item);
|
||||||
|
}
|
||||||
|
if (item is Map) {
|
||||||
|
return VideoItem.fromJson(Map<String, dynamic>.from(item));
|
||||||
|
}
|
||||||
|
return const VideoItem(name: '', size: 0);
|
||||||
|
}).where((item) => item.name.isNotEmpty).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> uploadVideo(File file) {
|
||||||
|
return _uploadSingleFile('/api/videos/upload', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> uploadVideos(List<String> filePaths) async {
|
||||||
|
if (filePaths.isEmpty) {
|
||||||
|
throw const ApiException('未选择上传文件');
|
||||||
|
}
|
||||||
|
|
||||||
|
final request = http.MultipartRequest('POST', _uri('/api/videos/upload'));
|
||||||
|
for (final filePath in filePaths) {
|
||||||
|
request.files.add(await http.MultipartFile.fromPath('file', filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.Response.fromStream(await request.send());
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> deleteVideo(String name) {
|
||||||
|
return _deleteCommand('/api/videos/${Uri.encodeComponent(name)}');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getConfig() async {
|
||||||
|
final response = await _client.get(_uri('/api/config'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return _decodeMap(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> updateConfig(Map<String, dynamic> config) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/config'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(config),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getDisplayConfig() async {
|
||||||
|
final response = await _client.get(_uri('/api/config/display'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return _decodeMap(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getAvailableConfigs() async {
|
||||||
|
final response = await _client.get(_uri('/api/config/available'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return _decodeMap(response.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> switchConfig(String filename) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/config/switch'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(<String, dynamic>{'filename': filename}),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> listFiles(String dirKey, [String? path]) async {
|
||||||
|
final response = await _client.get(
|
||||||
|
_uri('/api/files/$dirKey', _pathQuery(path)),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
final decoded = _decodeJson(response.body);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((entry) => Map<String, dynamic>.from(entry))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> uploadFile(String dirKey, File file, [String? path]) {
|
||||||
|
return _uploadSingleFile(
|
||||||
|
'/api/files/$dirKey/upload',
|
||||||
|
file,
|
||||||
|
directoryPath: path,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Uint8List> downloadFile(String dirKey, String path) async {
|
||||||
|
final response = await _client.get(
|
||||||
|
_uri('/api/files/$dirKey/download', <String, String>{'path': path}),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response, expectJson: false);
|
||||||
|
return response.bodyBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> deleteFile(String dirKey, String path) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/files/$dirKey/delete'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(<String, dynamic>{'path': path}),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> mkdir(String dirKey, String path) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/files/$dirKey/mkdir'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(<String, dynamic>{'path': path}),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> getPlugins() async {
|
||||||
|
final response = await _client.get(_uri('/api/plugins'));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
final decoded = _decodeJson(response.body);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const <Map<String, dynamic>>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((plugin) => Map<String, dynamic>.from(plugin))
|
||||||
|
.toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> enablePlugin(String id) {
|
||||||
|
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/enable');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> disablePlugin(String id) {
|
||||||
|
return _postCommand('/api/plugins/${Uri.encodeComponent(id)}/disable');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> installPlugin(String id, [String? version]) async {
|
||||||
|
final response = await _client.post(
|
||||||
|
_uri('/api/plugins/install'),
|
||||||
|
headers: _jsonHeaders,
|
||||||
|
body: jsonEncode(<String, dynamic>{
|
||||||
|
'id': id,
|
||||||
|
if (version != null && version.isNotEmpty) 'version': version,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> _uploadSingleFile(
|
||||||
|
String endpoint,
|
||||||
|
File file, {
|
||||||
|
String? directoryPath,
|
||||||
|
}) async {
|
||||||
|
final request = http.MultipartRequest(
|
||||||
|
'POST',
|
||||||
|
_uri(endpoint, _pathQuery(directoryPath)),
|
||||||
|
);
|
||||||
|
request.files.add(await http.MultipartFile.fromPath('file', file.path));
|
||||||
|
final response = await http.Response.fromStream(await request.send());
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> _postCommand(String path) async {
|
||||||
|
final response = await _client.post(_uri(path));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse> _deleteCommand(String path) async {
|
||||||
|
final response = await _client.delete(_uri(path));
|
||||||
|
_ensureSuccess(response);
|
||||||
|
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WifiNetwork> _decodeWifiNetworks(String body) {
|
||||||
|
final decoded = _decodeJson(body);
|
||||||
|
if (decoded is! List) {
|
||||||
|
return const <WifiNetwork>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded.map<WifiNetwork>((dynamic item) {
|
||||||
|
if (item is Map<String, dynamic>) {
|
||||||
|
return WifiNetwork.fromJson(item);
|
||||||
|
}
|
||||||
|
if (item is Map) {
|
||||||
|
return WifiNetwork.fromJson(Map<String, dynamic>.from(item));
|
||||||
|
}
|
||||||
|
return const WifiNetwork(ssid: '', signal: 0, security: 'Unknown');
|
||||||
|
}).where((network) => network.ssid.isNotEmpty).toList(growable: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _decodeJson(String body) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(body);
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
throw ApiException('JSON 解析失败: ${error.message}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _decodeMap(String body) {
|
||||||
|
final decoded = _decodeJson(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
if (decoded is Map) {
|
||||||
|
return Map<String, dynamic>.from(decoded);
|
||||||
|
}
|
||||||
|
throw const ApiException('期望返回 JSON 对象');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String>? _pathQuery(String? path) {
|
||||||
|
if (path == null || path.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return <String, String>{'path': path};
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ensureSuccess(http.Response response, {bool expectJson = true}) {
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!expectJson) {
|
||||||
|
throw ApiException(
|
||||||
|
'请求失败 (${response.statusCode}): ${response.body}',
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String message = response.body;
|
||||||
|
try {
|
||||||
|
final decoded = _decodeJson(response.body);
|
||||||
|
if (decoded is Map) {
|
||||||
|
message = decoded['message']?.toString() ?? response.body;
|
||||||
|
}
|
||||||
|
} on ApiException {
|
||||||
|
message = response.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw ApiException(message, statusCode: response.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> get _jsonHeaders => const <String, String>{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
_client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _normalizeBaseUrl(String raw) {
|
||||||
|
final trimmed = raw.trim();
|
||||||
|
if (trimmed.isEmpty) {
|
||||||
|
throw const ApiException('baseUrl 不能为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
final withScheme = trimmed.startsWith('http://') || trimmed.startsWith('https://')
|
||||||
|
? trimmed
|
||||||
|
: 'http://$trimmed';
|
||||||
|
return withScheme.endsWith('/')
|
||||||
|
? withScheme.substring(0, withScheme.length - 1)
|
||||||
|
: withScheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ApiException implements Exception {
|
||||||
|
const ApiException(this.message, {this.statusCode});
|
||||||
|
|
||||||
|
final String message;
|
||||||
|
final int? statusCode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
172
clients/flutter/lib/services/web_socket_service.dart
Normal file
172
clients/flutter/lib/services/web_socket_service.dart
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
|
|
||||||
|
import '../models/app_event.dart';
|
||||||
|
|
||||||
|
enum SocketConnectionStatus { disconnected, connecting, connected }
|
||||||
|
|
||||||
|
class WebSocketService {
|
||||||
|
WebSocketChannel? _channel;
|
||||||
|
StreamSubscription<dynamic>? _subscription;
|
||||||
|
Timer? _reconnectTimer;
|
||||||
|
String? _deviceIp;
|
||||||
|
bool _manualDisconnect = false;
|
||||||
|
SocketConnectionStatus _connectionStatus = SocketConnectionStatus.disconnected;
|
||||||
|
|
||||||
|
final StreamController<AppEvent> _eventController =
|
||||||
|
StreamController<AppEvent>.broadcast();
|
||||||
|
final StreamController<Map<String, dynamic>> _statusController =
|
||||||
|
StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final StreamController<Map<String, dynamic>> _stateController =
|
||||||
|
StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final StreamController<Map<String, dynamic>> _configController =
|
||||||
|
StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final StreamController<Map<String, dynamic>> _wifiController =
|
||||||
|
StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final StreamController<Map<String, dynamic>> _bleController =
|
||||||
|
StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final StreamController<SocketConnectionStatus> _connectionController =
|
||||||
|
StreamController<SocketConnectionStatus>.broadcast();
|
||||||
|
|
||||||
|
Stream<AppEvent> get events => _eventController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onStatusUpdate => _statusController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onStateUpdate => _stateController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onConfigUpdate => _configController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onWifiUpdate => _wifiController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onBleUpdate => _bleController.stream;
|
||||||
|
Stream<SocketConnectionStatus> get onConnectionChanged =>
|
||||||
|
_connectionController.stream;
|
||||||
|
|
||||||
|
SocketConnectionStatus get connectionStatus => _connectionStatus;
|
||||||
|
bool get isConnected => _connectionStatus == SocketConnectionStatus.connected;
|
||||||
|
|
||||||
|
Future<void> connect(String deviceIp) async {
|
||||||
|
_manualDisconnect = false;
|
||||||
|
_deviceIp = _normalizeDeviceIp(deviceIp);
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
|
||||||
|
await _subscription?.cancel();
|
||||||
|
await _channel?.sink.close();
|
||||||
|
|
||||||
|
_setConnectionStatus(SocketConnectionStatus.connecting);
|
||||||
|
|
||||||
|
final url = Uri.parse('ws://$_deviceIp:8080/ws');
|
||||||
|
_channel = WebSocketChannel.connect(url);
|
||||||
|
_subscription = _channel!.stream.listen(
|
||||||
|
_handleMessage,
|
||||||
|
onDone: _handleSocketClosed,
|
||||||
|
onError: (_) => _handleSocketClosed(),
|
||||||
|
cancelOnError: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
_setConnectionStatus(SocketConnectionStatus.connected);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendCommand(Map<String, dynamic> command) {
|
||||||
|
if (!isConnected || _channel == null) {
|
||||||
|
throw StateError('WebSocket 未连接');
|
||||||
|
}
|
||||||
|
_channel!.sink.add(jsonEncode(command));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> reconnect() async {
|
||||||
|
final deviceIp = _deviceIp;
|
||||||
|
if (deviceIp == null || deviceIp.isEmpty || _manualDisconnect) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
_reconnectTimer = Timer(const Duration(seconds: 2), () {
|
||||||
|
unawaited(connect(deviceIp));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> disconnect() async {
|
||||||
|
_manualDisconnect = true;
|
||||||
|
_reconnectTimer?.cancel();
|
||||||
|
await _subscription?.cancel();
|
||||||
|
_subscription = null;
|
||||||
|
await _channel?.sink.close();
|
||||||
|
_channel = null;
|
||||||
|
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> dispose() async {
|
||||||
|
await disconnect();
|
||||||
|
await _eventController.close();
|
||||||
|
await _statusController.close();
|
||||||
|
await _stateController.close();
|
||||||
|
await _configController.close();
|
||||||
|
await _wifiController.close();
|
||||||
|
await _bleController.close();
|
||||||
|
await _connectionController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleMessage(dynamic data) {
|
||||||
|
final raw = data is String ? data : utf8.decode(data as List<int>);
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
if (decoded is! Map) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final event = AppEvent.fromJson(Map<String, dynamic>.from(decoded));
|
||||||
|
_eventController.add(event);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case 'status_update':
|
||||||
|
_statusController.add(event.payload);
|
||||||
|
break;
|
||||||
|
case 'state_update':
|
||||||
|
_stateController.add(event.payload);
|
||||||
|
break;
|
||||||
|
case 'config_update':
|
||||||
|
_configController.add(event.payload);
|
||||||
|
break;
|
||||||
|
case 'wifi_update':
|
||||||
|
_wifiController.add(event.payload);
|
||||||
|
break;
|
||||||
|
case 'ble_update':
|
||||||
|
_bleController.add(event.payload);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSocketClosed() {
|
||||||
|
_channel = null;
|
||||||
|
_subscription = null;
|
||||||
|
_setConnectionStatus(SocketConnectionStatus.disconnected);
|
||||||
|
unawaited(reconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setConnectionStatus(SocketConnectionStatus status) {
|
||||||
|
_connectionStatus = status;
|
||||||
|
_connectionController.add(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normalizeDeviceIp(String raw) {
|
||||||
|
var value = raw.trim();
|
||||||
|
if (value.startsWith('ws://')) {
|
||||||
|
value = value.substring(5);
|
||||||
|
} else if (value.startsWith('wss://')) {
|
||||||
|
value = value.substring(6);
|
||||||
|
} else if (value.startsWith('http://')) {
|
||||||
|
value = value.substring(7);
|
||||||
|
} else if (value.startsWith('https://')) {
|
||||||
|
value = value.substring(8);
|
||||||
|
}
|
||||||
|
|
||||||
|
final slashIndex = value.indexOf('/');
|
||||||
|
if (slashIndex >= 0) {
|
||||||
|
value = value.substring(0, slashIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
final colonIndex = value.indexOf(':');
|
||||||
|
if (colonIndex >= 0) {
|
||||||
|
value = value.substring(0, colonIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
clients/flutter/lib/theme/app_colors.dart
Normal file
39
clients/flutter/lib/theme/app_colors.dart
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class AppColors {
|
||||||
|
static const Color primary = Color(0xFF6366F1);
|
||||||
|
static const Color secondary = Color(0xFF8B5CF6);
|
||||||
|
static const Color accent = Color(0xFFEC4899);
|
||||||
|
|
||||||
|
static const Color success = Color(0xFF10B981);
|
||||||
|
static const Color warning = Color(0xFFF59E0B);
|
||||||
|
static const Color error = Color(0xFFEF4444);
|
||||||
|
static const Color info = Color(0xFF3B82F6);
|
||||||
|
|
||||||
|
static const Color background = Color(0xFF0F172A);
|
||||||
|
static const Color card = Color(0xFF1E293B);
|
||||||
|
static const Color border = Color(0xFF334155);
|
||||||
|
static const Color textPrimary = Color(0xFFF1F5F9);
|
||||||
|
static const Color textSecondary = Color(0xFF94A3B8);
|
||||||
|
|
||||||
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
|
colors: [primary, secondary],
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppRadius {
|
||||||
|
static const double small = 4;
|
||||||
|
static const double medium = 8;
|
||||||
|
static const double large = 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppSpacing {
|
||||||
|
static const double xs = 4;
|
||||||
|
static const double sm = 8;
|
||||||
|
static const double md = 16;
|
||||||
|
static const double lg = 24;
|
||||||
|
static const double xl = 32;
|
||||||
|
static const double xxl = 48;
|
||||||
|
}
|
||||||
192
clients/flutter/lib/theme/app_theme.dart
Normal file
192
clients/flutter/lib/theme/app_theme.dart
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'app_colors.dart';
|
||||||
|
|
||||||
|
class AppTheme {
|
||||||
|
static ThemeData dark() {
|
||||||
|
const colorScheme = ColorScheme.dark(
|
||||||
|
primary: AppColors.primary,
|
||||||
|
secondary: AppColors.secondary,
|
||||||
|
surface: AppColors.card,
|
||||||
|
error: AppColors.error,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
onSecondary: Colors.white,
|
||||||
|
onSurface: AppColors.textPrimary,
|
||||||
|
onError: Colors.white,
|
||||||
|
);
|
||||||
|
|
||||||
|
final base = ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
colorScheme: colorScheme,
|
||||||
|
scaffoldBackgroundColor: AppColors.background,
|
||||||
|
canvasColor: AppColors.background,
|
||||||
|
splashColor: AppColors.primary.withOpacity(0.12),
|
||||||
|
highlightColor: AppColors.primary.withOpacity(0.08),
|
||||||
|
dividerColor: AppColors.border,
|
||||||
|
cardColor: AppColors.card,
|
||||||
|
fontFamily: 'Noto Sans SC',
|
||||||
|
textTheme: _textTheme,
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
centerTitle: false,
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
titleTextStyle: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontFamily: 'Noto Sans SC',
|
||||||
|
fontFamilyFallback: ['Inter'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cardTheme: CardThemeData(
|
||||||
|
color: AppColors.card,
|
||||||
|
elevation: 6,
|
||||||
|
shadowColor: Colors.black.withOpacity(0.20),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: AppColors.background,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.medium),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.medium),
|
||||||
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
hintStyle: const TextStyle(color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
navigationBarTheme: NavigationBarThemeData(
|
||||||
|
backgroundColor: AppColors.card,
|
||||||
|
height: 64,
|
||||||
|
indicatorColor: AppColors.primary.withOpacity(0.18),
|
||||||
|
labelTextStyle: WidgetStateProperty.all(
|
||||||
|
const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontFamilyFallback: ['Noto Sans SC'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
iconTheme: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => IconThemeData(
|
||||||
|
size: 24,
|
||||||
|
color: states.contains(WidgetState.selected)
|
||||||
|
? AppColors.textPrimary
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filledButtonTheme: FilledButtonThemeData(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.medium),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontFamilyFallback: ['Noto Sans SC'],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
foregroundColor: AppColors.primary,
|
||||||
|
side: const BorderSide(color: AppColors.primary),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.medium),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
switchTheme: SwitchThemeData(
|
||||||
|
trackOutlineColor: WidgetStateProperty.all(Colors.transparent),
|
||||||
|
thumbColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? AppColors.primary
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
trackColor: WidgetStateProperty.resolveWith(
|
||||||
|
(states) => states.contains(WidgetState.selected)
|
||||||
|
? AppColors.primary.withOpacity(0.4)
|
||||||
|
: AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
sliderTheme: const SliderThemeData(
|
||||||
|
activeTrackColor: AppColors.primary,
|
||||||
|
inactiveTrackColor: AppColors.border,
|
||||||
|
thumbColor: AppColors.primary,
|
||||||
|
trackHeight: 4,
|
||||||
|
thumbShape: RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return base.copyWith(
|
||||||
|
textSelectionTheme: const TextSelectionThemeData(
|
||||||
|
cursorColor: AppColors.primary,
|
||||||
|
selectionColor: Color(0x446366F1),
|
||||||
|
selectionHandleColor: AppColors.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static const TextTheme _textTheme = TextTheme(
|
||||||
|
headlineLarge: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontFamilyFallback: ['Noto Sans SC'],
|
||||||
|
),
|
||||||
|
headlineMedium: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontFamilyFallback: ['Noto Sans SC'],
|
||||||
|
),
|
||||||
|
headlineSmall: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontFamily: 'Inter',
|
||||||
|
fontFamilyFallback: ['Noto Sans SC'],
|
||||||
|
),
|
||||||
|
bodyLarge: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
height: 1.5,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontFamily: 'Noto Sans SC',
|
||||||
|
fontFamilyFallback: ['Inter'],
|
||||||
|
),
|
||||||
|
bodyMedium: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1.4,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontFamily: 'Noto Sans SC',
|
||||||
|
fontFamilyFallback: ['Inter'],
|
||||||
|
),
|
||||||
|
bodySmall: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.3,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontFamily: 'Noto Sans SC',
|
||||||
|
fontFamilyFallback: ['Inter'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
46
clients/flutter/lib/widgets/control_button.dart
Normal file
46
clients/flutter/lib/widgets/control_button.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class ControlButton extends StatelessWidget {
|
||||||
|
const ControlButton({
|
||||||
|
required this.label,
|
||||||
|
required this.icon,
|
||||||
|
required this.onPressed,
|
||||||
|
this.isFilled = true,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final bool isFilled;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (isFilled) {
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.medium),
|
||||||
|
),
|
||||||
|
child: FilledButton.icon(
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
shadowColor: Colors.transparent,
|
||||||
|
minimumSize: const Size.fromHeight(48),
|
||||||
|
),
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon),
|
||||||
|
label: Text(label),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return OutlinedButton.icon(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(icon),
|
||||||
|
label: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
clients/flutter/lib/widgets/status_card.dart
Normal file
55
clients/flutter/lib/widgets/status_card.dart
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class StatusCard extends StatelessWidget {
|
||||||
|
const StatusCard({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
required this.accentColor,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final Color accentColor;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(AppSpacing.md),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: accentColor.withOpacity(0.16),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.medium),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: accentColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppSpacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Text(value, style: Theme.of(context).textTheme.headlineSmall),
|
||||||
|
const SizedBox(height: AppSpacing.xs),
|
||||||
|
Text(subtitle, style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
clients/flutter/lib/widgets/wifi_list_tile.dart
Normal file
40
clients/flutter/lib/widgets/wifi_list_tile.dart
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/wifi_network.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class WifiListTile extends StatelessWidget {
|
||||||
|
const WifiListTile({
|
||||||
|
required this.network,
|
||||||
|
required this.onTap,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
|
final WifiNetwork network;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Card(
|
||||||
|
child: ListTile(
|
||||||
|
onTap: onTap,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppSpacing.md,
|
||||||
|
vertical: AppSpacing.xs,
|
||||||
|
),
|
||||||
|
leading: Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.info.withOpacity(0.14),
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.medium),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.wifi_rounded, color: AppColors.info),
|
||||||
|
),
|
||||||
|
title: Text(network.ssid),
|
||||||
|
subtitle: Text('${network.security} · ${network.signalLabel}'),
|
||||||
|
trailing: const Icon(Icons.chevron_right_rounded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
clients/flutter/pubspec.yaml
Normal file
25
clients/flutter/pubspec.yaml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
name: showen_v2_flutter
|
||||||
|
description: ShowenV2 cross-platform mobile controller.
|
||||||
|
publish_to: 'none'
|
||||||
|
|
||||||
|
version: 0.1.0+1
|
||||||
|
|
||||||
|
environment:
|
||||||
|
sdk: '>=3.3.0 <4.0.0'
|
||||||
|
|
||||||
|
dependencies:
|
||||||
|
flutter:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_blue_plus: ^1.35.3
|
||||||
|
go_router: ^14.8.1
|
||||||
|
http: ^1.2.1
|
||||||
|
provider: ^6.1.2
|
||||||
|
web_socket_channel: ^3.0.1
|
||||||
|
|
||||||
|
dev_dependencies:
|
||||||
|
flutter_test:
|
||||||
|
sdk: flutter
|
||||||
|
flutter_lints: ^4.0.0
|
||||||
|
|
||||||
|
flutter:
|
||||||
|
uses-material-design: true
|
||||||
@@ -294,7 +294,7 @@ fn default_ble_enabled() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
fn default_ble_device_name() -> String {
|
fn default_ble_device_name() -> String {
|
||||||
"showen".to_string()
|
"Showen".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 加载与验证 ──
|
// ── 加载与验证 ──
|
||||||
|
|||||||
@@ -165,8 +165,8 @@ struct AdvertisementData {
|
|||||||
pub fn run_ble_service(
|
pub fn run_ble_service(
|
||||||
device_name: String,
|
device_name: String,
|
||||||
tx: mpsc::Sender<Envelope>,
|
tx: mpsc::Sender<Envelope>,
|
||||||
control_rx: Receiver<BleControl>,
|
control_rx: &Receiver<BleControl>,
|
||||||
stop: Arc<AtomicBool>,
|
stop: &Arc<AtomicBool>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let shared = SharedState::new(tx.clone());
|
let shared = SharedState::new(tx.clone());
|
||||||
|
|
||||||
@@ -277,6 +277,9 @@ pub fn run_ble_service(
|
|||||||
let adapter_path = find_adapter(&conn)?;
|
let adapter_path = find_adapter(&conn)?;
|
||||||
configure_adapter(&conn, &adapter_path, &device_name)?;
|
configure_adapter(&conn, &adapter_path, &device_name)?;
|
||||||
|
|
||||||
|
// 先尝试清理上一次进程残留的注册(防止崩溃后 BlueZ 状态残留)
|
||||||
|
let _ = unregister_ble_objects(&conn, &adapter_path);
|
||||||
|
|
||||||
// 非阻塞发送 RegisterApplication + RegisterAdvertisement
|
// 非阻塞发送 RegisterApplication + RegisterAdvertisement
|
||||||
let _gatt_serial = send_register_gatt_app(&conn, &adapter_path)?;
|
let _gatt_serial = send_register_gatt_app(&conn, &adapter_path)?;
|
||||||
let _ad_serial = send_register_advertisement(&conn, &adapter_path)?;
|
let _ad_serial = send_register_advertisement(&conn, &adapter_path)?;
|
||||||
@@ -554,6 +557,20 @@ fn configure_adapter(conn: &Connection, adapter_path: &str, device_name: &str) -
|
|||||||
adapter
|
adapter
|
||||||
.set(ADAPTER_IFACE, "Alias", device_name.to_string())
|
.set(ADAPTER_IFACE, "Alias", device_name.to_string())
|
||||||
.context("failed to set BLE adapter alias")?;
|
.context("failed to set BLE adapter alias")?;
|
||||||
|
adapter
|
||||||
|
.set(ADAPTER_IFACE, "Discoverable", true)
|
||||||
|
.context("failed to set BLE adapter discoverable")?;
|
||||||
|
// DiscoverableTimeout=0 表示永久可发现
|
||||||
|
adapter
|
||||||
|
.set(ADAPTER_IFACE, "DiscoverableTimeout", 0u32)
|
||||||
|
.context("failed to set DiscoverableTimeout")?;
|
||||||
|
adapter
|
||||||
|
.set(ADAPTER_IFACE, "Pairable", true)
|
||||||
|
.context("failed to set BLE adapter pairable")?;
|
||||||
|
// PairableTimeout=0 表示永久可配对
|
||||||
|
adapter
|
||||||
|
.set(ADAPTER_IFACE, "PairableTimeout", 0u32)
|
||||||
|
.context("failed to set PairableTimeout")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,11 +82,32 @@ impl Plugin for BlePlugin {
|
|||||||
self.control_tx = Some(control_tx);
|
self.control_tx = Some(control_tx);
|
||||||
|
|
||||||
self.worker = Some(thread::spawn(move || {
|
self.worker = Some(thread::spawn(move || {
|
||||||
let result = gatt::run_ble_service(device_name, tx, control_rx, stop);
|
// 自动重连循环:BLE 服务出错后等待 3 秒重试
|
||||||
if let Err(ref error) = result {
|
loop {
|
||||||
eprintln!("[BlePlugin] worker exited with error: {error:#}");
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let result =
|
||||||
|
gatt::run_ble_service(device_name.clone(), tx.clone(), &control_rx, &stop);
|
||||||
|
match result {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(ref error) => {
|
||||||
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
eprintln!(
|
||||||
|
"[BlePlugin] worker error: {error:#}, restarting in 3 seconds..."
|
||||||
|
);
|
||||||
|
// 等待 3 秒,但如果 stop 被设置则立即退出
|
||||||
|
for _ in 0..30 {
|
||||||
|
if stop.load(Ordering::SeqCst) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
thread::sleep(std::time::Duration::from_millis(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
result
|
|
||||||
}));
|
}));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user