chore: 初始化 iotsaas 版本库基线
纳入范围: - backend/ Spring Boot 多模块后端(iot-common/device/rule/data/alarm/auth) - frontend/admin/ Vue3 + Element Plus 管理后台 - Android/ 涂鸦原生 APP (com.xhai.iot) - backend/sql/ Flyway 风格迁移脚本(V2~V21) - docs/ 对接文档与设计文档 排除范围: - 构建产物: node_modules/ target/ build/ dist/ .gradle/ - 签名密钥: *.jks *.keystore (严禁入库) - 本机配置: Android/local.properties - 运行日志: logs/ *.log - AI 协作本地数据 .codebuddy/ 与第三方 demo tuya-demo/ .gitattributes 统一以 LF 存储, Windows 批处理保留 CRLF
This commit is contained in:
commit
0ce191c448
41
.gitattributes
vendored
Normal file
41
.gitattributes
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# ============================================================
|
||||||
|
# iotsaas · 换行符与二进制属性
|
||||||
|
# 统一以 LF 存储,避免 Windows/Linux 跨端 diff 噪声
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
* text=auto eol=lf
|
||||||
|
|
||||||
|
# Windows 批处理脚本需 CRLF
|
||||||
|
*.bat text eol=crlf
|
||||||
|
*.cmd text eol=crlf
|
||||||
|
*.ps1 text eol=crlf
|
||||||
|
|
||||||
|
# Shell 脚本需 LF
|
||||||
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# 二进制文件,禁止行尾转换
|
||||||
|
*.png binary
|
||||||
|
*.jpg binary
|
||||||
|
*.jpeg binary
|
||||||
|
*.gif binary
|
||||||
|
*.ico binary
|
||||||
|
*.webp binary
|
||||||
|
*.jar binary
|
||||||
|
*.aar binary
|
||||||
|
*.apk binary
|
||||||
|
*.aab binary
|
||||||
|
*.dex binary
|
||||||
|
*.jks binary
|
||||||
|
*.keystore binary
|
||||||
|
*.so binary
|
||||||
|
*.zip binary
|
||||||
|
*.gz binary
|
||||||
|
*.pdf binary
|
||||||
|
*.docx binary
|
||||||
|
*.xlsx binary
|
||||||
|
*.pptx binary
|
||||||
|
*.woff binary
|
||||||
|
*.woff2 binary
|
||||||
|
*.ttf binary
|
||||||
|
*.mp3 binary
|
||||||
|
*.mp4 binary
|
||||||
75
.gitignore
vendored
Normal file
75
.gitignore
vendored
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
# ============================================================
|
||||||
|
# iotsaas · IoT SaaS 平台
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
# ---- AI 协作本地数据(仅本机,不纳入版本管理)----
|
||||||
|
.codebuddy/
|
||||||
|
|
||||||
|
# ---- 第三方 demo(自带独立 git 仓库,保留其原历史,不并入本仓)----
|
||||||
|
tuya-demo/
|
||||||
|
|
||||||
|
# ---- 构建产物 ----
|
||||||
|
**/node_modules/
|
||||||
|
**/target/
|
||||||
|
**/build/
|
||||||
|
**/dist/
|
||||||
|
**/out/
|
||||||
|
**/.gradle/
|
||||||
|
**/.cxx/
|
||||||
|
**/coverage/
|
||||||
|
|
||||||
|
# ---- Android ----
|
||||||
|
# 本机 SDK 绝对路径,不可移植
|
||||||
|
Android/local.properties
|
||||||
|
# 反编译/扫描临时产物
|
||||||
|
Android/tmp_scan*/
|
||||||
|
# 应用签名密钥:严禁入库
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
|
*.p12
|
||||||
|
*.pfx
|
||||||
|
# 打包产物
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# ---- 后端 ----
|
||||||
|
# 构建出的 jar;保留 Gradle Wrapper 需要的 jar
|
||||||
|
*.jar
|
||||||
|
!**/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
# 运行期目录
|
||||||
|
backend/**/logs/
|
||||||
|
xxl-job-logs/
|
||||||
|
nohup.out
|
||||||
|
|
||||||
|
# ---- 日志与运行时 ----
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
*.log.*
|
||||||
|
|
||||||
|
# ---- IDE ----
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
!.vscode/settings.json
|
||||||
|
|
||||||
|
# ---- OS ----
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# ---- 临时产物 ----
|
||||||
|
# uiautomator dump / 截图 等调试残留
|
||||||
|
ui*.xml
|
||||||
|
screencap*.png
|
||||||
|
$null
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.orig
|
||||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic",
|
||||||
|
"java.configuration.updateBuildConfiguration": "automatic"
|
||||||
|
}
|
||||||
102
Android/README.md
Normal file
102
Android/README.md
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
# XHai 智能物联 - Android 客户端
|
||||||
|
|
||||||
|
基于涂鸦 **Thing Smart Home SDK 7.5.0** 原生开发的 Android 客户端。
|
||||||
|
实现:**用户登录(iotsaas 后端)+ 涂鸦账号绑定/授权**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、功能范围(当前阶段)
|
||||||
|
|
||||||
|
| 功能 | 状态 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 用户登录 | ✅ | 对接 iotsaas 后端 `POST /api/v1/auth/member/login` |
|
||||||
|
| 用户注册 | ✅ | 对接 `POST /api/v1/auth/member/register` |
|
||||||
|
| 涂鸦账号绑定 | ✅ | 涂鸦原生 SDK 登录,绑定后同步 `tuya_user_binding` |
|
||||||
|
| 涂鸦账号解绑 | ✅ | 对接 `POST /api/v1/tuya/auth/unbind` |
|
||||||
|
| 绑定状态查询 | ✅ | 对接 `GET /api/v1/tuya/auth/status` |
|
||||||
|
| 设备管理/控制 | ⏳ | 走通绑定后再开发 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、环境要求
|
||||||
|
|
||||||
|
- **Android Studio**(Hedgehog 或更新版本)
|
||||||
|
- **JDK 17**(涂鸦 SDK 需要)
|
||||||
|
- **Android SDK**(API 34)
|
||||||
|
- **NDK**(`arm64-v8a`、`armeabi-v7a`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、涂鸦平台配置(必须完成)
|
||||||
|
|
||||||
|
> ⚠️ **关键**:涂鸦 SDK 初始化要求 `AppKey`、`AppSecret`、**包名**、**签名 keystore 的 SHA256** 四项全部与涂鸦平台登记一致,否则报 `illegal client`。
|
||||||
|
|
||||||
|
### 1. 确认 App 授权信息
|
||||||
|
登录 [涂鸦开发者平台](https://developer.tuya.com/cn/) → 云开发 → 你的项目 → **App 授权**:
|
||||||
|
- **AppKey**(Client ID):`wuvw788psccvxndpyxnv`
|
||||||
|
- **AppSecret**(Client Secret):需确认
|
||||||
|
- **包名**:`com.xhai.iot`
|
||||||
|
|
||||||
|
### 2. 配置签名证书 SHA256
|
||||||
|
涂鸦 SDK 校验应用签名。必须把**打包用 keystore 的 SHA256** 配置到涂鸦平台对应 App 的"Android 签名证书"里。
|
||||||
|
|
||||||
|
本项目已生成开发用 keystore:`app/xhai_iot.jks`(密码 `xhai123456`,alias `xhai`)
|
||||||
|
- **SHA256**:`F0:D1:85:41:CD:D2:21:E2:84:77:FF:E1:A4:CF:F7:19:41:D4:8A:BD:B7:77:83:F2:C0:DB:CE:FB:69:25:F6:13`
|
||||||
|
- **SHA1**(涂鸦 identifier 用):`0C44B4A0C696BC823B5BEFD52B16CFBF7D169B78`
|
||||||
|
|
||||||
|
> ⚠️ **注意**:涂鸦平台"App授权"里的 identifier(SHA1+包名)必须与打包签名一致。若涂鸦后台已配置了其他 keystore 的 SHA1,请使用那个 keystore 替换 `app/xhai_iot.jks`,或在涂鸦后台更新 SHA1。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、构建步骤
|
||||||
|
|
||||||
|
### 方式一:Android Studio(推荐)
|
||||||
|
1. 用 Android Studio 打开 `f:/work/iotsaas/Android` 文件夹
|
||||||
|
2. Android Studio 会自动下载 Gradle、AGP、涂鸦 SDK 依赖(需网络)
|
||||||
|
3. 等待首次 Gradle Sync 完成
|
||||||
|
4. 连接手机,点击 Run
|
||||||
|
|
||||||
|
### 方式二:命令行
|
||||||
|
```bash
|
||||||
|
cd Android
|
||||||
|
gradle assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、关键配置文件
|
||||||
|
|
||||||
|
| 文件 | 作用 |
|
||||||
|
|------|------|
|
||||||
|
| `app/build.gradle` | 依赖、签名、ABI 配置 |
|
||||||
|
| `app/src/main/AndroidManifest.xml` | AppKey/AppSecret、Activity 注册 |
|
||||||
|
| `app/src/main/java/.../data/ApiConfig.java` | 后端地址、涂鸦密钥 |
|
||||||
|
| `app/src/main/java/.../TuyaApplication.java` | 涂鸦 SDK 初始化 |
|
||||||
|
| `app/src/main/java/.../ui/TuyaBindActivity.java` | 涂鸦登录+绑定 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、API 对接(iotsaas 后端)
|
||||||
|
|
||||||
|
| 后端接口 | 方法 | 作用 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `/api/v1/auth/member/login` | POST | 登录 |
|
||||||
|
| `/api/v1/auth/member/register` | POST | 注册 |
|
||||||
|
| `/api/v1/tuya/auth/status` | GET | 涂鸦绑定状态 |
|
||||||
|
| `/api/v1/tuya/auth/bind-native` | POST | 绑定同步 |
|
||||||
|
| `/api/v1/tuya/auth/unbind` | POST | 解绑 |
|
||||||
|
|
||||||
|
> 所有涂鸦接口需要 `Authorization: Bearer <token>` 头。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、常见问题
|
||||||
|
|
||||||
|
### Q1: 启动报 `illegal client`
|
||||||
|
检查 AppKey/AppSecret/包名/keystore SHA256 四项与涂鸦平台是否一致。
|
||||||
|
|
||||||
|
### Q2: 绑定同步失败
|
||||||
|
检查后端是否已登录(token 有效)、`tuya_user_binding` 是否写入。
|
||||||
|
|
||||||
|
### Q3: 涂鸦登录失败
|
||||||
|
确认涂鸦账号存在、网络正常、SDK 已初始化成功。
|
||||||
168
Android/app/build.gradle
Normal file
168
Android/app/build.gradle
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'com.google.dagger.hilt.android'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.xhai.iot'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.xhai.iot"
|
||||||
|
minSdk 24
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// 多包名支持(涂鸦SDK需要)
|
||||||
|
multiDexEnabled true
|
||||||
|
|
||||||
|
// 配置 ABI(涂鸦SDK依赖较多,按需裁剪)
|
||||||
|
ndk {
|
||||||
|
abiFilters 'armeabi-v7a', 'arm64-v8a'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Java 8 支持
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名配置(必须与涂鸦平台登记的 keystore SHA256 一致)
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file("xhai_iot.jks")
|
||||||
|
storePassword "xhai123456"
|
||||||
|
keyAlias "xhai"
|
||||||
|
keyPassword "xhai123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
minifyEnabled false
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局排除:涂鸦摄像头/RTSP 模块(面板 RN 容器会注册这些包,但
|
||||||
|
// thingsmart-mediaplayer:4.5.0-rc.1 缺 MediaPlayerListener 类导致
|
||||||
|
// create_react_context 崩溃。我们不需要摄像头功能,直接排除。)
|
||||||
|
configurations.all {
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctrtspmediamanager'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctrtspmediaplayermanager'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameramanager'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameraviewmanager'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameraaudiomanager'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameramessagemanager'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameramessagemediaplayermanager'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameraplayer'
|
||||||
|
exclude group: 'com.thingclips.smart.rnplugin', module: 'trctcameratimelineviewmanager'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解决涂鸦SDK中 assets/res 合并冲突
|
||||||
|
packagingOptions {
|
||||||
|
// 涂鸦SDK so 库冲突处理
|
||||||
|
pickFirst 'lib/*/libc++_shared.so'
|
||||||
|
pickFirst 'lib/*/libyuv.so'
|
||||||
|
pickFirst 'lib/*/libopenh264.so'
|
||||||
|
pickFirst 'lib/*/libgnustl_shared.so'
|
||||||
|
pickFirst 'lib/*/libv8android.so'
|
||||||
|
pickFirst 'lib/*/libv8wrapper.so'
|
||||||
|
pickFirst 'lib/*/liblog.so'
|
||||||
|
pickFirst 'lib/*/libbytehook.so'
|
||||||
|
pickFirst 'lib/*/libxdl.so'
|
||||||
|
pickFirst 'lib/*/libsqlcipher.so'
|
||||||
|
resources {
|
||||||
|
excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt',
|
||||||
|
'META-INF/license.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt',
|
||||||
|
'META-INF/notice.txt', 'META-INF/ASL2.0', 'META-INF/INDEX.LIST',
|
||||||
|
'META-INF/versions/**']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// 本地 AAR 依赖
|
||||||
|
implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'])
|
||||||
|
|
||||||
|
// AndroidX
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
|
|
||||||
|
// 网络请求(对接 iotsaas 后端)
|
||||||
|
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||||
|
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||||
|
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||||
|
|
||||||
|
// Gson
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
|
||||||
|
// 图片加载(用于加载涂鸦设备图标)
|
||||||
|
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||||
|
|
||||||
|
// 二维码扫码(中控屏会员自助绑定 P4):自带 CaptureActivity,无需声明额外权限/Activity
|
||||||
|
implementation 'com.journeyapps:zxing-android-embedded:4.3.0'
|
||||||
|
|
||||||
|
// 涂鸦 SDK 依赖(升级至 7.8.0 + UI BizBundle)
|
||||||
|
implementation 'com.alibaba:fastjson:1.1.67.android'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.14.9'
|
||||||
|
|
||||||
|
// 涂鸦主 SDK 7.8.0
|
||||||
|
implementation('com.thingclips.smart:thingsmart:7.8.0') {
|
||||||
|
// 排除幽灵 SNAPSHOT 组件(编译期注解,运行时不需要)
|
||||||
|
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 涂鸦 UI BizBundle(设备控制面板 + 家庭服务)
|
||||||
|
// BOM 统一管理各 BizBundle 版本
|
||||||
|
api enforcedPlatform('com.thingclips.smart:thingsmart-BizBundlesBom:7.8.14')
|
||||||
|
|
||||||
|
// 设备控制业务包(RN 容器,原生面板核心)
|
||||||
|
implementation 'com.thingclips.smart:thingsmart-bizbundle-panel'
|
||||||
|
// 基础扩展能力
|
||||||
|
implementation('com.thingclips.smart:thingsmart-bizbundle-basekit') {
|
||||||
|
exclude group: 'com.thingclips.smart', module: 'thingplugin-annotation'
|
||||||
|
}
|
||||||
|
// 涂鸦业务扩展能力
|
||||||
|
implementation 'com.thingclips.smart:thingsmart-bizbundle-bizkit'
|
||||||
|
// 设备控制相关能力
|
||||||
|
implementation 'com.thingclips.smart:thingsmart-bizbundle-devicekit'
|
||||||
|
// 家庭服务(面板必需)
|
||||||
|
implementation 'com.thingclips.smart:thingsmart-bizbundle-family'
|
||||||
|
// 小程序业务包(提供 AbsMiniAppConfigInjectService 注入服务,
|
||||||
|
// 面板 RN 容器依赖 MiniAppConfig,缺失会导致 ThingRCTSmartPanelActivity.onCreate NPE 崩溃)
|
||||||
|
implementation 'com.thingclips.smart:thingsmart-bizbundle-miniapp'
|
||||||
|
|
||||||
|
// 涂鸦扩展 SDK(提供配网/激活能力)
|
||||||
|
implementation('com.thingclips.smart:thingsmart-expansion-sdk:7.8.0') {
|
||||||
|
exclude group: 'com.thingclips.android.module', module: 'thingmodule-annotation'
|
||||||
|
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 涂鸦 Matter 配网能力(设备委任 API:CommissioningParameters / MatterActivatorCallback 等)
|
||||||
|
// 主 thingsmart aar 不含这些类,需显式引入 matterlib-api(编译期类型)与 matterlib(运行期实现)
|
||||||
|
implementation 'com.thingclips.smart:thingsmart-matterlib-api:7.8.0'
|
||||||
|
implementation 'com.thingclips.smart:thingsmart-matterlib:7.8.0'
|
||||||
|
|
||||||
|
// Hilt(BizBundle 依赖注入)
|
||||||
|
implementation 'com.google.dagger:hilt-android:2.58'
|
||||||
|
annotationProcessor 'com.google.dagger:hilt-android-compiler:2.58'
|
||||||
|
|
||||||
|
// 多Dex
|
||||||
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
|
}
|
||||||
118
Android/app/build.gradle.bak611
Normal file
118
Android/app/build.gradle.bak611
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.xhai.iot'
|
||||||
|
compileSdk 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.xhai.iot"
|
||||||
|
minSdk 23
|
||||||
|
targetSdk 34
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
|
// 多包名支持(涂鸦SDK需要)
|
||||||
|
multiDexEnabled true
|
||||||
|
|
||||||
|
// 配置 ABI(涂鸦SDK依赖较多,按需裁剪)
|
||||||
|
ndk {
|
||||||
|
abiFilters 'armeabi-v7a', 'arm64-v8a'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Java 8 支持
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名配置(必须与涂鸦平台登记的 keystore SHA256 一致)
|
||||||
|
signingConfigs {
|
||||||
|
release {
|
||||||
|
storeFile file("xhai_iot.jks")
|
||||||
|
storePassword "xhai123456"
|
||||||
|
keyAlias "xhai"
|
||||||
|
keyPassword "xhai123456"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
debug {
|
||||||
|
minifyEnabled false
|
||||||
|
signingConfig signingConfigs.release
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解决涂鸦SDK中 assets/res 合并冲突
|
||||||
|
packagingOptions {
|
||||||
|
// 涂鸦SDK so 库冲突处理
|
||||||
|
pickFirst 'lib/*/libc++_shared.so'
|
||||||
|
pickFirst 'lib/*/libyuv.so'
|
||||||
|
pickFirst 'lib/*/libopenh264.so'
|
||||||
|
resources {
|
||||||
|
excludes += ['META-INF/DEPENDENCIES', 'META-INF/LICENSE', 'META-INF/LICENSE.txt',
|
||||||
|
'META-INF/license.txt', 'META-INF/NOTICE', 'META-INF/NOTICE.txt',
|
||||||
|
'META-INF/notice.txt', 'META-INF/ASL2.0']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// 本地 AAR 依赖
|
||||||
|
implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'])
|
||||||
|
|
||||||
|
// AndroidX
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.google.android.material:material:1.11.0'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
|
implementation 'androidx.recyclerview:recyclerview:1.3.2'
|
||||||
|
|
||||||
|
// 网络请求(对接 iotsaas 后端)
|
||||||
|
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
|
||||||
|
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
|
||||||
|
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
|
||||||
|
|
||||||
|
// Gson
|
||||||
|
implementation 'com.google.code.gson:gson:2.10.1'
|
||||||
|
|
||||||
|
// 图片加载(用于加载涂鸦设备图标)
|
||||||
|
implementation 'com.github.bumptech.glide:glide:4.16.0'
|
||||||
|
|
||||||
|
// 涂鸦 SDK 依赖(来自 SDK 包 dependencies.txt)
|
||||||
|
implementation 'com.alibaba:fastjson:1.1.67.android'
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.14.9'
|
||||||
|
implementation('com.thingclips.smart:thingsmart:6.11.1') {
|
||||||
|
// 排除幽灵 SNAPSHOT 组件(编译期注解,运行时不需要)
|
||||||
|
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
|
||||||
|
// 注意:不能排除 thingsmart-asynclib!
|
||||||
|
// 6.11.1 的 ThingHomeListCache 运行时需要 com.thingclips.smart.asynclib.schedulers.ThreadEnv
|
||||||
|
// 排除会导致点击"我的设备"时 NoClassDefFoundError 闪退
|
||||||
|
}
|
||||||
|
// 注意:涂鸦 panel SDK (thingsmart-panel:6.11.1) 在公开 Maven 仓库不可用,
|
||||||
|
// 需从涂鸦开发者平台私有仓库下载后本地集成。
|
||||||
|
// 当前通过反射调用 gotoPanelActivity,SDK 可用时自动生效,无需改代码。
|
||||||
|
// compileOnly 'com.thingclips.smart:thingsmart-panel:6.11.1' // 待私有仓库集成后启用
|
||||||
|
|
||||||
|
// 涂鸦扩展 SDK(提供配网/激活能力 ThingActivatorCoreKit)
|
||||||
|
// 排除编译期注解 SNAPSHOT 组件(运行时不需要)
|
||||||
|
implementation('com.thingclips.smart:thingsmart-expansion-sdk:6.11.1') {
|
||||||
|
exclude group: 'com.thingclips.android.module', module: 'thingmodule-annotation'
|
||||||
|
exclude group: 'com.thingclips.smart', module: 'thingsmart-modularCampAnno'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多Dex
|
||||||
|
implementation 'androidx.multidex:multidex:2.0.1'
|
||||||
|
}
|
||||||
BIN
Android/app/libs/security-algorithm-1.0.0-beta.aar
Normal file
BIN
Android/app/libs/security-algorithm-1.0.0-beta.aar
Normal file
Binary file not shown.
18
Android/app/proguard-rules.pro
vendored
Normal file
18
Android/app/proguard-rules.pro
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# 涂鸦 SDK 混淆规则
|
||||||
|
-keep class com.thingclips.** { *; }
|
||||||
|
-keep class com.tuya.** { *; }
|
||||||
|
-keep class com.thingclips.smart.** { *; }
|
||||||
|
-dontwarn com.thingclips.**
|
||||||
|
-dontwarn com.tuya.**
|
||||||
|
|
||||||
|
# fastjson
|
||||||
|
-keep class com.alibaba.fastjson.** { *; }
|
||||||
|
-dontwarn com.alibaba.fastjson.**
|
||||||
|
|
||||||
|
# okhttp
|
||||||
|
-dontwarn okhttp3.**
|
||||||
|
-dontwarn okio.**
|
||||||
|
|
||||||
|
# Gson
|
||||||
|
-keep class com.google.gson.** { *; }
|
||||||
|
-keep class com.xhai.iot.model.** { *; }
|
||||||
192
Android/app/src/main/AndroidManifest.xml
Normal file
192
Android/app/src/main/AndroidManifest.xml
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<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.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||||
|
<!-- Android 12+ 蓝牙扫描/连接权限(配网必需) -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.BLUETOOTH_SCAN"
|
||||||
|
android:usesPermissionFlags="neverForLocation"
|
||||||
|
tools:targetApi="s" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<!-- 蓝牙 Mesh 本地通道需要发送广播唤醒设备,缺失会导致 Mesh 设备无法识别在线 -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
|
||||||
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
|
||||||
|
<!-- 移除涂鸦SDK的桌面快捷方式权限:禁止 SDK 自行创建桌面快捷方式 -->
|
||||||
|
<uses-permission
|
||||||
|
android:name="com.android.launcher.permission.INSTALL_SHORTCUT"
|
||||||
|
tools:node="remove" />
|
||||||
|
|
||||||
|
<!-- 涂鸦SDK所需 -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" />
|
||||||
|
|
||||||
|
<!-- Android 11+ 包可见性:AI 助手语音输入需要发现系统语音识别服务,否则 SpeechRecognizer 不可用 -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:name=".TuyaApplication"
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.XhaiIoT"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
tools:replace="android:allowBackup,android:supportsRtl,android:theme"
|
||||||
|
tools:targetApi="31">
|
||||||
|
|
||||||
|
<!-- 涂鸦 SDK AppKey/AppSecret 配置(涂鸦SDK自动读取) -->
|
||||||
|
<meta-data
|
||||||
|
android:name="tuya_sdk_appkey"
|
||||||
|
android:value="cex9ntw3dsr3tywkrggp" />
|
||||||
|
<meta-data
|
||||||
|
android:name="tuya_sdk_appsecret"
|
||||||
|
android:value="9cqqk43gksxdkueppjsexnvjttd5evaf" />
|
||||||
|
|
||||||
|
<!-- 涂鸦 UI BizBundle 所需配置 -->
|
||||||
|
<meta-data
|
||||||
|
android:name="THING_SMART_APPKEY"
|
||||||
|
android:value="cex9ntw3dsr3tywkrggp" />
|
||||||
|
<meta-data
|
||||||
|
android:name="THING_SMART_SECRET"
|
||||||
|
android:value="9cqqk43gksxdkueppjsexnvjttd5evaf" />
|
||||||
|
<meta-data
|
||||||
|
android:name="PACKAGE_NAME"
|
||||||
|
android:value="${applicationId}" />
|
||||||
|
<meta-data
|
||||||
|
android:name="APP_SCHEME_NAME"
|
||||||
|
android:value="xhaiiot" />
|
||||||
|
|
||||||
|
<!-- 登录页 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.LoginActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
<!-- 注册页 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.RegisterActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 涂鸦绑定页 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.TuyaBindActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 主界面 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.MainActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 设备列表 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.DeviceActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 操控动态列表页 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.DeviceActivityActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 设备控制 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.DeviceControlActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 网关面板(展示子设备列表,网关与子设备关系由我们后端维护) -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.GatewayPanelActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 添加子设备 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.AddSubDeviceActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 场景详情 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.SceneDetailActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 添加设备(配网) -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.AddDeviceActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 家庭管理 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.HomeManagerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 创建场景 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.SceneActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 房间管理 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.RoomManagerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 设备管理 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.DeviceManagerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- HomeAI 助手 -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.AiAssistantActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
|
|
||||||
|
<!-- 我的中控屏(会员扫码自助绑定) -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.PanelListActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 扫码页(自定义,带关闭按钮,继承应用 DayNight 主题) -->
|
||||||
|
<activity
|
||||||
|
android:name=".ui.ZxingScanActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:theme="@style/Theme.XhaiIoT" />
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
BIN
Android/app/src/main/assets/Manrope-Bold.ttf
Normal file
BIN
Android/app/src/main/assets/Manrope-Bold.ttf
Normal file
Binary file not shown.
374
Android/app/src/main/assets/configList.json
Normal file
374
Android/app/src/main/assets/configList.json
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
{
|
||||||
|
"my":[
|
||||||
|
{
|
||||||
|
"type" :"avatar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" :16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"thirdService"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" :16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"family"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "deviceList"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"outdoorOfflineStore"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"message"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"healthCenter"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"help"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"moreService"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"mall"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"setting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16,
|
||||||
|
"bindTypes": ["exit"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"exit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"account":[
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"avatar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"nick"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"timeZone"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"accountSecurity":[
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"region"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"phoneNumber"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "bindPhone"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"email"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"bindEmail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"thirdAccount"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"cloudProject"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"manageAuthorization"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"password"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fingerLogin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"gestureSwitch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"gestureChange"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"gestureSetting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"loginVertify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"close"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"setting":[
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"account"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"security"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "deviceUpgrades"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"sound"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"push"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "scan"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "active"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"darkColorMode"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"unit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" : "pad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"about"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"privacySetting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"privacyPolicySetting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"network"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"cache"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"language-debug"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "empty",
|
||||||
|
"bindTypes": ["language"],
|
||||||
|
"height": 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "language"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 26
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"logout"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"about":[
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"rateUs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"aboutUs"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"source"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"uploadLog"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"currentVersion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"checkVersion"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"testflight"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"environment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"empty",
|
||||||
|
"height" : 16
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type" :"consumerHotline"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"deviceDetail":[
|
||||||
|
{
|
||||||
|
"type":"header"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "device_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "device_net_info"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "connect_cloud_activation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"group_edit_devices"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cellular_management"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"scene_and_automation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"device_evaluation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"recommend_products"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"section_third-party_control"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"third-party_control"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"section_off_line_warn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"off_line_warn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"section_other"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"device_from"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"bind_multi_control_link"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"sync_control"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"panel_share_group_or_device"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"group_create"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"link_device"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"location_access_authorization"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"product_instruction"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"help_and_feedback"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"add_icon_to_home_screen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"show_infrared_gateway_sub_device"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"device_add_to_family"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"check_device_network"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"accessories_manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"check_firmware_update"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"sub_device_migrate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"empty",
|
||||||
|
"height":10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"footer"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"devBaseInfo": [
|
||||||
|
{
|
||||||
|
"type": "dev_icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "set_icon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "set_name"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "set_position"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
541
Android/app/src/main/assets/default_iconfont.json
Normal file
541
Android/app/src/main/assets/default_iconfont.json
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
{
|
||||||
|
"id": "3757359",
|
||||||
|
"name": "default_iconfont",
|
||||||
|
"font_family": "default_iconfont",
|
||||||
|
"css_prefix_text": "icon-",
|
||||||
|
"description": "",
|
||||||
|
"glyphs": [
|
||||||
|
{
|
||||||
|
"icon_id": "35597351",
|
||||||
|
"name": "navbar_security_IC3_N1",
|
||||||
|
"font_class": "navbar_security_IC3_N1",
|
||||||
|
"unicode": "e9b6",
|
||||||
|
"unicode_decimal": 59830
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775867",
|
||||||
|
"name": "home_card_on_IC1_N6",
|
||||||
|
"font_class": "home_card_on_IC1_N6",
|
||||||
|
"unicode": "e97a",
|
||||||
|
"unicode_decimal": 59770
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775868",
|
||||||
|
"name": "smart_card_off_IC5_N6",
|
||||||
|
"font_class": "smart_card_off_IC5_N6",
|
||||||
|
"unicode": "e97d",
|
||||||
|
"unicode_decimal": 59773
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775869",
|
||||||
|
"name": "smart_card_on_IC5_M3",
|
||||||
|
"font_class": "smart_card_on_IC5_M3",
|
||||||
|
"unicode": "e98c",
|
||||||
|
"unicode_decimal": 59788
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775870",
|
||||||
|
"name": "home_card_off_IC1_N6",
|
||||||
|
"font_class": "home_card_off_IC1_N6",
|
||||||
|
"unicode": "e992",
|
||||||
|
"unicode_decimal": 59794
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775871",
|
||||||
|
"name": "home_card_bluetooth_IC5_N6",
|
||||||
|
"font_class": "home_card_bluetooth_IC5_N6",
|
||||||
|
"unicode": "e9a0",
|
||||||
|
"unicode_decimal": 59808
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750453",
|
||||||
|
"name": "me_cell_online_customer_service",
|
||||||
|
"font_class": "me_cell_online_customer_service",
|
||||||
|
"unicode": "e9a4",
|
||||||
|
"unicode_decimal": 59812
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750105",
|
||||||
|
"name": "home_card_switch_on_IC5_M3",
|
||||||
|
"font_class": "home_card_switch_on_IC5_M3",
|
||||||
|
"unicode": "e99c",
|
||||||
|
"unicode_decimal": 59804
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750106",
|
||||||
|
"name": "smart_card_add_IC4_N1",
|
||||||
|
"font_class": "smart_card_add_IC4_N1",
|
||||||
|
"unicode": "e99d",
|
||||||
|
"unicode_decimal": 59805
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750107",
|
||||||
|
"name": "smart_card_arrow_right_IC3_N4",
|
||||||
|
"font_class": "smart_card_arrow_right_IC3_N4",
|
||||||
|
"unicode": "e99e",
|
||||||
|
"unicode_decimal": 59806
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750108",
|
||||||
|
"name": "popover_management_IC3_N1",
|
||||||
|
"font_class": "popover_management_IC3_N1",
|
||||||
|
"unicode": "e99f",
|
||||||
|
"unicode_decimal": 59807
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750110",
|
||||||
|
"name": "navbar_scanning_IC3_N1",
|
||||||
|
"font_class": "navbar_scanning_IC3_N1",
|
||||||
|
"unicode": "e9a1",
|
||||||
|
"unicode_decimal": 59809
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750111",
|
||||||
|
"name": "segement_more_IC3_N3",
|
||||||
|
"font_class": "segement_more_IC3_N3",
|
||||||
|
"unicode": "e9a2",
|
||||||
|
"unicode_decimal": 59810
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32742211",
|
||||||
|
"name": "switch_new",
|
||||||
|
"font_class": "switch_new",
|
||||||
|
"unicode": "e99b",
|
||||||
|
"unicode_decimal": 59803
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734381",
|
||||||
|
"name": "addDevice_switch_IC3_N6",
|
||||||
|
"font_class": "addDevice_switch_IC3_N6",
|
||||||
|
"unicode": "e957",
|
||||||
|
"unicode_decimal": 59735
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734382",
|
||||||
|
"name": "card_edit_IC3_N3",
|
||||||
|
"font_class": "card_edit_IC3_N3",
|
||||||
|
"unicode": "e958",
|
||||||
|
"unicode_decimal": 59736
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734383",
|
||||||
|
"name": "addDevice_bluetooth_IC3_N6",
|
||||||
|
"font_class": "addDevice_bluetooth_IC3_N6",
|
||||||
|
"unicode": "e959",
|
||||||
|
"unicode_decimal": 59737
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734384",
|
||||||
|
"name": "addDevice_wifi_IC3_N6",
|
||||||
|
"font_class": "addDevice_wifi_IC3_N6",
|
||||||
|
"unicode": "e95a",
|
||||||
|
"unicode_decimal": 59738
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734385",
|
||||||
|
"name": "addDevice_arrow_IC3_N6",
|
||||||
|
"font_class": "addDevice_arrow_IC3_N6",
|
||||||
|
"unicode": "e95b",
|
||||||
|
"unicode_decimal": 59739
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734386",
|
||||||
|
"name": "common_remove_IC3_M2",
|
||||||
|
"font_class": "common_remove_IC3_M2",
|
||||||
|
"unicode": "e95c",
|
||||||
|
"unicode_decimal": 59740
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734387",
|
||||||
|
"name": "card_notselected_IC3_N6",
|
||||||
|
"font_class": "card_notselected_IC3_N6",
|
||||||
|
"unicode": "e95d",
|
||||||
|
"unicode_decimal": 59741
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734388",
|
||||||
|
"name": "home_card_arrow_up_IC3_N4",
|
||||||
|
"font_class": "home_card_arrow_up_IC3_N4",
|
||||||
|
"unicode": "e95e",
|
||||||
|
"unicode_decimal": 59742
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734389",
|
||||||
|
"name": "home_card_line_IC1_N6",
|
||||||
|
"font_class": "home_card_line_IC1_N6",
|
||||||
|
"unicode": "e95f",
|
||||||
|
"unicode_decimal": 59743
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734390",
|
||||||
|
"name": "device_popup_note_s_IC1_M2",
|
||||||
|
"font_class": "device_popup_note_s_IC1_M2",
|
||||||
|
"unicode": "e960",
|
||||||
|
"unicode_decimal": 59744
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734391",
|
||||||
|
"name": "home_card_exclamation",
|
||||||
|
"font_class": "home_card_exclamation",
|
||||||
|
"unicode": "e961",
|
||||||
|
"unicode_decimal": 59745
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734392",
|
||||||
|
"name": "home_card_down_s_IC1_N6",
|
||||||
|
"font_class": "home_card_down_s_IC1_N6",
|
||||||
|
"unicode": "e962",
|
||||||
|
"unicode_decimal": 59746
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734393",
|
||||||
|
"name": "common_add_IC3_M3",
|
||||||
|
"font_class": "common_add_IC3_M3",
|
||||||
|
"unicode": "e963",
|
||||||
|
"unicode_decimal": 59747
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734394",
|
||||||
|
"name": "home_card_group_IC1_N6",
|
||||||
|
"font_class": "home_card_group_IC1_N6",
|
||||||
|
"unicode": "e964",
|
||||||
|
"unicode_decimal": 59748
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734395",
|
||||||
|
"name": "card_select_IC3_M1",
|
||||||
|
"font_class": "card_select_IC3_M1",
|
||||||
|
"unicode": "e965",
|
||||||
|
"unicode_decimal": 59749
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734397",
|
||||||
|
"name": "device_popup_bluetooth_IC4_N4",
|
||||||
|
"font_class": "device_popup_bluetooth_IC4_N4",
|
||||||
|
"unicode": "e967",
|
||||||
|
"unicode_decimal": 59751
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734398",
|
||||||
|
"name": "home_topbar_add_IC4_M1",
|
||||||
|
"font_class": "home_topbar_add_IC4_M1",
|
||||||
|
"unicode": "e968",
|
||||||
|
"unicode_decimal": 59752
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734399",
|
||||||
|
"name": "home_card_switch_off_IC5_N6",
|
||||||
|
"font_class": "home_card_switch_off_IC5_N6",
|
||||||
|
"unicode": "e969",
|
||||||
|
"unicode_decimal": 59753
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734400",
|
||||||
|
"name": "light_segment_manage",
|
||||||
|
"font_class": "light_segment_manage",
|
||||||
|
"unicode": "e96a",
|
||||||
|
"unicode_decimal": 59754
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734401",
|
||||||
|
"name": "home_weather_arrow_IC1_N6",
|
||||||
|
"font_class": "home_weather_arrow_IC1_N6",
|
||||||
|
"unicode": "e96b",
|
||||||
|
"unicode_decimal": 59755
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734402",
|
||||||
|
"name": "mine_cell_FAQ_IC3_S6",
|
||||||
|
"font_class": "mine_cell_FAQ_IC3_S6",
|
||||||
|
"unicode": "e96c",
|
||||||
|
"unicode_decimal": 59756
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734403",
|
||||||
|
"name": "input_arrow_down_IC3_N4",
|
||||||
|
"font_class": "input_arrow_down_IC3_N4",
|
||||||
|
"unicode": "e96d",
|
||||||
|
"unicode_decimal": 59757
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734404",
|
||||||
|
"name": "home_topbar_voice_IC3_N2",
|
||||||
|
"font_class": "home_topbar_voice_IC3_N2",
|
||||||
|
"unicode": "e96e",
|
||||||
|
"unicode_decimal": 59758
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734405",
|
||||||
|
"name": "home_topbar_gateway_IC3_N2",
|
||||||
|
"font_class": "home_topbar_gateway_IC3_N2",
|
||||||
|
"unicode": "e96f",
|
||||||
|
"unicode_decimal": 59759
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734406",
|
||||||
|
"name": "mine_cell_arrow_right_IC3_N4",
|
||||||
|
"font_class": "mine_cell_arrow_right_IC3_N4",
|
||||||
|
"unicode": "e970",
|
||||||
|
"unicode_decimal": 59760
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734407",
|
||||||
|
"name": "home_btn_delete_IC2_N6",
|
||||||
|
"font_class": "home_btn_delete_IC2_N6",
|
||||||
|
"unicode": "e971",
|
||||||
|
"unicode_decimal": 59761
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734408",
|
||||||
|
"name": "home_topbar_energy_IC3_N2",
|
||||||
|
"font_class": "home_topbar_energy_IC3_N2",
|
||||||
|
"unicode": "e972",
|
||||||
|
"unicode_decimal": 59762
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734409",
|
||||||
|
"name": "mine_cell_home_IC3_S11",
|
||||||
|
"font_class": "mine_cell_home_IC3_S11",
|
||||||
|
"unicode": "e973",
|
||||||
|
"unicode_decimal": 59763
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734410",
|
||||||
|
"name": "home_topbar_ipc_IC3_N2",
|
||||||
|
"font_class": "home_topbar_ipc_IC3_N2",
|
||||||
|
"unicode": "e974",
|
||||||
|
"unicode_decimal": 59764
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734411",
|
||||||
|
"name": "mine_cell_features_IC3_S5",
|
||||||
|
"font_class": "mine_cell_features_IC3_S5",
|
||||||
|
"unicode": "e975",
|
||||||
|
"unicode_decimal": 59765
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734412",
|
||||||
|
"name": "home_card_arrow_down_IC3_N4",
|
||||||
|
"font_class": "home_card_arrow_down_IC3_N4",
|
||||||
|
"unicode": "e976",
|
||||||
|
"unicode_decimal": 59766
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734413",
|
||||||
|
"name": "me_cell_device",
|
||||||
|
"font_class": "me_cell_device",
|
||||||
|
"unicode": "e977",
|
||||||
|
"unicode_decimal": 59767
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734414",
|
||||||
|
"name": "mine_cell_iwatch_IC3_S12",
|
||||||
|
"font_class": "mine_cell_iwatch_IC3_S12",
|
||||||
|
"unicode": "e978",
|
||||||
|
"unicode_decimal": 59768
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734415",
|
||||||
|
"name": "smart_card_attention_IC4_N3",
|
||||||
|
"font_class": "smart_card_attention_IC4_N3",
|
||||||
|
"unicode": "e979",
|
||||||
|
"unicode_decimal": 59769
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734417",
|
||||||
|
"name": "smart_card_loading1_IC3_N4",
|
||||||
|
"font_class": "smart_card_loading1_IC3_N4",
|
||||||
|
"unicode": "e97b",
|
||||||
|
"unicode_decimal": 59771
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734419",
|
||||||
|
"name": "home_topbar_security_IC3_N2",
|
||||||
|
"font_class": "home_topbar_security_IC3_N2",
|
||||||
|
"unicode": "e97c",
|
||||||
|
"unicode_decimal": 59772
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734421",
|
||||||
|
"name": "smart_topbar_add_IC4_N1",
|
||||||
|
"font_class": "smart_topbar_add_IC4_N1",
|
||||||
|
"unicode": "e97e",
|
||||||
|
"unicode_decimal": 59774
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734422",
|
||||||
|
"name": "smart_card_arrow_IC5_N6",
|
||||||
|
"font_class": "smart_card_arrow_IC5_N6",
|
||||||
|
"unicode": "e97f",
|
||||||
|
"unicode_decimal": 59775
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734423",
|
||||||
|
"name": "popover_list_IC3_N1",
|
||||||
|
"font_class": "popover_list_IC3_N1",
|
||||||
|
"unicode": "e980",
|
||||||
|
"unicode_decimal": 59776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734424",
|
||||||
|
"name": "popover_grid_IC3_N1",
|
||||||
|
"font_class": "popover_grid_IC3_N1",
|
||||||
|
"unicode": "e981",
|
||||||
|
"unicode_decimal": 59777
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734425",
|
||||||
|
"name": "popover_add_device_IC3_N1",
|
||||||
|
"font_class": "popover_add_device_IC3_N1",
|
||||||
|
"unicode": "e982",
|
||||||
|
"unicode_decimal": 59778
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734426",
|
||||||
|
"name": "popover_log_IC3_N1",
|
||||||
|
"font_class": "popover_log_IC3_N1",
|
||||||
|
"unicode": "e983",
|
||||||
|
"unicode_decimal": 59779
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734427",
|
||||||
|
"name": "mine_cell_message_IC3_S1",
|
||||||
|
"font_class": "mine_cell_message_IC3_S1",
|
||||||
|
"unicode": "e984",
|
||||||
|
"unicode_decimal": 59780
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734428",
|
||||||
|
"name": "navbar_arrow_left_IC3_N1",
|
||||||
|
"font_class": "navbar_arrow_left_IC3_N1",
|
||||||
|
"unicode": "e985",
|
||||||
|
"unicode_decimal": 59781
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734429",
|
||||||
|
"name": "topbar_arrow_down_IC2_N4-1",
|
||||||
|
"font_class": "topbar_arrow_down_IC2_N4-1",
|
||||||
|
"unicode": "e986",
|
||||||
|
"unicode_decimal": 59782
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734430",
|
||||||
|
"name": "navbar_management_IC3_N1",
|
||||||
|
"font_class": "navbar_management_IC3_N1",
|
||||||
|
"unicode": "e987",
|
||||||
|
"unicode_decimal": 59783
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734431",
|
||||||
|
"name": "popup_arrow_up_IC3_N4",
|
||||||
|
"font_class": "popup_arrow_up_IC3_N4",
|
||||||
|
"unicode": "e988",
|
||||||
|
"unicode_decimal": 59784
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734432",
|
||||||
|
"name": "navbar_scanning_IC3_N1-1",
|
||||||
|
"font_class": "navbar_scanning_IC3_N1-1",
|
||||||
|
"unicode": "e989",
|
||||||
|
"unicode_decimal": 59785
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734433",
|
||||||
|
"name": "popover_room_IC3_N1",
|
||||||
|
"font_class": "popover_room_IC3_N1",
|
||||||
|
"unicode": "e98a",
|
||||||
|
"unicode_decimal": 59786
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734434",
|
||||||
|
"name": "popup_arrow_down_IC3_N4",
|
||||||
|
"font_class": "popup_arrow_down_IC3_N4",
|
||||||
|
"unicode": "e98b",
|
||||||
|
"unicode_decimal": 59787
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734436",
|
||||||
|
"name": "toolbar_top_IC3_M4",
|
||||||
|
"font_class": "toolbar_top_IC3_M4",
|
||||||
|
"unicode": "e98d",
|
||||||
|
"unicode_decimal": 59789
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734437",
|
||||||
|
"name": "topbar_arrow_down_IC2_N4",
|
||||||
|
"font_class": "topbar_arrow_down_IC2_N4",
|
||||||
|
"unicode": "e98e",
|
||||||
|
"unicode_decimal": 59790
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734438",
|
||||||
|
"name": "smart_card_fire_IC1_N3",
|
||||||
|
"font_class": "smart_card_fire_IC1_N3",
|
||||||
|
"unicode": "e98f",
|
||||||
|
"unicode_decimal": 59791
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734439",
|
||||||
|
"name": "popover_add_scenario_IC3_N1",
|
||||||
|
"font_class": "popover_add_scenario_IC3_N1",
|
||||||
|
"unicode": "e990",
|
||||||
|
"unicode_decimal": 59792
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734442",
|
||||||
|
"name": "mine_cell_homekit_IC3_S10",
|
||||||
|
"font_class": "mine_cell_homekit_IC3_S10",
|
||||||
|
"unicode": "e993",
|
||||||
|
"unicode_decimal": 59795
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734443",
|
||||||
|
"name": "mine_cell_mall_IC3_S9",
|
||||||
|
"font_class": "mine_cell_mall_IC3_S9",
|
||||||
|
"unicode": "e994",
|
||||||
|
"unicode_decimal": 59796
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734444",
|
||||||
|
"name": "segement_more_IC3_N3-1",
|
||||||
|
"font_class": "segement_more_IC3_N3-1",
|
||||||
|
"unicode": "e995",
|
||||||
|
"unicode_decimal": 59797
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734445",
|
||||||
|
"name": "popover_device_IC3_N1",
|
||||||
|
"font_class": "popover_device_IC3_N1",
|
||||||
|
"unicode": "e996",
|
||||||
|
"unicode_decimal": 59798
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734446",
|
||||||
|
"name": "input_arrow_up_IC3_N4",
|
||||||
|
"font_class": "input_arrow_up_IC3_N4",
|
||||||
|
"unicode": "e997",
|
||||||
|
"unicode_decimal": 59799
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734447",
|
||||||
|
"name": "mine_cell_health_IC3_S9",
|
||||||
|
"font_class": "mine_cell_health_IC3_S9",
|
||||||
|
"unicode": "e998",
|
||||||
|
"unicode_decimal": 59800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734448",
|
||||||
|
"name": "toolbar_delete_IC3_M2",
|
||||||
|
"font_class": "toolbar_delete_IC3_M2",
|
||||||
|
"unicode": "e999",
|
||||||
|
"unicode_decimal": 59801
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734495",
|
||||||
|
"name": "me_cell_store",
|
||||||
|
"font_class": "me_cell_store",
|
||||||
|
"unicode": "e99a",
|
||||||
|
"unicode_decimal": 59802
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
Android/app/src/main/assets/default_iconfont.ttf
Normal file
BIN
Android/app/src/main/assets/default_iconfont.ttf
Normal file
Binary file not shown.
541
Android/app/src/main/assets/iconfont/default_iconfont.json
Normal file
541
Android/app/src/main/assets/iconfont/default_iconfont.json
Normal file
@ -0,0 +1,541 @@
|
|||||||
|
{
|
||||||
|
"id": "3757359",
|
||||||
|
"name": "default_iconfont",
|
||||||
|
"font_family": "default_iconfont",
|
||||||
|
"css_prefix_text": "icon-",
|
||||||
|
"description": "",
|
||||||
|
"glyphs": [
|
||||||
|
{
|
||||||
|
"icon_id": "35597351",
|
||||||
|
"name": "navbar_security_IC3_N1",
|
||||||
|
"font_class": "navbar_security_IC3_N1",
|
||||||
|
"unicode": "e9b6",
|
||||||
|
"unicode_decimal": 59830
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775867",
|
||||||
|
"name": "home_card_on_IC1_N6",
|
||||||
|
"font_class": "home_card_on_IC1_N6",
|
||||||
|
"unicode": "e97a",
|
||||||
|
"unicode_decimal": 59770
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775868",
|
||||||
|
"name": "smart_card_off_IC5_N6",
|
||||||
|
"font_class": "smart_card_off_IC5_N6",
|
||||||
|
"unicode": "e97d",
|
||||||
|
"unicode_decimal": 59773
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775869",
|
||||||
|
"name": "smart_card_on_IC5_M3",
|
||||||
|
"font_class": "smart_card_on_IC5_M3",
|
||||||
|
"unicode": "e98c",
|
||||||
|
"unicode_decimal": 59788
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775870",
|
||||||
|
"name": "home_card_off_IC1_N6",
|
||||||
|
"font_class": "home_card_off_IC1_N6",
|
||||||
|
"unicode": "e992",
|
||||||
|
"unicode_decimal": 59794
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32775871",
|
||||||
|
"name": "home_card_bluetooth_IC5_N6",
|
||||||
|
"font_class": "home_card_bluetooth_IC5_N6",
|
||||||
|
"unicode": "e9a0",
|
||||||
|
"unicode_decimal": 59808
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750453",
|
||||||
|
"name": "me_cell_online_customer_service",
|
||||||
|
"font_class": "me_cell_online_customer_service",
|
||||||
|
"unicode": "e9a4",
|
||||||
|
"unicode_decimal": 59812
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750105",
|
||||||
|
"name": "home_card_switch_on_IC5_M3",
|
||||||
|
"font_class": "home_card_switch_on_IC5_M3",
|
||||||
|
"unicode": "e99c",
|
||||||
|
"unicode_decimal": 59804
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750106",
|
||||||
|
"name": "smart_card_add_IC4_N1",
|
||||||
|
"font_class": "smart_card_add_IC4_N1",
|
||||||
|
"unicode": "e99d",
|
||||||
|
"unicode_decimal": 59805
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750107",
|
||||||
|
"name": "smart_card_arrow_right_IC3_N4",
|
||||||
|
"font_class": "smart_card_arrow_right_IC3_N4",
|
||||||
|
"unicode": "e99e",
|
||||||
|
"unicode_decimal": 59806
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750108",
|
||||||
|
"name": "popover_management_IC3_N1",
|
||||||
|
"font_class": "popover_management_IC3_N1",
|
||||||
|
"unicode": "e99f",
|
||||||
|
"unicode_decimal": 59807
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750110",
|
||||||
|
"name": "navbar_scanning_IC3_N1",
|
||||||
|
"font_class": "navbar_scanning_IC3_N1",
|
||||||
|
"unicode": "e9a1",
|
||||||
|
"unicode_decimal": 59809
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32750111",
|
||||||
|
"name": "segement_more_IC3_N3",
|
||||||
|
"font_class": "segement_more_IC3_N3",
|
||||||
|
"unicode": "e9a2",
|
||||||
|
"unicode_decimal": 59810
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32742211",
|
||||||
|
"name": "switch_new",
|
||||||
|
"font_class": "switch_new",
|
||||||
|
"unicode": "e99b",
|
||||||
|
"unicode_decimal": 59803
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734381",
|
||||||
|
"name": "addDevice_switch_IC3_N6",
|
||||||
|
"font_class": "addDevice_switch_IC3_N6",
|
||||||
|
"unicode": "e957",
|
||||||
|
"unicode_decimal": 59735
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734382",
|
||||||
|
"name": "card_edit_IC3_N3",
|
||||||
|
"font_class": "card_edit_IC3_N3",
|
||||||
|
"unicode": "e958",
|
||||||
|
"unicode_decimal": 59736
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734383",
|
||||||
|
"name": "addDevice_bluetooth_IC3_N6",
|
||||||
|
"font_class": "addDevice_bluetooth_IC3_N6",
|
||||||
|
"unicode": "e959",
|
||||||
|
"unicode_decimal": 59737
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734384",
|
||||||
|
"name": "addDevice_wifi_IC3_N6",
|
||||||
|
"font_class": "addDevice_wifi_IC3_N6",
|
||||||
|
"unicode": "e95a",
|
||||||
|
"unicode_decimal": 59738
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734385",
|
||||||
|
"name": "addDevice_arrow_IC3_N6",
|
||||||
|
"font_class": "addDevice_arrow_IC3_N6",
|
||||||
|
"unicode": "e95b",
|
||||||
|
"unicode_decimal": 59739
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734386",
|
||||||
|
"name": "common_remove_IC3_M2",
|
||||||
|
"font_class": "common_remove_IC3_M2",
|
||||||
|
"unicode": "e95c",
|
||||||
|
"unicode_decimal": 59740
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734387",
|
||||||
|
"name": "card_notselected_IC3_N6",
|
||||||
|
"font_class": "card_notselected_IC3_N6",
|
||||||
|
"unicode": "e95d",
|
||||||
|
"unicode_decimal": 59741
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734388",
|
||||||
|
"name": "home_card_arrow_up_IC3_N4",
|
||||||
|
"font_class": "home_card_arrow_up_IC3_N4",
|
||||||
|
"unicode": "e95e",
|
||||||
|
"unicode_decimal": 59742
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734389",
|
||||||
|
"name": "home_card_line_IC1_N6",
|
||||||
|
"font_class": "home_card_line_IC1_N6",
|
||||||
|
"unicode": "e95f",
|
||||||
|
"unicode_decimal": 59743
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734390",
|
||||||
|
"name": "device_popup_note_s_IC1_M2",
|
||||||
|
"font_class": "device_popup_note_s_IC1_M2",
|
||||||
|
"unicode": "e960",
|
||||||
|
"unicode_decimal": 59744
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734391",
|
||||||
|
"name": "home_card_exclamation",
|
||||||
|
"font_class": "home_card_exclamation",
|
||||||
|
"unicode": "e961",
|
||||||
|
"unicode_decimal": 59745
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734392",
|
||||||
|
"name": "home_card_down_s_IC1_N6",
|
||||||
|
"font_class": "home_card_down_s_IC1_N6",
|
||||||
|
"unicode": "e962",
|
||||||
|
"unicode_decimal": 59746
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734393",
|
||||||
|
"name": "common_add_IC3_M3",
|
||||||
|
"font_class": "common_add_IC3_M3",
|
||||||
|
"unicode": "e963",
|
||||||
|
"unicode_decimal": 59747
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734394",
|
||||||
|
"name": "home_card_group_IC1_N6",
|
||||||
|
"font_class": "home_card_group_IC1_N6",
|
||||||
|
"unicode": "e964",
|
||||||
|
"unicode_decimal": 59748
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734395",
|
||||||
|
"name": "card_select_IC3_M1",
|
||||||
|
"font_class": "card_select_IC3_M1",
|
||||||
|
"unicode": "e965",
|
||||||
|
"unicode_decimal": 59749
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734397",
|
||||||
|
"name": "device_popup_bluetooth_IC4_N4",
|
||||||
|
"font_class": "device_popup_bluetooth_IC4_N4",
|
||||||
|
"unicode": "e967",
|
||||||
|
"unicode_decimal": 59751
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734398",
|
||||||
|
"name": "home_topbar_add_IC4_M1",
|
||||||
|
"font_class": "home_topbar_add_IC4_M1",
|
||||||
|
"unicode": "e968",
|
||||||
|
"unicode_decimal": 59752
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734399",
|
||||||
|
"name": "home_card_switch_off_IC5_N6",
|
||||||
|
"font_class": "home_card_switch_off_IC5_N6",
|
||||||
|
"unicode": "e969",
|
||||||
|
"unicode_decimal": 59753
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734400",
|
||||||
|
"name": "light_segment_manage",
|
||||||
|
"font_class": "light_segment_manage",
|
||||||
|
"unicode": "e96a",
|
||||||
|
"unicode_decimal": 59754
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734401",
|
||||||
|
"name": "home_weather_arrow_IC1_N6",
|
||||||
|
"font_class": "home_weather_arrow_IC1_N6",
|
||||||
|
"unicode": "e96b",
|
||||||
|
"unicode_decimal": 59755
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734402",
|
||||||
|
"name": "mine_cell_FAQ_IC3_S6",
|
||||||
|
"font_class": "mine_cell_FAQ_IC3_S6",
|
||||||
|
"unicode": "e96c",
|
||||||
|
"unicode_decimal": 59756
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734403",
|
||||||
|
"name": "input_arrow_down_IC3_N4",
|
||||||
|
"font_class": "input_arrow_down_IC3_N4",
|
||||||
|
"unicode": "e96d",
|
||||||
|
"unicode_decimal": 59757
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734404",
|
||||||
|
"name": "home_topbar_voice_IC3_N2",
|
||||||
|
"font_class": "home_topbar_voice_IC3_N2",
|
||||||
|
"unicode": "e96e",
|
||||||
|
"unicode_decimal": 59758
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734405",
|
||||||
|
"name": "home_topbar_gateway_IC3_N2",
|
||||||
|
"font_class": "home_topbar_gateway_IC3_N2",
|
||||||
|
"unicode": "e96f",
|
||||||
|
"unicode_decimal": 59759
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734406",
|
||||||
|
"name": "mine_cell_arrow_right_IC3_N4",
|
||||||
|
"font_class": "mine_cell_arrow_right_IC3_N4",
|
||||||
|
"unicode": "e970",
|
||||||
|
"unicode_decimal": 59760
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734407",
|
||||||
|
"name": "home_btn_delete_IC2_N6",
|
||||||
|
"font_class": "home_btn_delete_IC2_N6",
|
||||||
|
"unicode": "e971",
|
||||||
|
"unicode_decimal": 59761
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734408",
|
||||||
|
"name": "home_topbar_energy_IC3_N2",
|
||||||
|
"font_class": "home_topbar_energy_IC3_N2",
|
||||||
|
"unicode": "e972",
|
||||||
|
"unicode_decimal": 59762
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734409",
|
||||||
|
"name": "mine_cell_home_IC3_S11",
|
||||||
|
"font_class": "mine_cell_home_IC3_S11",
|
||||||
|
"unicode": "e973",
|
||||||
|
"unicode_decimal": 59763
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734410",
|
||||||
|
"name": "home_topbar_ipc_IC3_N2",
|
||||||
|
"font_class": "home_topbar_ipc_IC3_N2",
|
||||||
|
"unicode": "e974",
|
||||||
|
"unicode_decimal": 59764
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734411",
|
||||||
|
"name": "mine_cell_features_IC3_S5",
|
||||||
|
"font_class": "mine_cell_features_IC3_S5",
|
||||||
|
"unicode": "e975",
|
||||||
|
"unicode_decimal": 59765
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734412",
|
||||||
|
"name": "home_card_arrow_down_IC3_N4",
|
||||||
|
"font_class": "home_card_arrow_down_IC3_N4",
|
||||||
|
"unicode": "e976",
|
||||||
|
"unicode_decimal": 59766
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734413",
|
||||||
|
"name": "me_cell_device",
|
||||||
|
"font_class": "me_cell_device",
|
||||||
|
"unicode": "e977",
|
||||||
|
"unicode_decimal": 59767
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734414",
|
||||||
|
"name": "mine_cell_iwatch_IC3_S12",
|
||||||
|
"font_class": "mine_cell_iwatch_IC3_S12",
|
||||||
|
"unicode": "e978",
|
||||||
|
"unicode_decimal": 59768
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734415",
|
||||||
|
"name": "smart_card_attention_IC4_N3",
|
||||||
|
"font_class": "smart_card_attention_IC4_N3",
|
||||||
|
"unicode": "e979",
|
||||||
|
"unicode_decimal": 59769
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734417",
|
||||||
|
"name": "smart_card_loading1_IC3_N4",
|
||||||
|
"font_class": "smart_card_loading1_IC3_N4",
|
||||||
|
"unicode": "e97b",
|
||||||
|
"unicode_decimal": 59771
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734419",
|
||||||
|
"name": "home_topbar_security_IC3_N2",
|
||||||
|
"font_class": "home_topbar_security_IC3_N2",
|
||||||
|
"unicode": "e97c",
|
||||||
|
"unicode_decimal": 59772
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734421",
|
||||||
|
"name": "smart_topbar_add_IC4_N1",
|
||||||
|
"font_class": "smart_topbar_add_IC4_N1",
|
||||||
|
"unicode": "e97e",
|
||||||
|
"unicode_decimal": 59774
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734422",
|
||||||
|
"name": "smart_card_arrow_IC5_N6",
|
||||||
|
"font_class": "smart_card_arrow_IC5_N6",
|
||||||
|
"unicode": "e97f",
|
||||||
|
"unicode_decimal": 59775
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734423",
|
||||||
|
"name": "popover_list_IC3_N1",
|
||||||
|
"font_class": "popover_list_IC3_N1",
|
||||||
|
"unicode": "e980",
|
||||||
|
"unicode_decimal": 59776
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734424",
|
||||||
|
"name": "popover_grid_IC3_N1",
|
||||||
|
"font_class": "popover_grid_IC3_N1",
|
||||||
|
"unicode": "e981",
|
||||||
|
"unicode_decimal": 59777
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734425",
|
||||||
|
"name": "popover_add_device_IC3_N1",
|
||||||
|
"font_class": "popover_add_device_IC3_N1",
|
||||||
|
"unicode": "e982",
|
||||||
|
"unicode_decimal": 59778
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734426",
|
||||||
|
"name": "popover_log_IC3_N1",
|
||||||
|
"font_class": "popover_log_IC3_N1",
|
||||||
|
"unicode": "e983",
|
||||||
|
"unicode_decimal": 59779
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734427",
|
||||||
|
"name": "mine_cell_message_IC3_S1",
|
||||||
|
"font_class": "mine_cell_message_IC3_S1",
|
||||||
|
"unicode": "e984",
|
||||||
|
"unicode_decimal": 59780
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734428",
|
||||||
|
"name": "navbar_arrow_left_IC3_N1",
|
||||||
|
"font_class": "navbar_arrow_left_IC3_N1",
|
||||||
|
"unicode": "e985",
|
||||||
|
"unicode_decimal": 59781
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734429",
|
||||||
|
"name": "topbar_arrow_down_IC2_N4-1",
|
||||||
|
"font_class": "topbar_arrow_down_IC2_N4-1",
|
||||||
|
"unicode": "e986",
|
||||||
|
"unicode_decimal": 59782
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734430",
|
||||||
|
"name": "navbar_management_IC3_N1",
|
||||||
|
"font_class": "navbar_management_IC3_N1",
|
||||||
|
"unicode": "e987",
|
||||||
|
"unicode_decimal": 59783
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734431",
|
||||||
|
"name": "popup_arrow_up_IC3_N4",
|
||||||
|
"font_class": "popup_arrow_up_IC3_N4",
|
||||||
|
"unicode": "e988",
|
||||||
|
"unicode_decimal": 59784
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734432",
|
||||||
|
"name": "navbar_scanning_IC3_N1-1",
|
||||||
|
"font_class": "navbar_scanning_IC3_N1-1",
|
||||||
|
"unicode": "e989",
|
||||||
|
"unicode_decimal": 59785
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734433",
|
||||||
|
"name": "popover_room_IC3_N1",
|
||||||
|
"font_class": "popover_room_IC3_N1",
|
||||||
|
"unicode": "e98a",
|
||||||
|
"unicode_decimal": 59786
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734434",
|
||||||
|
"name": "popup_arrow_down_IC3_N4",
|
||||||
|
"font_class": "popup_arrow_down_IC3_N4",
|
||||||
|
"unicode": "e98b",
|
||||||
|
"unicode_decimal": 59787
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734436",
|
||||||
|
"name": "toolbar_top_IC3_M4",
|
||||||
|
"font_class": "toolbar_top_IC3_M4",
|
||||||
|
"unicode": "e98d",
|
||||||
|
"unicode_decimal": 59789
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734437",
|
||||||
|
"name": "topbar_arrow_down_IC2_N4",
|
||||||
|
"font_class": "topbar_arrow_down_IC2_N4",
|
||||||
|
"unicode": "e98e",
|
||||||
|
"unicode_decimal": 59790
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734438",
|
||||||
|
"name": "smart_card_fire_IC1_N3",
|
||||||
|
"font_class": "smart_card_fire_IC1_N3",
|
||||||
|
"unicode": "e98f",
|
||||||
|
"unicode_decimal": 59791
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734439",
|
||||||
|
"name": "popover_add_scenario_IC3_N1",
|
||||||
|
"font_class": "popover_add_scenario_IC3_N1",
|
||||||
|
"unicode": "e990",
|
||||||
|
"unicode_decimal": 59792
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734442",
|
||||||
|
"name": "mine_cell_homekit_IC3_S10",
|
||||||
|
"font_class": "mine_cell_homekit_IC3_S10",
|
||||||
|
"unicode": "e993",
|
||||||
|
"unicode_decimal": 59795
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734443",
|
||||||
|
"name": "mine_cell_mall_IC3_S9",
|
||||||
|
"font_class": "mine_cell_mall_IC3_S9",
|
||||||
|
"unicode": "e994",
|
||||||
|
"unicode_decimal": 59796
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734444",
|
||||||
|
"name": "segement_more_IC3_N3-1",
|
||||||
|
"font_class": "segement_more_IC3_N3-1",
|
||||||
|
"unicode": "e995",
|
||||||
|
"unicode_decimal": 59797
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734445",
|
||||||
|
"name": "popover_device_IC3_N1",
|
||||||
|
"font_class": "popover_device_IC3_N1",
|
||||||
|
"unicode": "e996",
|
||||||
|
"unicode_decimal": 59798
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734446",
|
||||||
|
"name": "input_arrow_up_IC3_N4",
|
||||||
|
"font_class": "input_arrow_up_IC3_N4",
|
||||||
|
"unicode": "e997",
|
||||||
|
"unicode_decimal": 59799
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734447",
|
||||||
|
"name": "mine_cell_health_IC3_S9",
|
||||||
|
"font_class": "mine_cell_health_IC3_S9",
|
||||||
|
"unicode": "e998",
|
||||||
|
"unicode_decimal": 59800
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734448",
|
||||||
|
"name": "toolbar_delete_IC3_M2",
|
||||||
|
"font_class": "toolbar_delete_IC3_M2",
|
||||||
|
"unicode": "e999",
|
||||||
|
"unicode_decimal": 59801
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"icon_id": "32734495",
|
||||||
|
"name": "me_cell_store",
|
||||||
|
"font_class": "me_cell_store",
|
||||||
|
"unicode": "e99a",
|
||||||
|
"unicode_decimal": 59802
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
Android/app/src/main/assets/iconfont/default_iconfont.ttf
Normal file
BIN
Android/app/src/main/assets/iconfont/default_iconfont.ttf
Normal file
Binary file not shown.
1086
Android/app/src/main/assets/module_app.json
Normal file
1086
Android/app/src/main/assets/module_app.json
Normal file
File diff suppressed because it is too large
Load Diff
80
Android/app/src/main/assets/thingTheme/ui_theme_config.json
Normal file
80
Android/app/src/main/assets/thingTheme/ui_theme_config.json
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"black_theme":{
|
||||||
|
"color_alpha":{
|
||||||
|
"N1":1,
|
||||||
|
"N2":0.7,
|
||||||
|
"N3":0.5,
|
||||||
|
"N4":0.3,
|
||||||
|
"N5":0.7,
|
||||||
|
"N6":0.2,
|
||||||
|
"N7":0.1,
|
||||||
|
"N8":0.7,
|
||||||
|
"N9":0.1
|
||||||
|
},
|
||||||
|
"colors":{
|
||||||
|
"B1":"#000000",
|
||||||
|
"B2":"#1A1A1A",
|
||||||
|
"B3":"#1A1A1A",
|
||||||
|
"B4":"#1A1A1A",
|
||||||
|
"B5":"#1A1A1A",
|
||||||
|
"B6":"",
|
||||||
|
"M1":"#FF5A28",
|
||||||
|
"M2":"#FF4444",
|
||||||
|
"M3":"#23D1A5",
|
||||||
|
"M4":"#1989FA",
|
||||||
|
"M5":"#FF5A28"
|
||||||
|
},
|
||||||
|
"corner":{
|
||||||
|
"alert_corner_type":"angle",
|
||||||
|
"button_corner_type":"angle",
|
||||||
|
"card_corner_type":"angle"
|
||||||
|
},
|
||||||
|
"custom_colors":{
|
||||||
|
"B_Activator":"#000000",
|
||||||
|
"B_Login":"#000000"
|
||||||
|
},
|
||||||
|
"font":{
|
||||||
|
"app_bold_font":"",
|
||||||
|
"app_font":""
|
||||||
|
},
|
||||||
|
"maskAlpha":0.7
|
||||||
|
},
|
||||||
|
"color_alpha":{
|
||||||
|
"N1":0.9,
|
||||||
|
"N2":0.7,
|
||||||
|
"N3":0.5,
|
||||||
|
"N4":0.3,
|
||||||
|
"N5":0.7,
|
||||||
|
"N6":0.2,
|
||||||
|
"N7":0.1,
|
||||||
|
"N8":0.4,
|
||||||
|
"N9":0.03
|
||||||
|
},
|
||||||
|
"colors":{
|
||||||
|
"B1":"#F6F7FB",
|
||||||
|
"B2":"#FFFFFF",
|
||||||
|
"B3":"#FFFFFF",
|
||||||
|
"B4":"#FFFFFF",
|
||||||
|
"B5":"#FFFFFF",
|
||||||
|
"B6":"",
|
||||||
|
"M1":"#FF5A28",
|
||||||
|
"M2":"#FF4444",
|
||||||
|
"M3":"#23D1A5",
|
||||||
|
"M4":"#1989FA",
|
||||||
|
"M5":"#FF5A28"
|
||||||
|
},
|
||||||
|
"corner":{
|
||||||
|
"alert_corner_type":"angle",
|
||||||
|
"button_corner_type":"angle",
|
||||||
|
"card_corner_type":"angle"
|
||||||
|
},
|
||||||
|
"custom_colors":{
|
||||||
|
"B_Activator":"#FFFFFF",
|
||||||
|
"B_Login":"#FFFFFF"
|
||||||
|
},
|
||||||
|
"font":{
|
||||||
|
"app_bold_font":"",
|
||||||
|
"app_font":""
|
||||||
|
},
|
||||||
|
"maskAlpha":0
|
||||||
|
}
|
||||||
1
Android/app/src/main/assets/thing_pbt_group_config.json
Normal file
1
Android/app/src/main/assets/thing_pbt_group_config.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
[{"npmName":"WearKit","version":"3.0.4","PBTGroupName":"TUNIWearKit"},{"npmName":"HomeKit","version":"3.17.3","PBTGroupName":"TUNIHomeKit"},{"npmName":"SweeperKit","version":"2.0.0","PBTGroupName":"TUNISweeperKit"},{"npmName":"BizKit","version":"4.29.12","PBTGroupName":"TUNIBizKit"},{"npmName":"IPCKit","version":"7.7.12","PBTGroupName":"TUNIIPCKit"},{"npmName":"P2PKit","version":"7.7.6","PBTGroupName":"TUNIP2PKit"},{"npmName":"MapKit","version":"7.8.2","PBTGroupName":"TUNIMapKit"},{"npmName":"AIKit","version":"2.1.2","PBTGroupName":"TUNIAIKit"},{"npmName":"PlayNetKit","version":"7.3.0","PBTGroupName":"TUNIPlayNetKit"},{"npmName":"DeviceKit","version":"7.8.1","PBTGroupName":"TUNIDeviceKit"},{"npmName":"BaseKit","version":"3.36.3","PBTGroupName":"TUNIBaseKit"},{"npmName":"MediaKit","version":"3.6.3","PBTGroupName":"TUNIMediaKit"},{"npmName":"HealthKit","version":"7.2.1","PBTGroupName":"TUNIHealthKit"},{"npmName":"LightKit","version":"1.0.18","PBTGroupName":"TUNILightKit"},{"npmName":"CategoryCommonBizKit","version":"6.4.1","PBTGroupName":"TUNICategoryCommonBizKit"},{"npmName":"AIStreamKit","version":"2.2.2","PBTGroupName":"TUNIAIStreamKit"},{"npmName":"ThirdPartyDeviceKit","version":"1.0.0-rc.4","PBTGroupName":"TUNIThirdPartyDeviceKit"},{"npmName":"ThirdAuthKit","version":"1.0.14","PBTGroupName":"TUNIThirdAuthKit"},{"npmName":"AVideoKit","version":"1.0.9","PBTGroupName":"TUNIAVideoKit"},{"npmName":"MediaPlayerKit","version":"1.0.34","PBTGroupName":"TUNIMediaPlayerKit"},{"npmName":"MiniKit","version":"3.33.1","PBTGroupName":"TUNIMiniKit"}]
|
||||||
1
Android/app/src/main/assets/thing_plugin_config.json
Normal file
1
Android/app/src/main/assets/thing_plugin_config.json
Normal file
File diff suppressed because one or more lines are too long
1
Android/app/src/main/assets/x_platform_config.json
Normal file
1
Android/app/src/main/assets/x_platform_config.json
Normal file
File diff suppressed because one or more lines are too long
23
Android/app/src/main/java/com/xhai/iot/AppConfig.java
Normal file
23
Android/app/src/main/java/com/xhai/iot/AppConfig.java
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
package com.xhai.iot;
|
||||||
|
|
||||||
|
import androidx.annotation.Keep;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦 BizBundle 应用层代理配置
|
||||||
|
* 控制配网能力开关(热点/蓝牙/BLE Mesh)
|
||||||
|
*/
|
||||||
|
@Keep
|
||||||
|
public class AppConfig {
|
||||||
|
/** 主题色支持深色模式的开关 */
|
||||||
|
public static final boolean is_darkMode_support = true;
|
||||||
|
/** 华为包支持开关 */
|
||||||
|
public static final boolean is_huawei_pkg = false;
|
||||||
|
/** 热点配网支持的热点前缀列表 */
|
||||||
|
public static final String ap_mode_ssid = "SmartLife";
|
||||||
|
/** 是否支持蓝牙设备配网功能 */
|
||||||
|
public static final boolean is_need_ble_support = true;
|
||||||
|
/** 是否支持蓝牙 Mesh 设备配网功能 */
|
||||||
|
public static final boolean is_need_blemesh_support = true;
|
||||||
|
/** 是否支持列表页面右上角扫描配网功能 */
|
||||||
|
public static final boolean is_scan_support = true;
|
||||||
|
}
|
||||||
123
Android/app/src/main/java/com/xhai/iot/TuyaApplication.java
Normal file
123
Android/app/src/main/java/com/xhai/iot/TuyaApplication.java
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
package com.xhai.iot;
|
||||||
|
|
||||||
|
import android.app.Application;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.multidex.MultiDex;
|
||||||
|
|
||||||
|
import com.thingclips.smart.api.MicroContext;
|
||||||
|
import com.thingclips.smart.api.router.UrlBuilder;
|
||||||
|
import com.thingclips.smart.api.service.RedirectService;
|
||||||
|
import com.thingclips.smart.api.service.RouteEventListener;
|
||||||
|
import com.thingclips.smart.api.service.ServiceEventListener;
|
||||||
|
import com.thingclips.smart.bizbundle.initializer.BizBundleInitializer;
|
||||||
|
import com.thingclips.smart.commonbiz.bizbundle.family.api.AbsBizBundleFamilyService;
|
||||||
|
import com.thingclips.smart.home.sdk.ThingHomeSdk;
|
||||||
|
import com.thingclips.smart.thingpackconfig.PackConfig;
|
||||||
|
import com.xhai.iot.data.ApiConfig;
|
||||||
|
import com.xhai.iot.service.BizBundleFamilyServiceImpl;
|
||||||
|
|
||||||
|
import dagger.hilt.android.HiltAndroidApp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用入口:初始化涂鸦 SDK + UI BizBundle
|
||||||
|
*/
|
||||||
|
@HiltAndroidApp
|
||||||
|
public class TuyaApplication extends Application {
|
||||||
|
|
||||||
|
private static final String TAG = "TuyaApplication";
|
||||||
|
|
||||||
|
/** 全局上下文:供进程级常驻逻辑(如设备DP上报)使用,不依赖任何页面生命周期 */
|
||||||
|
private static volatile Context appContext;
|
||||||
|
|
||||||
|
public static Context getAppContext() {
|
||||||
|
return appContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void attachBaseContext(Context base) {
|
||||||
|
super.attachBaseContext(base);
|
||||||
|
appContext = getApplicationContext();
|
||||||
|
// 多Dex支持
|
||||||
|
MultiDex.install(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
// 进程启动即设定全局日夜模式(跟随 ThemeManager 持久化偏好),
|
||||||
|
// 避免强退重启后首个 Activity 落到默认亮色(尤其在扫码返回等场景)
|
||||||
|
try {
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "applyNightMode 失败: " + t.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 涂鸦 SDK 初始化开关(调试阶段先关掉,测我们自己的登录/绑定流程)
|
||||||
|
if (!ApiConfig.TUYA_SDK_ENABLED) {
|
||||||
|
Log.i(TAG, "涂鸦SDK已禁用(调试模式),仅测试应用自身流程");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 打开涂鸦 SDK 日志(调试面板问题:RNLog/L 默认关闭)
|
||||||
|
com.thingclips.smart.android.common.utils.L.setLogSwitcher(true);
|
||||||
|
|
||||||
|
// 业务包代理配置(配网能力开关等)
|
||||||
|
PackConfig.addValueDelegate(AppConfig.class);
|
||||||
|
|
||||||
|
// SoLoader 初始化(RN 面板容器必需;初始化后需删除旧 App 重装)
|
||||||
|
try {
|
||||||
|
com.facebook.soloader.SoLoader.init(this, false);
|
||||||
|
} catch (Throwable soErr) {
|
||||||
|
Log.e(TAG, "SoLoader 初始化失败: " + soErr.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// BizBundle 统一初始化(内部会初始化 ThingHomeSdk)
|
||||||
|
BizBundleInitializer.init(this, new RouteEventListener() {
|
||||||
|
@Override
|
||||||
|
public void onFaild(int errorCode, UrlBuilder urlBuilder) {
|
||||||
|
Log.e(TAG, "路由未实现 target=" + urlBuilder.target + " params=" + urlBuilder.params);
|
||||||
|
}
|
||||||
|
}, new ServiceEventListener() {
|
||||||
|
@Override
|
||||||
|
public void onFaild(String serviceName) {
|
||||||
|
Log.e(TAG, "服务未实现: " + serviceName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册家庭服务(Panel 等 BizBundle 必需)
|
||||||
|
BizBundleInitializer.registerService(AbsBizBundleFamilyService.class,
|
||||||
|
new BizBundleFamilyServiceImpl());
|
||||||
|
|
||||||
|
// 注册路由拦截器(面板跳转依赖路由系统,必须放行所有路由)
|
||||||
|
try {
|
||||||
|
RedirectService rs = MicroContext.getServiceManager()
|
||||||
|
.findServiceByInterface(RedirectService.class.getName());
|
||||||
|
if (rs != null) {
|
||||||
|
rs.registerUrlInterceptor(new RedirectService.UrlInterceptor() {
|
||||||
|
@Override
|
||||||
|
public void forUrlBuilder(UrlBuilder urlBuilder,
|
||||||
|
RedirectService.InterceptorCallback interceptorCallback) {
|
||||||
|
interceptorCallback.onContinue(urlBuilder);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (Throwable rsErr) {
|
||||||
|
Log.e(TAG, "注册路由拦截器失败: " + rsErr.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.i(TAG, "涂鸦BizBundle初始化成功");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "涂鸦BizBundle初始化失败: " + e.getMessage(), e);
|
||||||
|
// 兜底:如果 BizBundle 初始化失败,尝试用基础 SDK 初始化
|
||||||
|
try {
|
||||||
|
ThingHomeSdk.init(this, ApiConfig.TUYA_APP_KEY, ApiConfig.TUYA_APP_SECRET);
|
||||||
|
} catch (Exception ex) {
|
||||||
|
Log.e(TAG, "基础SDK初始化也失败: " + ex.getMessage(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
200
Android/app/src/main/java/com/xhai/iot/data/ApiClient.java
Normal file
200
Android/app/src/main/java/com/xhai/iot/data/ApiClient.java
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
package com.xhai.iot.data;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.logging.HttpLoggingInterceptor;
|
||||||
|
import retrofit2.Retrofit;
|
||||||
|
import retrofit2.converter.gson.GsonConverterFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrofit 网络客户端
|
||||||
|
*/
|
||||||
|
public class ApiClient {
|
||||||
|
|
||||||
|
private static final String TAG = "ApiClient";
|
||||||
|
private static ApiService apiService;
|
||||||
|
private static ApiService sceneApiService;
|
||||||
|
|
||||||
|
public static ApiService getApiService() {
|
||||||
|
if (apiService == null) {
|
||||||
|
synchronized (ApiClient.class) {
|
||||||
|
if (apiService == null) {
|
||||||
|
apiService = build(ApiConfig.BASE_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return apiService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 场景服务(规则引擎服务 iot-rule,独立端口) */
|
||||||
|
public static ApiService getSceneApiService() {
|
||||||
|
if (sceneApiService == null) {
|
||||||
|
synchronized (ApiClient.class) {
|
||||||
|
if (sceneApiService == null) {
|
||||||
|
sceneApiService = build(ApiConfig.SCENE_BASE_URL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sceneApiService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 静默刷新并发锁(多个请求同时 401 时只刷新一次) */
|
||||||
|
private static final Object REFRESH_LOCK = new Object();
|
||||||
|
|
||||||
|
private static ApiService build(String baseUrl) {
|
||||||
|
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(msg ->
|
||||||
|
Log.d(TAG, msg));
|
||||||
|
logging.setLevel(HttpLoggingInterceptor.Level.BODY);
|
||||||
|
|
||||||
|
// 401 统一兜底处理:静默刷新也失败(refresh token 也过期)时,
|
||||||
|
// 清除登录态,提示并跳转登录页。这是最后一道防线——正常情况下
|
||||||
|
// authenticator 已在 401 时用 refresh token 静默续期并重试成功。
|
||||||
|
okhttp3.Interceptor unauthorizedHandler = chain -> {
|
||||||
|
okhttp3.Response response = chain.proceed(chain.request());
|
||||||
|
if (response.code() == 401) {
|
||||||
|
try {
|
||||||
|
com.xhai.iot.util.SessionManager session =
|
||||||
|
com.xhai.iot.util.SessionManager.getInstance(null);
|
||||||
|
// 只有携带 token 的请求返回 401 才是登录过期(登录接口失败不带 token)
|
||||||
|
if (session.getBearerToken() != null && session.handleTokenExpired()) {
|
||||||
|
new android.os.Handler(android.os.Looper.getMainLooper()).post(() -> {
|
||||||
|
android.widget.Toast.makeText(session.appContext(),
|
||||||
|
"登录已过期,请重新登录",
|
||||||
|
android.widget.Toast.LENGTH_LONG).show();
|
||||||
|
android.content.Intent intent = new android.content.Intent(
|
||||||
|
session.appContext(), com.xhai.iot.ui.LoginActivity.class);
|
||||||
|
intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
| android.content.Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||||
|
session.appContext().startActivity(intent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "处理 401 失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
|
OkHttpClient client = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(15, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.authenticator(tokenAuthenticator())
|
||||||
|
.addInterceptor(unauthorizedHandler)
|
||||||
|
.addInterceptor(logging)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
Retrofit retrofit = new Retrofit.Builder()
|
||||||
|
.baseUrl(baseUrl)
|
||||||
|
.client(client)
|
||||||
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return retrofit.create(ApiService.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== refresh token 静默续期 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 401 时自动用 refresh token 换取新 token 并重试原请求(用户无感知)。
|
||||||
|
*
|
||||||
|
* 并发说明:多个请求同时 401 时,只有第一个进入锁的会真正刷新,
|
||||||
|
* 其他请求发现 session 里的 token 已更新(与失败请求的不同),
|
||||||
|
* 直接用新 token 重试,不会重复刷新。
|
||||||
|
*
|
||||||
|
* 刷新失败(refresh token 也过期/无效)→ 返回 null → 请求以 401 返回,
|
||||||
|
* 由 unauthorizedHandler 兜底清会话跳登录页。
|
||||||
|
*/
|
||||||
|
private static okhttp3.Authenticator tokenAuthenticator() {
|
||||||
|
return (route, response) -> {
|
||||||
|
// 已经重试过一次仍 401 → 不再重试(交给 401 兜底处理)
|
||||||
|
if (response.request().header("X-Auth-Retried") != null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
com.xhai.iot.util.SessionManager session;
|
||||||
|
try {
|
||||||
|
session = com.xhai.iot.util.SessionManager.getInstance(null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
synchronized (REFRESH_LOCK) {
|
||||||
|
String failedAuthHeader = response.request().header("Authorization");
|
||||||
|
String currentBearer = session.getBearerToken();
|
||||||
|
// 其他线程已完成刷新(session 里的 token 与失败请求的不同)→ 直接重试
|
||||||
|
if (currentBearer != null && !currentBearer.equals(failedAuthHeader)) {
|
||||||
|
Log.i(TAG, "token 已被其他请求刷新,直接重试");
|
||||||
|
return retryWithToken(response.request(), currentBearer);
|
||||||
|
}
|
||||||
|
// 静默刷新
|
||||||
|
String refreshToken = session.getRefreshToken();
|
||||||
|
if (refreshToken == null || refreshToken.isEmpty()) {
|
||||||
|
Log.i(TAG, "无 refresh token(旧版本登录),401 走过期处理");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String[] tokens = refreshSync(refreshToken);
|
||||||
|
if (tokens == null) {
|
||||||
|
Log.i(TAG, "静默刷新失败(refresh token 也过期),401 走过期处理");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
session.updateTokens(tokens[0], tokens[1]);
|
||||||
|
Log.i(TAG, "token 静默续期成功,重试原请求");
|
||||||
|
return retryWithToken(response.request(), "Bearer " + tokens[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static okhttp3.Request retryWithToken(okhttp3.Request request, String bearer) {
|
||||||
|
return request.newBuilder()
|
||||||
|
.header("Authorization", bearer)
|
||||||
|
.header("X-Auth-Retried", "1")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步调用刷新接口(须在 REFRESH_LOCK 内调用)。
|
||||||
|
*
|
||||||
|
* @return [新token, 新refreshToken],失败返回 null
|
||||||
|
*/
|
||||||
|
private static String[] refreshSync(String refreshToken) {
|
||||||
|
try {
|
||||||
|
okhttp3.OkHttpClient bare = new okhttp3.OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
okhttp3.RequestBody body = okhttp3.RequestBody.create(
|
||||||
|
"{\"refreshToken\":\"" + refreshToken + "\"}",
|
||||||
|
okhttp3.MediaType.parse("application/json"));
|
||||||
|
okhttp3.Request request = new okhttp3.Request.Builder()
|
||||||
|
.url(com.xhai.iot.data.ApiConfig.BASE_URL + "v1/auth/member/refresh")
|
||||||
|
.post(body)
|
||||||
|
.build();
|
||||||
|
okhttp3.Response resp = bare.newCall(request).execute();
|
||||||
|
String respBody = resp.body() != null ? resp.body().string() : null;
|
||||||
|
if (resp.isSuccessful() && respBody != null) {
|
||||||
|
com.google.gson.JsonObject root = com.google.gson.JsonParser
|
||||||
|
.parseString(respBody).getAsJsonObject();
|
||||||
|
if (root.has("data") && root.get("data").isJsonObject()
|
||||||
|
&& root.get("success").isJsonNull() == false
|
||||||
|
&& root.get("success").getAsBoolean()) {
|
||||||
|
com.google.gson.JsonObject data = root.getAsJsonObject("data");
|
||||||
|
String token = data.has("token") && !data.get("token").isJsonNull()
|
||||||
|
? data.get("token").getAsString() : null;
|
||||||
|
String refresh = data.has("refreshToken") && !data.get("refreshToken").isJsonNull()
|
||||||
|
? data.get("refreshToken").getAsString() : null;
|
||||||
|
if (token != null) {
|
||||||
|
return new String[]{token, refresh};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Log.w(TAG, "刷新token业务失败: " + respBody);
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "刷新token HTTP失败: " + resp.code());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "刷新token异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
30
Android/app/src/main/java/com/xhai/iot/data/ApiConfig.java
Normal file
30
Android/app/src/main/java/com/xhai/iot/data/ApiConfig.java
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
package com.xhai.iot.data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 API 配置
|
||||||
|
*/
|
||||||
|
public class ApiConfig {
|
||||||
|
|
||||||
|
// iotsaas 后端服务地址(生产环境经 nginx 按路径分发到各微服务)
|
||||||
|
public static final String BASE_URL = "https://mum.tonzou.cn/api/";
|
||||||
|
|
||||||
|
// 规则引擎服务地址(同样走公网域名,nginx 按 /api/v1/scenes|rules 分发到 iot-rule 9083)
|
||||||
|
public static final String SCENE_BASE_URL = "https://mum.tonzou.cn/api/";
|
||||||
|
|
||||||
|
// 登录后保存 token 的 SharedPreferences key
|
||||||
|
public static final String PREF_NAME = "xhai_session";
|
||||||
|
public static final String KEY_TOKEN = "token";
|
||||||
|
public static final String KEY_MEMBER_ID = "memberId";
|
||||||
|
public static final String KEY_USERNAME = "username";
|
||||||
|
public static final String KEY_NICKNAME = "nickname";
|
||||||
|
public static final String KEY_TUYA_BOUND = "tuya_bound";
|
||||||
|
|
||||||
|
// 涂鸦 SDK 配置(智能生活App SDK,瑞华物联 com.xhai.iot)
|
||||||
|
public static final String TUYA_APP_KEY = "cex9ntw3dsr3tywkrggp";
|
||||||
|
public static final String TUYA_APP_SECRET = "9cqqk43gksxdkueppjsexnvjttd5evaf";
|
||||||
|
|
||||||
|
// 启用涂鸦 SDK 初始化(已使用涂鸦后台真实凭证)
|
||||||
|
public static final boolean TUYA_SDK_ENABLED = true;
|
||||||
|
|
||||||
|
private ApiConfig() {}
|
||||||
|
}
|
||||||
453
Android/app/src/main/java/com/xhai/iot/data/ApiService.java
Normal file
453
Android/app/src/main/java/com/xhai/iot/data/ApiService.java
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
package com.xhai.iot.data;
|
||||||
|
|
||||||
|
import com.xhai.iot.model.AiCommandResult;
|
||||||
|
import com.xhai.iot.model.AiDeviceInfo;
|
||||||
|
import com.xhai.iot.model.BackendDevice;
|
||||||
|
import com.xhai.iot.model.ControlSchema;
|
||||||
|
import com.xhai.iot.model.DeviceActivityItem;
|
||||||
|
import com.xhai.iot.model.LoginData;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.model.SceneItem;
|
||||||
|
import com.xhai.iot.model.SpeechAuth;
|
||||||
|
import com.xhai.iot.model.TuyaDevice;
|
||||||
|
import com.xhai.iot.model.TuyaHome;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.http.Body;
|
||||||
|
import retrofit2.http.DELETE;
|
||||||
|
import retrofit2.http.GET;
|
||||||
|
import retrofit2.http.Header;
|
||||||
|
import retrofit2.http.POST;
|
||||||
|
import retrofit2.http.PUT;
|
||||||
|
import retrofit2.http.Path;
|
||||||
|
import retrofit2.http.Query;
|
||||||
|
import retrofit2.http.FormUrlEncoded;
|
||||||
|
import retrofit2.http.Field;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 API 接口定义
|
||||||
|
*/
|
||||||
|
public interface ApiService {
|
||||||
|
|
||||||
|
// ============ 会员登录 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员登录
|
||||||
|
* POST /api/v1/auth/member/login
|
||||||
|
* body: { "username": "xxx", "password": "xxx" }
|
||||||
|
*/
|
||||||
|
@POST("v1/auth/member/login")
|
||||||
|
Call<Result<LoginData>> login(@Body Map<String, String> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员注册
|
||||||
|
* POST /api/v1/auth/member/register
|
||||||
|
* body: { "username", "password", "nickname", "phone" }
|
||||||
|
*/
|
||||||
|
@POST("v1/auth/member/register")
|
||||||
|
Call<Result<Void>> register(@Body Map<String, String> body);
|
||||||
|
|
||||||
|
// ============ 中控屏会员自助绑定(APP 扫码) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码后预检某 SN 是否已预置/启用/已绑定
|
||||||
|
* GET /api/v1/member/panels/bindable?serialNumber=
|
||||||
|
*/
|
||||||
|
@GET("v1/member/panels/bindable")
|
||||||
|
Call<Result<com.xhai.iot.model.PanelBindable>> getPanelBindable(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("serialNumber") String serialNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定中控屏到家庭
|
||||||
|
* POST /api/v1/member/panels/bind
|
||||||
|
* body: { "serialNumber", "homeId" }
|
||||||
|
*/
|
||||||
|
@POST("v1/member/panels/bind")
|
||||||
|
Call<Result<com.xhai.iot.model.PanelBindResult>> bindPanel(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Body Map<String, Object> body);
|
||||||
|
|
||||||
|
/** 列出当前会员绑定的所有中控屏 GET /api/v1/member/panels */
|
||||||
|
@GET("v1/member/panels")
|
||||||
|
Call<Result<List<com.xhai.iot.model.PanelInfo>>> getMyPanels(
|
||||||
|
@Header("Authorization") String auth);
|
||||||
|
|
||||||
|
/** 解绑中控屏 POST /api/v1/member/panels/{panelId}/unbind */
|
||||||
|
@POST("v1/member/panels/{panelId}/unbind")
|
||||||
|
Call<Result<Void>> unbindPanel(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("panelId") long panelId);
|
||||||
|
|
||||||
|
/** 获取 Matter Fabric 配网凭证 POST /api/v1/tuya/config/matter-token */
|
||||||
|
@FormUrlEncoded
|
||||||
|
@POST("v1/tuya/config/matter-token")
|
||||||
|
Call<Result<com.xhai.iot.model.MatterTokenResp>> getMatterToken(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Field("homeId") long homeId);
|
||||||
|
|
||||||
|
// ============ 设备列表(本平台后端,不依赖涂鸦云端) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取本平台设备列表
|
||||||
|
* GET /api/v1/devices?page=1&size=20
|
||||||
|
* @param parentDeviceId 可选:按父网关查子设备
|
||||||
|
* @param isGateway 可选:1=仅网关
|
||||||
|
*/
|
||||||
|
@GET("v1/devices")
|
||||||
|
Call<Result<PageData<BackendDevice>>> getDevices(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("page") int page,
|
||||||
|
@Query("size") int size,
|
||||||
|
@Query("parentDeviceId") String parentDeviceId,
|
||||||
|
@Query("isGateway") Integer isGateway,
|
||||||
|
@Query("homeId") Long homeId);
|
||||||
|
|
||||||
|
/** 查询某网关下的子设备 */
|
||||||
|
default Call<Result<PageData<BackendDevice>>> getSubDevices(
|
||||||
|
String auth, int page, int size, String gatewayId) {
|
||||||
|
return getDevices(auth, page, size, gatewayId, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 天气(首页环境卡) ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取室外天气
|
||||||
|
* GET /api/v1/weather?lat=&lon=(Open-Meteo 代理,服务端缓存 30 分钟)
|
||||||
|
* 返回:{ temp, humidity, weather, weatherCode, source }
|
||||||
|
*/
|
||||||
|
@GET("v1/weather")
|
||||||
|
Call<Result<java.util.Map<String, Object>>> getWeather(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("lat") double lat,
|
||||||
|
@Query("lon") double lon);
|
||||||
|
|
||||||
|
// ============ 家庭/房间(本平台后端,不对接涂鸦) ============
|
||||||
|
|
||||||
|
/** 我的家庭列表 GET /api/v1/homes?memberId= */
|
||||||
|
@GET("v1/homes")
|
||||||
|
Call<Result<List<com.xhai.iot.model.BackendHome>>> getMyHomes(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("memberId") long memberId);
|
||||||
|
|
||||||
|
/** 创建家庭 POST /api/v1/homes?memberId= body: {"name": "..."} */
|
||||||
|
@POST("v1/homes")
|
||||||
|
Call<Result<com.xhai.iot.model.BackendHome>> createHome(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("memberId") long memberId,
|
||||||
|
@Body Map<String, String> body);
|
||||||
|
|
||||||
|
/** 删除家庭 DELETE /api/v1/homes/{id}?memberId=(仅 owner) */
|
||||||
|
@DELETE("v1/homes/{id}")
|
||||||
|
Call<Result<Void>> deleteHome(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("id") long homeId,
|
||||||
|
@Query("memberId") long memberId);
|
||||||
|
|
||||||
|
/** 房间列表 GET /api/v1/homes/{homeId}/rooms?memberId= */
|
||||||
|
@GET("v1/homes/{homeId}/rooms")
|
||||||
|
Call<Result<List<com.xhai.iot.model.BackendRoom>>> getRooms(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("homeId") long homeId,
|
||||||
|
@Query("memberId") long memberId);
|
||||||
|
|
||||||
|
/** 添加房间 POST /api/v1/homes/{homeId}/rooms?memberId= body: {"name": "..."} */
|
||||||
|
@POST("v1/homes/{homeId}/rooms")
|
||||||
|
Call<Result<com.xhai.iot.model.BackendRoom>> addRoom(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("homeId") long homeId,
|
||||||
|
@Query("memberId") long memberId,
|
||||||
|
@Body Map<String, String> body);
|
||||||
|
|
||||||
|
/** 删除房间 DELETE /api/v1/homes/{homeId}/rooms/{roomId}?memberId= */
|
||||||
|
@DELETE("v1/homes/{homeId}/rooms/{roomId}")
|
||||||
|
Call<Result<Void>> deleteRoom(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("homeId") long homeId,
|
||||||
|
@Path("roomId") long roomId,
|
||||||
|
@Query("memberId") long memberId);
|
||||||
|
|
||||||
|
// ============ 网关-子设备关系 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定/解绑父网关(添加子设备)
|
||||||
|
* POST /api/v1/devices/{deviceId}/parent body: {"parentDeviceId":"网关deviceId"}
|
||||||
|
*/
|
||||||
|
@POST("v1/devices/{deviceId}/parent")
|
||||||
|
Call<Result<Integer>> bindDeviceParent(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("deviceId") String deviceId,
|
||||||
|
@Body Map<String, String> body);
|
||||||
|
|
||||||
|
// ============ 涂鸦绑定 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦绑定状态查询
|
||||||
|
* GET /api/v1/tuya/auth/status
|
||||||
|
*/
|
||||||
|
@GET("v1/tuya/auth/status")
|
||||||
|
Call<Result<Map<String, Object>>> getTuyaBindStatus(
|
||||||
|
@Header("Authorization") String auth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 原生SDK绑定涂鸦账号
|
||||||
|
* POST /api/v1/tuya/auth/bind-native
|
||||||
|
* body: { "tuyaUid", "account", "loginMethod", "platform" }
|
||||||
|
*/
|
||||||
|
@POST("v1/tuya/auth/bind-native")
|
||||||
|
Call<Result<Map<String, Object>>> bindNative(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Body Map<String, String> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑涂鸦账号
|
||||||
|
* POST /api/v1/tuya/auth/unbind
|
||||||
|
*/
|
||||||
|
@POST("v1/tuya/auth/unbind")
|
||||||
|
Call<Result<Void>> unbindTuya(@Header("Authorization") String auth);
|
||||||
|
|
||||||
|
// ============ 涂鸦家庭 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取涂鸦家庭列表
|
||||||
|
* GET /api/v1/tuya/homes
|
||||||
|
*/
|
||||||
|
@GET("v1/tuya/homes")
|
||||||
|
Call<Result<List<TuyaHome>>> getTuyaHomes(@Header("Authorization") String auth);
|
||||||
|
|
||||||
|
// ============ 涂鸦设备 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取涂鸦设备列表
|
||||||
|
* GET /api/v1/tuya/devices?homeId=xxx
|
||||||
|
*/
|
||||||
|
@GET("v1/tuya/devices")
|
||||||
|
Call<Result<List<TuyaDevice>>> getTuyaDevices(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("homeId") Long homeId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定涂鸦设备到本平台
|
||||||
|
* POST /api/v1/tuya/devices/bind
|
||||||
|
*/
|
||||||
|
@POST("v1/tuya/devices/bind")
|
||||||
|
Call<Result<Integer>> bindTuyaDevices(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Body Map<String, Object> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备状态实时上报(监听涂鸦SDK的上下线、DP变化)
|
||||||
|
* POST /api/v1/tuya/devices/status
|
||||||
|
*/
|
||||||
|
@POST("v1/tuya/devices/status")
|
||||||
|
Call<Result<Integer>> reportDeviceStatus(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Body Map<String, Object> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询涂鸦云端设备在线状态(探活兜底)
|
||||||
|
* GET /api/v1/tuya/devices/{deviceId}/cloud-status
|
||||||
|
* 返回涂鸦 IoT 平台的设备激活状态(online 字段),比 SDK 本地快照权威
|
||||||
|
*/
|
||||||
|
@GET("v1/tuya/devices/{deviceId}/cloud-status")
|
||||||
|
Call<Result<Map<String, Object>>> getTuyaCloudStatus(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("deviceId") String deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页近期动态(最近操控记录,最多 20 条)
|
||||||
|
* GET /api/v1/devices/activities/recent
|
||||||
|
*/
|
||||||
|
@GET("v1/devices/activities/recent")
|
||||||
|
Call<Result<List<DeviceActivityItem>>> getRecentActivities(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("homeId") Long homeId,
|
||||||
|
@Query("limit") int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操控动态分页查询(动态详情页,可按设备过滤)
|
||||||
|
* GET /api/v1/devices/activities
|
||||||
|
*/
|
||||||
|
@GET("v1/devices/activities")
|
||||||
|
Call<Result<PageData<DeviceActivityItem>>> getDeviceActivities(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("homeId") Long homeId,
|
||||||
|
@Query("deviceId") String deviceId,
|
||||||
|
@Query("pageNum") int pageNum,
|
||||||
|
@Query("pageSize") int pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送设备控制指令
|
||||||
|
* POST /api/v1/tuya/devices/{deviceId}/commands
|
||||||
|
*/
|
||||||
|
@POST("v1/tuya/devices/{deviceId}/commands")
|
||||||
|
Call<Result<Boolean>> sendTuyaCommand(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("deviceId") String deviceId,
|
||||||
|
@Body List<Map<String, Object>> commands);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取设备状态
|
||||||
|
* GET /api/v1/tuya/devices/{deviceId}/status
|
||||||
|
*/
|
||||||
|
@GET("v1/tuya/devices/{deviceId}/status")
|
||||||
|
Call<Result<List<Map<String, Object>>>> getTuyaDeviceStatus(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("deviceId") String deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报设备控制动态(APP 走 SDK 直连不经后端,需主动上报首页"控制记录")
|
||||||
|
* POST /api/v1/devices/{deviceId}/activity
|
||||||
|
*/
|
||||||
|
@POST("v1/devices/activities/{deviceId}/activity")
|
||||||
|
Call<Result<Void>> reportDeviceActivity(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("deviceId") String deviceId,
|
||||||
|
@Body Map<String, Object> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备标准控制模型(由产品物模型生成,跨平台统一渲染)
|
||||||
|
* GET /api/v1/devices/{deviceId}/control-schema
|
||||||
|
*/
|
||||||
|
@GET("v1/devices/{deviceId}/control-schema")
|
||||||
|
Call<Result<ControlSchema>> getDeviceControlSchema(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("deviceId") String deviceId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重命名涂鸦设备(同步到本平台 device 表)
|
||||||
|
* POST /api/v1/tuya/devices/{deviceId}/rename
|
||||||
|
*/
|
||||||
|
@POST("v1/tuya/devices/{deviceId}/rename")
|
||||||
|
Call<Result<Void>> renameTuyaDevice(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("deviceId") String deviceId,
|
||||||
|
@Body Map<String, String> body);
|
||||||
|
|
||||||
|
// ============ 场景 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询场景列表
|
||||||
|
* GET /api/v1/scenes?pageNum=1&pageSize=50
|
||||||
|
*/
|
||||||
|
@GET("v1/scenes")
|
||||||
|
Call<Result<PageData<SceneItem>>> getScenes(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("pageNum") int pageNum,
|
||||||
|
@Query("pageSize") int pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建场景
|
||||||
|
* POST /api/v1/scenes
|
||||||
|
*/
|
||||||
|
@POST("v1/scenes")
|
||||||
|
Call<Result<Long>> createScene(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Body Map<String, Object> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键执行场景
|
||||||
|
* POST /api/v1/scenes/{sceneId}/execute
|
||||||
|
*/
|
||||||
|
@POST("v1/scenes/{sceneId}/execute")
|
||||||
|
Call<Result<List<Map<String, Object>>>> executeScene(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("sceneId") Long sceneId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除场景
|
||||||
|
* DELETE /api/v1/scenes/{sceneId}
|
||||||
|
*/
|
||||||
|
@retrofit2.http.DELETE("v1/scenes/{sceneId}")
|
||||||
|
Call<Result<Void>> deleteScene(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("sceneId") Long sceneId);
|
||||||
|
|
||||||
|
// ============ 规则 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询某场景下的规则列表
|
||||||
|
* GET /api/v1/rules?sceneId=xxx
|
||||||
|
*/
|
||||||
|
@GET("v1/rules")
|
||||||
|
Call<Result<PageData<Map<String, Object>>>> getRules(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("sceneId") Long sceneId,
|
||||||
|
@Query("pageNum") int pageNum,
|
||||||
|
@Query("pageSize") int pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建规则
|
||||||
|
* POST /api/v1/rules
|
||||||
|
*/
|
||||||
|
@POST("v1/rules")
|
||||||
|
Call<Result<Long>> createRule(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Body Map<String, Object> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除规则
|
||||||
|
* DELETE /api/v1/rules/{ruleId}
|
||||||
|
*/
|
||||||
|
@retrofit2.http.DELETE("v1/rules/{ruleId}")
|
||||||
|
Call<Result<Void>> deleteRule(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("ruleId") Long ruleId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用/禁用规则
|
||||||
|
*/
|
||||||
|
@POST("v1/rules/{ruleId}/{action}")
|
||||||
|
Call<Result<Void>> toggleRule(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Path("ruleId") Long ruleId,
|
||||||
|
@Path("action") String action);
|
||||||
|
|
||||||
|
// ============ AI 助手(iot-rule 服务,端口 9083) ============
|
||||||
|
// 注意:这些接口必须用 ApiClient.getSceneApiService() 调用
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取讯飞语音听写鉴权地址
|
||||||
|
* GET /api/v1/ai/speech/auth
|
||||||
|
* <p>
|
||||||
|
* 返回 { enabled, appId, url }:enabled=false 表示后台未启用(回退系统识别/键盘);
|
||||||
|
* url 为带签名的临时 wss 地址(签名时效约 5 分钟,领取后立即使用),APISecret 全程不出后端。
|
||||||
|
*/
|
||||||
|
@GET("v1/ai/speech/auth")
|
||||||
|
Call<Result<SpeechAuth>> getSpeechAuth(
|
||||||
|
@Header("Authorization") String auth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行自然语言指令
|
||||||
|
* POST /api/v1/ai/command
|
||||||
|
* body: { "text": "打开客厅灯", "targetType": "device", "targetId": "xxx", "learn": true }
|
||||||
|
* <p>
|
||||||
|
* targetType/targetId 仅在上一轮返回 needConfirm=true、用户点选候选后传入,
|
||||||
|
* 服务端据此消歧并自动把该说法学习为设备别名。
|
||||||
|
*/
|
||||||
|
@POST("v1/ai/command")
|
||||||
|
Call<Result<AiCommandResult>> aiCommand(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Body Map<String, Object> body);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户可被 AI 控制的设备(用于生成快捷指令与目标提示)
|
||||||
|
* GET /api/v1/ai/devices
|
||||||
|
*/
|
||||||
|
@GET("v1/ai/devices")
|
||||||
|
Call<Result<List<AiDeviceInfo>>> getAiDevices(
|
||||||
|
@Header("Authorization") String auth);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 指令历史记录
|
||||||
|
* GET /api/v1/ai/logs?limit=20
|
||||||
|
*/
|
||||||
|
@GET("v1/ai/logs")
|
||||||
|
Call<Result<List<Map<String, Object>>>> getAiLogs(
|
||||||
|
@Header("Authorization") String auth,
|
||||||
|
@Query("limit") int limit);
|
||||||
|
}
|
||||||
@ -0,0 +1,154 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 自然语言指令执行结果
|
||||||
|
* 对应后端 iot-rule 的 POST /api/v1/ai/command → AiCommandResponse
|
||||||
|
*/
|
||||||
|
public class AiCommandResult {
|
||||||
|
|
||||||
|
/** 是否执行成功 */
|
||||||
|
@SerializedName("success")
|
||||||
|
private Boolean success;
|
||||||
|
|
||||||
|
/** 给用户的自然语言回复,可直接用于界面展示/语音播报 */
|
||||||
|
@SerializedName("reply")
|
||||||
|
private String reply;
|
||||||
|
|
||||||
|
/** 意图类型: device_control / scene_execute / unknown */
|
||||||
|
@SerializedName("intentType")
|
||||||
|
private String intentType;
|
||||||
|
|
||||||
|
/** 动作: turn_on / turn_off / set_value / execute */
|
||||||
|
@SerializedName("action")
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
/** 解析来源: rule(规则模板) / llm(大模型) */
|
||||||
|
@SerializedName("parseSource")
|
||||||
|
private String parseSource;
|
||||||
|
|
||||||
|
/** 命中目标类型: device / scene */
|
||||||
|
@SerializedName("matchedType")
|
||||||
|
private String matchedType;
|
||||||
|
|
||||||
|
/** 命中目标ID */
|
||||||
|
@SerializedName("matchedId")
|
||||||
|
private String matchedId;
|
||||||
|
|
||||||
|
/** 命中目标名称 */
|
||||||
|
@SerializedName("matchedName")
|
||||||
|
private String matchedName;
|
||||||
|
|
||||||
|
/** 置信度 0-1 */
|
||||||
|
@SerializedName("confidence")
|
||||||
|
private Double confidence;
|
||||||
|
|
||||||
|
/** 是否需要用户二次确认(存在歧义候选) */
|
||||||
|
@SerializedName("needConfirm")
|
||||||
|
private Boolean needConfirm;
|
||||||
|
|
||||||
|
/** 是否已执行 */
|
||||||
|
@SerializedName("executed")
|
||||||
|
private Boolean executed;
|
||||||
|
|
||||||
|
/** 歧义候选列表(needConfirm=true 时返回) */
|
||||||
|
@SerializedName("candidates")
|
||||||
|
private List<Candidate> candidates = new ArrayList<>();
|
||||||
|
|
||||||
|
/** 耗时(毫秒) */
|
||||||
|
@SerializedName("costMs")
|
||||||
|
private Integer costMs;
|
||||||
|
|
||||||
|
public Boolean getSuccess() { return success; }
|
||||||
|
public void setSuccess(Boolean success) { this.success = success; }
|
||||||
|
|
||||||
|
public String getReply() { return reply; }
|
||||||
|
public void setReply(String reply) { this.reply = reply; }
|
||||||
|
|
||||||
|
public String getIntentType() { return intentType; }
|
||||||
|
public void setIntentType(String intentType) { this.intentType = intentType; }
|
||||||
|
|
||||||
|
public String getAction() { return action; }
|
||||||
|
public void setAction(String action) { this.action = action; }
|
||||||
|
|
||||||
|
public String getParseSource() { return parseSource; }
|
||||||
|
public void setParseSource(String parseSource) { this.parseSource = parseSource; }
|
||||||
|
|
||||||
|
public String getMatchedType() { return matchedType; }
|
||||||
|
public void setMatchedType(String matchedType) { this.matchedType = matchedType; }
|
||||||
|
|
||||||
|
public String getMatchedId() { return matchedId; }
|
||||||
|
public void setMatchedId(String matchedId) { this.matchedId = matchedId; }
|
||||||
|
|
||||||
|
public String getMatchedName() { return matchedName; }
|
||||||
|
public void setMatchedName(String matchedName) { this.matchedName = matchedName; }
|
||||||
|
|
||||||
|
public Double getConfidence() { return confidence; }
|
||||||
|
public void setConfidence(Double confidence) { this.confidence = confidence; }
|
||||||
|
|
||||||
|
public Boolean getNeedConfirm() { return needConfirm; }
|
||||||
|
public void setNeedConfirm(Boolean needConfirm) { this.needConfirm = needConfirm; }
|
||||||
|
|
||||||
|
public Boolean getExecuted() { return executed; }
|
||||||
|
public void setExecuted(Boolean executed) { this.executed = executed; }
|
||||||
|
|
||||||
|
public List<Candidate> getCandidates() { return candidates; }
|
||||||
|
public void setCandidates(List<Candidate> candidates) { this.candidates = candidates; }
|
||||||
|
|
||||||
|
public Integer getCostMs() { return costMs; }
|
||||||
|
public void setCostMs(Integer costMs) { this.costMs = costMs; }
|
||||||
|
|
||||||
|
public boolean isSuccess() { return success != null && success; }
|
||||||
|
|
||||||
|
public boolean isNeedConfirm() { return needConfirm != null && needConfirm; }
|
||||||
|
|
||||||
|
public boolean isExecuted() { return executed != null && executed; }
|
||||||
|
|
||||||
|
public boolean hasCandidates() { return candidates != null && !candidates.isEmpty(); }
|
||||||
|
|
||||||
|
public String getReplyOrEmpty() { return reply == null ? "" : reply; }
|
||||||
|
|
||||||
|
/** 解析来源中文标签,用于界面标注「由什么理解出来的」 */
|
||||||
|
public String getParseSourceLabel() {
|
||||||
|
if ("llm".equalsIgnoreCase(parseSource)) return "大模型";
|
||||||
|
if ("rule".equalsIgnoreCase(parseSource)) return "规则模板";
|
||||||
|
if ("alias".equalsIgnoreCase(parseSource)) return "已学习别名";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 歧义候选目标 */
|
||||||
|
public static class Candidate {
|
||||||
|
|
||||||
|
/** 目标类型: device / scene */
|
||||||
|
@SerializedName("targetType")
|
||||||
|
private String targetType;
|
||||||
|
|
||||||
|
/** 目标ID */
|
||||||
|
@SerializedName("targetId")
|
||||||
|
private String targetId;
|
||||||
|
|
||||||
|
/** 目标名称 */
|
||||||
|
@SerializedName("targetName")
|
||||||
|
private String targetName;
|
||||||
|
|
||||||
|
/** 匹配分数 */
|
||||||
|
@SerializedName("score")
|
||||||
|
private Double score;
|
||||||
|
|
||||||
|
public String getTargetType() { return targetType; }
|
||||||
|
public void setTargetType(String targetType) { this.targetType = targetType; }
|
||||||
|
|
||||||
|
public String getTargetId() { return targetId; }
|
||||||
|
public void setTargetId(String targetId) { this.targetId = targetId; }
|
||||||
|
|
||||||
|
public String getTargetName() { return targetName; }
|
||||||
|
public void setTargetName(String targetName) { this.targetName = targetName; }
|
||||||
|
|
||||||
|
public Double getScore() { return score; }
|
||||||
|
public void setScore(Double score) { this.score = score; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可被 AI 控制的设备(供 AI 助手展示与生成快捷指令)
|
||||||
|
* 对应后端 iot-rule 的 GET /api/v1/ai/devices → AiDeviceView
|
||||||
|
*/
|
||||||
|
public class AiDeviceInfo {
|
||||||
|
|
||||||
|
/** 设备业务ID(下发指令用) */
|
||||||
|
@SerializedName("deviceId")
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
/** 设备名称 */
|
||||||
|
@SerializedName("deviceName")
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
/** 设备类型(涂鸦设备常为空) */
|
||||||
|
@SerializedName("deviceType")
|
||||||
|
private String deviceType;
|
||||||
|
|
||||||
|
/** 位置描述(如:客厅) */
|
||||||
|
@SerializedName("location")
|
||||||
|
private String location;
|
||||||
|
|
||||||
|
/** 所属房间ID */
|
||||||
|
@SerializedName("roomId")
|
||||||
|
private Long roomId;
|
||||||
|
|
||||||
|
/** 所属家庭ID */
|
||||||
|
@SerializedName("homeId")
|
||||||
|
private Long homeId;
|
||||||
|
|
||||||
|
/** 接入平台: tuya/local/... */
|
||||||
|
@SerializedName("platform")
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/** 在线状态: online / offline */
|
||||||
|
@SerializedName("status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
/** 使用频次(后端统计,用于常用设备排序) */
|
||||||
|
@SerializedName("useCount")
|
||||||
|
private int useCount;
|
||||||
|
|
||||||
|
/** 最近使用时间(后端统计,用于常用设备排序) */
|
||||||
|
@SerializedName("lastUsedTime")
|
||||||
|
private String lastUsedTime;
|
||||||
|
|
||||||
|
public String getDeviceId() { return deviceId; }
|
||||||
|
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||||
|
|
||||||
|
public String getDeviceName() { return deviceName; }
|
||||||
|
public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
|
||||||
|
|
||||||
|
public String getDeviceType() { return deviceType; }
|
||||||
|
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
|
||||||
|
|
||||||
|
public String getLocation() { return location; }
|
||||||
|
public void setLocation(String location) { this.location = location; }
|
||||||
|
|
||||||
|
public Long getRoomId() { return roomId; }
|
||||||
|
public void setRoomId(Long roomId) { this.roomId = roomId; }
|
||||||
|
|
||||||
|
public Long getHomeId() { return homeId; }
|
||||||
|
public void setHomeId(Long homeId) { this.homeId = homeId; }
|
||||||
|
|
||||||
|
public String getPlatform() { return platform; }
|
||||||
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
|
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
|
||||||
|
public int getUseCount() { return useCount; }
|
||||||
|
public void setUseCount(int useCount) { this.useCount = useCount; }
|
||||||
|
|
||||||
|
public String getLastUsedTime() { return lastUsedTime; }
|
||||||
|
public void setLastUsedTime(String lastUsedTime) { this.lastUsedTime = lastUsedTime; }
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本平台后端设备(device 表),首页设备列表数据源。
|
||||||
|
* 家庭/房间/设备清单由我们后端管理,涂鸦 SDK 仅用于设备控制。
|
||||||
|
*/
|
||||||
|
public class BackendDevice {
|
||||||
|
|
||||||
|
@SerializedName("deviceId")
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
@SerializedName("deviceName")
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
@SerializedName("platform")
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
@SerializedName("status")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@SerializedName("icon")
|
||||||
|
private String icon;
|
||||||
|
|
||||||
|
@SerializedName("ownerId")
|
||||||
|
private String ownerId;
|
||||||
|
|
||||||
|
@SerializedName("deviceType")
|
||||||
|
private String deviceType;
|
||||||
|
|
||||||
|
@SerializedName("parentDeviceId")
|
||||||
|
private String parentDeviceId;
|
||||||
|
|
||||||
|
@SerializedName("isGateway")
|
||||||
|
private Integer isGateway;
|
||||||
|
|
||||||
|
public String getDeviceId() { return deviceId; }
|
||||||
|
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||||
|
|
||||||
|
public String getDeviceName() { return deviceName; }
|
||||||
|
public void setDeviceName(String deviceName) { this.deviceName = deviceName; }
|
||||||
|
|
||||||
|
public String getPlatform() { return platform; }
|
||||||
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
|
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
|
||||||
|
public boolean isOnline() { return "online".equalsIgnoreCase(status); }
|
||||||
|
|
||||||
|
public String getIcon() { return icon; }
|
||||||
|
public void setIcon(String icon) { this.icon = icon; }
|
||||||
|
|
||||||
|
public String getOwnerId() { return ownerId; }
|
||||||
|
public void setOwnerId(String ownerId) { this.ownerId = ownerId; }
|
||||||
|
|
||||||
|
public String getDeviceType() { return deviceType; }
|
||||||
|
public void setDeviceType(String deviceType) { this.deviceType = deviceType; }
|
||||||
|
|
||||||
|
public String getParentDeviceId() { return parentDeviceId; }
|
||||||
|
public void setParentDeviceId(String parentDeviceId) { this.parentDeviceId = parentDeviceId; }
|
||||||
|
|
||||||
|
public Integer getIsGateway() { return isGateway; }
|
||||||
|
public void setIsGateway(Integer isGateway) { this.isGateway = isGateway; }
|
||||||
|
|
||||||
|
public boolean isGateway() { return isGateway != null && isGateway == 1; }
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本平台家庭(后端 home 表)
|
||||||
|
* 对应 GET /api/v1/homes 返回
|
||||||
|
*/
|
||||||
|
public class BackendHome {
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private Long ownerId;
|
||||||
|
private Integer deviceCount;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getOwnerId() {
|
||||||
|
return ownerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDeviceCount() {
|
||||||
|
return deviceCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本平台房间(后端 room 表)
|
||||||
|
* 对应 GET /api/v1/homes/{homeId}/rooms 返回
|
||||||
|
*/
|
||||||
|
public class BackendRoom {
|
||||||
|
private Long id;
|
||||||
|
private Long homeId;
|
||||||
|
private String name;
|
||||||
|
private Integer deviceCount;
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getHomeId() {
|
||||||
|
return homeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Integer getDeviceCount() {
|
||||||
|
return deviceCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准控制模型(由产品物模型 TSL 推导,后端 GET /api/v1/devices/{deviceId}/control-schema)
|
||||||
|
* 跨平台统一:涂鸦/Matter/华为等统一按此模型渲染控制界面,无物模型时 available=false 回退原渲染。
|
||||||
|
*/
|
||||||
|
public class ControlSchema {
|
||||||
|
|
||||||
|
public long productId;
|
||||||
|
public String productName;
|
||||||
|
public String platform;
|
||||||
|
public int specVersion;
|
||||||
|
public String mainSwitchCode;
|
||||||
|
public boolean available;
|
||||||
|
public List<ControlItem> items;
|
||||||
|
public List<ControlItem> readOnly;
|
||||||
|
|
||||||
|
public static class ControlItem {
|
||||||
|
public String code;
|
||||||
|
public String name;
|
||||||
|
public String type;
|
||||||
|
public String dpId;
|
||||||
|
/** switch / slider / segmented / number / text / color */
|
||||||
|
public String widget;
|
||||||
|
public Double min;
|
||||||
|
public Double max;
|
||||||
|
public Double step;
|
||||||
|
public String unit;
|
||||||
|
public List<String> enumValues;
|
||||||
|
public String defaultValue;
|
||||||
|
public Integer sort;
|
||||||
|
public boolean writable;
|
||||||
|
public Object value;
|
||||||
|
|
||||||
|
public String key() {
|
||||||
|
// 下发优先用 DP 映射,没有则用功能点编码
|
||||||
|
return (dpId != null && !dpId.isEmpty()) ? dpId : code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,106 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备操控动态(对应后端 device_activity_log)
|
||||||
|
*/
|
||||||
|
public class DeviceActivityItem {
|
||||||
|
|
||||||
|
@SerializedName("id")
|
||||||
|
private long id;
|
||||||
|
|
||||||
|
@SerializedName("homeId")
|
||||||
|
private Long homeId;
|
||||||
|
|
||||||
|
@SerializedName("deviceId")
|
||||||
|
private String deviceId;
|
||||||
|
|
||||||
|
@SerializedName("deviceName")
|
||||||
|
private String deviceName;
|
||||||
|
|
||||||
|
/** switch_on/switch_off/adjust/status_change/online/offline/scene */
|
||||||
|
@SerializedName("action")
|
||||||
|
private String action;
|
||||||
|
|
||||||
|
/** 人类可读描述,如"打开 客厅灯" */
|
||||||
|
@SerializedName("actionDesc")
|
||||||
|
private String actionDesc;
|
||||||
|
|
||||||
|
/** app/panel/voice/scene/cloud_push */
|
||||||
|
@SerializedName("source")
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
@SerializedName("detail")
|
||||||
|
private String detail;
|
||||||
|
|
||||||
|
/** ISO 时间字符串,如 2026-09-17T15:30:00 */
|
||||||
|
@SerializedName("createTime")
|
||||||
|
private String createTime;
|
||||||
|
|
||||||
|
public long getId() { return id; }
|
||||||
|
public Long getHomeId() { return homeId; }
|
||||||
|
public String getDeviceId() { return deviceId; }
|
||||||
|
public String getDeviceName() { return deviceName; }
|
||||||
|
public String getAction() { return action; }
|
||||||
|
public String getActionDesc() { return actionDesc; }
|
||||||
|
public String getSource() { return source; }
|
||||||
|
public String getDetail() { return detail; }
|
||||||
|
public String getCreateTime() { return createTime; }
|
||||||
|
|
||||||
|
/** 动作对应的展示图标 */
|
||||||
|
public String icon() {
|
||||||
|
if (action == null) return "🔄";
|
||||||
|
switch (action) {
|
||||||
|
case "switch_on": return "💡";
|
||||||
|
case "switch_off": return "🌙";
|
||||||
|
case "adjust": return "🎚";
|
||||||
|
case "status_change": return "🔄";
|
||||||
|
case "online": return "✅";
|
||||||
|
case "offline": return "⛔";
|
||||||
|
case "scene": return "🎬";
|
||||||
|
default: return "🔄";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 来源的展示文案 */
|
||||||
|
public String sourceLabel() {
|
||||||
|
if (source == null) return "";
|
||||||
|
switch (source) {
|
||||||
|
case "app": return "APP 操作";
|
||||||
|
case "panel": return "面板/手动";
|
||||||
|
case "voice": return "AI 语音";
|
||||||
|
case "scene": return "场景联动";
|
||||||
|
case "cloud_push": return "设备上报";
|
||||||
|
default: return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 时间 → 相对时间(刚刚/x 分钟前/x 小时前/昨天 MM:mm/MM-dd/MM-dd 以前) */
|
||||||
|
public String relativeTime() {
|
||||||
|
if (createTime == null || createTime.isEmpty()) return "";
|
||||||
|
try {
|
||||||
|
java.text.SimpleDateFormat in = new java.text.SimpleDateFormat(
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.CHINA);
|
||||||
|
java.util.Date d = in.parse(createTime);
|
||||||
|
if (d == null) return createTime;
|
||||||
|
long diff = System.currentTimeMillis() - d.getTime();
|
||||||
|
if (diff < 0) diff = 0;
|
||||||
|
long min = diff / 60000;
|
||||||
|
if (min < 1) return "刚刚";
|
||||||
|
if (min < 60) return min + " 分钟前";
|
||||||
|
long h = min / 60;
|
||||||
|
if (h < 24) return h + " 小时前";
|
||||||
|
long day = h / 24;
|
||||||
|
java.text.SimpleDateFormat hm =
|
||||||
|
new java.text.SimpleDateFormat("HH:mm", java.util.Locale.CHINA);
|
||||||
|
if (day == 1) return "昨天 " + hm.format(d);
|
||||||
|
if (day < 30) {
|
||||||
|
return new java.text.SimpleDateFormat("MM-dd HH:mm", java.util.Locale.CHINA).format(d);
|
||||||
|
}
|
||||||
|
return new java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.CHINA).format(d);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return createTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能家居设备平台枚举
|
||||||
|
* 与后端 Constants.Platform 对齐,支持多平台扩展
|
||||||
|
*/
|
||||||
|
public enum DevicePlatform {
|
||||||
|
|
||||||
|
/** 涂鸦 */
|
||||||
|
TUYA("tuya"),
|
||||||
|
/** 谷歌 Home (Matter/Thread) */
|
||||||
|
GOOGLE_HOME("google_home"),
|
||||||
|
/** 华为 HiLink/鸿蒙智联 */
|
||||||
|
HUAWEI("huawei"),
|
||||||
|
/** 小米米家 */
|
||||||
|
XIAOMI("miot"),
|
||||||
|
/** 小度 */
|
||||||
|
DUEROS("dueros"),
|
||||||
|
/** Matter */
|
||||||
|
MATTER("matter"),
|
||||||
|
/** 本地/自研设备 */
|
||||||
|
LOCAL("local");
|
||||||
|
|
||||||
|
private final String code;
|
||||||
|
|
||||||
|
DevicePlatform(String code) {
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 由后端平台码解析枚举 */
|
||||||
|
public static DevicePlatform fromCode(String code) {
|
||||||
|
if (code == null) return LOCAL;
|
||||||
|
for (DevicePlatform p : values()) {
|
||||||
|
if (p.code.equalsIgnoreCase(code)) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return LOCAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平台显示名称 */
|
||||||
|
public String getDisplayName() {
|
||||||
|
switch (this) {
|
||||||
|
case TUYA: return "涂鸦";
|
||||||
|
case GOOGLE_HOME: return "Google Home";
|
||||||
|
case HUAWEI: return "华为智联";
|
||||||
|
case XIAOMI: return "小米米家";
|
||||||
|
case DUEROS: return "小度";
|
||||||
|
case MATTER: return "Matter";
|
||||||
|
default: return "本地";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
51
Android/app/src/main/java/com/xhai/iot/model/LoginData.java
Normal file
51
Android/app/src/main/java/com/xhai/iot/model/LoginData.java
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录接口返回的 data 结构
|
||||||
|
*/
|
||||||
|
public class LoginData {
|
||||||
|
|
||||||
|
@SerializedName("token")
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
/** 刷新令牌:access token 过期后用它静默换取新 token(30天有效) */
|
||||||
|
@SerializedName("refreshToken")
|
||||||
|
private String refreshToken;
|
||||||
|
|
||||||
|
@SerializedName("memberId")
|
||||||
|
private long memberId;
|
||||||
|
|
||||||
|
@SerializedName("username")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@SerializedName("nickname")
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
@SerializedName("avatar")
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
@SerializedName("phone")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
public String getToken() { return token; }
|
||||||
|
public void setToken(String token) { this.token = token; }
|
||||||
|
public String getRefreshToken() { return refreshToken; }
|
||||||
|
public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
|
||||||
|
|
||||||
|
public long getMemberId() { return memberId; }
|
||||||
|
public void setMemberId(long memberId) { this.memberId = memberId; }
|
||||||
|
|
||||||
|
public String getUsername() { return username; }
|
||||||
|
public void setUsername(String username) { this.username = username; }
|
||||||
|
|
||||||
|
public String getNickname() { return nickname; }
|
||||||
|
public void setNickname(String nickname) { this.nickname = nickname; }
|
||||||
|
|
||||||
|
public String getAvatar() { return avatar; }
|
||||||
|
public void setAvatar(String avatar) { this.avatar = avatar; }
|
||||||
|
|
||||||
|
public String getPhone() { return phone; }
|
||||||
|
public void setPhone(String phone) { this.phone = phone; }
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
/** 后端 /api/v1/tuya/config/matter-token 返回的 Matter Fabric 凭证 */
|
||||||
|
public class MatterTokenResp {
|
||||||
|
/** 涂鸦云 Matter Fabric 配网 token */
|
||||||
|
public String token;
|
||||||
|
/** Fabric / space 标识(一般为数字字符串,作为 commissioning 的 spaceId) */
|
||||||
|
public String uuid;
|
||||||
|
}
|
||||||
41
Android/app/src/main/java/com/xhai/iot/model/PageData.java
Normal file
41
Android/app/src/main/java/com/xhai/iot/model/PageData.java
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页数据模型(对应 MyBatis-Plus Page)
|
||||||
|
*/
|
||||||
|
public class PageData<T> {
|
||||||
|
|
||||||
|
@SerializedName("records")
|
||||||
|
private List<T> records;
|
||||||
|
|
||||||
|
@SerializedName("total")
|
||||||
|
private long total;
|
||||||
|
|
||||||
|
@SerializedName("current")
|
||||||
|
private long current;
|
||||||
|
|
||||||
|
@SerializedName("size")
|
||||||
|
private long size;
|
||||||
|
|
||||||
|
@SerializedName("pages")
|
||||||
|
private long pages;
|
||||||
|
|
||||||
|
public List<T> getRecords() { return records; }
|
||||||
|
public void setRecords(List<T> records) { this.records = records; }
|
||||||
|
|
||||||
|
public long getTotal() { return total; }
|
||||||
|
public void setTotal(long total) { this.total = total; }
|
||||||
|
|
||||||
|
public long getCurrent() { return current; }
|
||||||
|
public void setCurrent(long current) { this.current = current; }
|
||||||
|
|
||||||
|
public long getSize() { return size; }
|
||||||
|
public void setSize(long size) { this.size = size; }
|
||||||
|
|
||||||
|
public long getPages() { return pages; }
|
||||||
|
public void setPages(long pages) { this.pages = pages; }
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定结果(POST /api/v1/member/panels/bind)
|
||||||
|
* { panelId, serialNumber, homeId, memberId, role, boundAt }
|
||||||
|
*/
|
||||||
|
public class PanelBindResult {
|
||||||
|
@SerializedName("panelId")
|
||||||
|
private Long panelId;
|
||||||
|
@SerializedName("serialNumber")
|
||||||
|
private String serialNumber;
|
||||||
|
@SerializedName("homeId")
|
||||||
|
private Long homeId;
|
||||||
|
@SerializedName("memberId")
|
||||||
|
private Long memberId;
|
||||||
|
@SerializedName("role")
|
||||||
|
private String role;
|
||||||
|
@SerializedName("boundAt")
|
||||||
|
private String boundAt;
|
||||||
|
|
||||||
|
public Long getPanelId() { return panelId; }
|
||||||
|
public String getSerialNumber() { return serialNumber; }
|
||||||
|
public Long getHomeId() { return homeId; }
|
||||||
|
public Long getMemberId() { return memberId; }
|
||||||
|
public String getRole() { return role; }
|
||||||
|
public String getBoundAt() { return boundAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫码预检返回(GET /api/v1/member/panels/bindable)
|
||||||
|
* { exists, enabled, bound, panelId }
|
||||||
|
*/
|
||||||
|
public class PanelBindable {
|
||||||
|
@SerializedName("exists")
|
||||||
|
private boolean exists;
|
||||||
|
@SerializedName("enabled")
|
||||||
|
private boolean enabled;
|
||||||
|
@SerializedName("bound")
|
||||||
|
private boolean bound;
|
||||||
|
@SerializedName("panelId")
|
||||||
|
private Long panelId;
|
||||||
|
|
||||||
|
public boolean isExists() { return exists; }
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public boolean isBound() { return bound; }
|
||||||
|
public Long getPanelId() { return panelId; }
|
||||||
|
}
|
||||||
38
Android/app/src/main/java/com/xhai/iot/model/PanelInfo.java
Normal file
38
Android/app/src/main/java/com/xhai/iot/model/PanelInfo.java
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已绑定的中控屏条目(GET /api/v1/member/panels)
|
||||||
|
* { panelId, serialNumber, homeId, memberId, role, enabled, online, model, lastSeenAt }
|
||||||
|
*/
|
||||||
|
public class PanelInfo {
|
||||||
|
@SerializedName("panelId")
|
||||||
|
private Long panelId;
|
||||||
|
@SerializedName("serialNumber")
|
||||||
|
private String serialNumber;
|
||||||
|
@SerializedName("homeId")
|
||||||
|
private Long homeId;
|
||||||
|
@SerializedName("memberId")
|
||||||
|
private Long memberId;
|
||||||
|
@SerializedName("role")
|
||||||
|
private String role;
|
||||||
|
@SerializedName("enabled")
|
||||||
|
private boolean enabled;
|
||||||
|
@SerializedName("online")
|
||||||
|
private boolean online;
|
||||||
|
@SerializedName("model")
|
||||||
|
private String model;
|
||||||
|
@SerializedName("lastSeenAt")
|
||||||
|
private String lastSeenAt;
|
||||||
|
|
||||||
|
public Long getPanelId() { return panelId; }
|
||||||
|
public String getSerialNumber() { return serialNumber; }
|
||||||
|
public Long getHomeId() { return homeId; }
|
||||||
|
public Long getMemberId() { return memberId; }
|
||||||
|
public String getRole() { return role; }
|
||||||
|
public boolean isEnabled() { return enabled; }
|
||||||
|
public boolean isOnline() { return online; }
|
||||||
|
public String getModel() { return model; }
|
||||||
|
public String getLastSeenAt() { return lastSeenAt; }
|
||||||
|
}
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一智能设备模型
|
||||||
|
* 聚合多平台(涂鸦/谷歌/华为/小米等)设备,用于设备列表聚合展示
|
||||||
|
*/
|
||||||
|
public class PlatformDevice {
|
||||||
|
|
||||||
|
/** 设备唯一ID(平台内唯一) */
|
||||||
|
private String deviceId;
|
||||||
|
/** 设备名称 */
|
||||||
|
private String name;
|
||||||
|
/** 所属平台 */
|
||||||
|
private DevicePlatform platform;
|
||||||
|
/** 是否在线 */
|
||||||
|
private boolean online;
|
||||||
|
/** 设备类型 */
|
||||||
|
private String category;
|
||||||
|
/** 产品ID */
|
||||||
|
private String productId;
|
||||||
|
/** 图标URL */
|
||||||
|
private String iconUrl;
|
||||||
|
/** 本平台家庭ID */
|
||||||
|
private long homeId;
|
||||||
|
/** 家庭名称 */
|
||||||
|
private String homeName;
|
||||||
|
|
||||||
|
public PlatformDevice() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDeviceId() { return deviceId; }
|
||||||
|
public void setDeviceId(String deviceId) { this.deviceId = deviceId; }
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public DevicePlatform getPlatform() { return platform; }
|
||||||
|
public void setPlatform(DevicePlatform platform) { this.platform = platform; }
|
||||||
|
|
||||||
|
public boolean isOnline() { return online; }
|
||||||
|
public void setOnline(boolean online) { this.online = online; }
|
||||||
|
|
||||||
|
public String getCategory() { return category; }
|
||||||
|
public void setCategory(String category) { this.category = category; }
|
||||||
|
|
||||||
|
public String getProductId() { return productId; }
|
||||||
|
public void setProductId(String productId) { this.productId = productId; }
|
||||||
|
|
||||||
|
public String getIconUrl() { return iconUrl; }
|
||||||
|
public void setIconUrl(String iconUrl) { this.iconUrl = iconUrl; }
|
||||||
|
|
||||||
|
public long getHomeId() { return homeId; }
|
||||||
|
public void setHomeId(long homeId) { this.homeId = homeId; }
|
||||||
|
|
||||||
|
public String getHomeName() { return homeName; }
|
||||||
|
public void setHomeName(String homeName) { this.homeName = homeName; }
|
||||||
|
}
|
||||||
39
Android/app/src/main/java/com/xhai/iot/model/Result.java
Normal file
39
Android/app/src/main/java/com/xhai/iot/model/Result.java
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端统一响应结构
|
||||||
|
* { code, message, data, timestamp }
|
||||||
|
*/
|
||||||
|
public class Result<T> {
|
||||||
|
|
||||||
|
@SerializedName("code")
|
||||||
|
private int code;
|
||||||
|
|
||||||
|
@SerializedName("message")
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
@SerializedName("data")
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
@SerializedName("timestamp")
|
||||||
|
private long timestamp;
|
||||||
|
|
||||||
|
public int getCode() { return code; }
|
||||||
|
public void setCode(int code) { this.code = code; }
|
||||||
|
|
||||||
|
public String getMessage() { return message; }
|
||||||
|
public void setMessage(String message) { this.message = message; }
|
||||||
|
|
||||||
|
public T getData() { return data; }
|
||||||
|
public void setData(T data) { this.data = data; }
|
||||||
|
|
||||||
|
public long getTimestamp() { return timestamp; }
|
||||||
|
public void setTimestamp(long timestamp) { this.timestamp = timestamp; }
|
||||||
|
|
||||||
|
/** 后端 code=200 或 code=0 视为成功 */
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return code == 200 || code == 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
63
Android/app/src/main/java/com/xhai/iot/model/SceneItem.java
Normal file
63
Android/app/src/main/java/com/xhai/iot/model/SceneItem.java
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景数据模型(对应后端 SceneEntity)
|
||||||
|
*/
|
||||||
|
public class SceneItem {
|
||||||
|
|
||||||
|
@SerializedName("id")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@SerializedName("sceneName")
|
||||||
|
private String sceneName;
|
||||||
|
|
||||||
|
@SerializedName("sceneCode")
|
||||||
|
private String sceneCode;
|
||||||
|
|
||||||
|
@SerializedName("sceneType")
|
||||||
|
private String sceneType;
|
||||||
|
|
||||||
|
@SerializedName("icon")
|
||||||
|
private String icon;
|
||||||
|
|
||||||
|
@SerializedName("description")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@SerializedName("enabled")
|
||||||
|
private Boolean enabled;
|
||||||
|
|
||||||
|
@SerializedName("sortOrder")
|
||||||
|
private Integer sortOrder;
|
||||||
|
|
||||||
|
@SerializedName("ownerId")
|
||||||
|
private Long ownerId;
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public void setId(Long id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getSceneName() { return sceneName; }
|
||||||
|
public void setSceneName(String sceneName) { this.sceneName = sceneName; }
|
||||||
|
|
||||||
|
public String getSceneCode() { return sceneCode; }
|
||||||
|
public void setSceneCode(String sceneCode) { this.sceneCode = sceneCode; }
|
||||||
|
|
||||||
|
public String getSceneType() { return sceneType; }
|
||||||
|
public void setSceneType(String sceneType) { this.sceneType = sceneType; }
|
||||||
|
|
||||||
|
public String getIcon() { return icon; }
|
||||||
|
public void setIcon(String icon) { this.icon = icon; }
|
||||||
|
|
||||||
|
public String getDescription() { return description; }
|
||||||
|
public void setDescription(String description) { this.description = description; }
|
||||||
|
|
||||||
|
public Boolean getEnabled() { return enabled; }
|
||||||
|
public void setEnabled(Boolean enabled) { this.enabled = enabled; }
|
||||||
|
|
||||||
|
public Integer getSortOrder() { return sortOrder; }
|
||||||
|
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||||
|
|
||||||
|
public Long getOwnerId() { return ownerId; }
|
||||||
|
public void setOwnerId(Long ownerId) { this.ownerId = ownerId; }
|
||||||
|
}
|
||||||
48
Android/app/src/main/java/com/xhai/iot/model/SpeechAuth.java
Normal file
48
Android/app/src/main/java/com/xhai/iot/model/SpeechAuth.java
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 后端 GET /api/v1/ai/speech/auth 的响应体(data 部分)
|
||||||
|
*
|
||||||
|
* <p>安全设计:APISecret 只留在服务端,App 领取带签名的临时 wss 地址后直连讯飞;
|
||||||
|
* enabled=false 表示后台未启用讯飞语音听写,App 应回退系统识别 / 键盘输入。</p>
|
||||||
|
*/
|
||||||
|
public class SpeechAuth {
|
||||||
|
|
||||||
|
/** 讯飞语音听写是否已启用 */
|
||||||
|
@SerializedName("enabled")
|
||||||
|
private boolean enabled;
|
||||||
|
|
||||||
|
/** 讯飞 APPID(WebSocket 首帧 common.app_id 使用) */
|
||||||
|
@SerializedName("appId")
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
/** 带 HMAC-SHA256 签名的 wss 地址(签名时效约 5 分钟,领取后请立即使用) */
|
||||||
|
@SerializedName("url")
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
public boolean isEnabled() {
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEnabled(boolean enabled) {
|
||||||
|
this.enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAppId() {
|
||||||
|
return appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAppId(String appId) {
|
||||||
|
this.appId = appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUrl(String url) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
Android/app/src/main/java/com/xhai/iot/model/TuyaDevice.java
Normal file
101
Android/app/src/main/java/com/xhai/iot/model/TuyaDevice.java
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦设备信息
|
||||||
|
*/
|
||||||
|
public class TuyaDevice {
|
||||||
|
|
||||||
|
@SerializedName("id")
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@SerializedName("name")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@SerializedName("online")
|
||||||
|
private boolean online;
|
||||||
|
|
||||||
|
@SerializedName("productId")
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
@SerializedName("productName")
|
||||||
|
private String productName;
|
||||||
|
|
||||||
|
@SerializedName("category")
|
||||||
|
private String category;
|
||||||
|
|
||||||
|
@SerializedName("icon")
|
||||||
|
private String icon;
|
||||||
|
|
||||||
|
@SerializedName("status")
|
||||||
|
private List<Map<String, Object>> status;
|
||||||
|
|
||||||
|
@SerializedName("bound")
|
||||||
|
private boolean bound;
|
||||||
|
|
||||||
|
@SerializedName("localDeviceId")
|
||||||
|
private Long localDeviceId;
|
||||||
|
|
||||||
|
@SerializedName("platform")
|
||||||
|
private String platform;
|
||||||
|
|
||||||
|
/** 设备实时 DP 状态(code → value),用于卡片开关同步显示 */
|
||||||
|
@SerializedName("dps")
|
||||||
|
private Map<String, Object> dps;
|
||||||
|
|
||||||
|
/** 父网关设备ID(本平台维护"网关→子设备"关系) */
|
||||||
|
@SerializedName("parentDeviceId")
|
||||||
|
private String parentDeviceId;
|
||||||
|
|
||||||
|
/** 是否为网关 */
|
||||||
|
@SerializedName("isGateway")
|
||||||
|
private Integer isGateway;
|
||||||
|
|
||||||
|
public String getId() { return id; }
|
||||||
|
public void setId(String id) { this.id = id; }
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public boolean isOnline() { return online; }
|
||||||
|
public void setOnline(boolean online) { this.online = online; }
|
||||||
|
|
||||||
|
public String getProductId() { return productId; }
|
||||||
|
public void setProductId(String productId) { this.productId = productId; }
|
||||||
|
|
||||||
|
public String getProductName() { return productName; }
|
||||||
|
public void setProductName(String productName) { this.productName = productName; }
|
||||||
|
|
||||||
|
public String getCategory() { return category; }
|
||||||
|
public void setCategory(String category) { this.category = category; }
|
||||||
|
|
||||||
|
public String getIcon() { return icon; }
|
||||||
|
public void setIcon(String icon) { this.icon = icon; }
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getStatus() { return status; }
|
||||||
|
public void setStatus(List<Map<String, Object>> status) { this.status = status; }
|
||||||
|
|
||||||
|
public boolean isBound() { return bound; }
|
||||||
|
public void setBound(boolean bound) { this.bound = bound; }
|
||||||
|
|
||||||
|
public Long getLocalDeviceId() { return localDeviceId; }
|
||||||
|
public void setLocalDeviceId(Long localDeviceId) { this.localDeviceId = localDeviceId; }
|
||||||
|
|
||||||
|
public String getPlatform() { return platform; }
|
||||||
|
public void setPlatform(String platform) { this.platform = platform; }
|
||||||
|
|
||||||
|
public Map<String, Object> getDps() { return dps; }
|
||||||
|
public void setDps(Map<String, Object> dps) { this.dps = dps; }
|
||||||
|
|
||||||
|
public String getParentDeviceId() { return parentDeviceId; }
|
||||||
|
public void setParentDeviceId(String parentDeviceId) { this.parentDeviceId = parentDeviceId; }
|
||||||
|
|
||||||
|
public Integer getIsGateway() { return isGateway; }
|
||||||
|
public void setIsGateway(Integer isGateway) { this.isGateway = isGateway; }
|
||||||
|
|
||||||
|
public boolean isGateway() { return isGateway != null && isGateway == 1; }
|
||||||
|
}
|
||||||
39
Android/app/src/main/java/com/xhai/iot/model/TuyaHome.java
Normal file
39
Android/app/src/main/java/com/xhai/iot/model/TuyaHome.java
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
package com.xhai.iot.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦家庭信息
|
||||||
|
*/
|
||||||
|
public class TuyaHome {
|
||||||
|
|
||||||
|
@SerializedName("homeId")
|
||||||
|
private long homeId;
|
||||||
|
|
||||||
|
@SerializedName("name")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@SerializedName("geoName")
|
||||||
|
private String geoName;
|
||||||
|
|
||||||
|
@SerializedName("role")
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
@SerializedName("deviceCount")
|
||||||
|
private int deviceCount;
|
||||||
|
|
||||||
|
public long getHomeId() { return homeId; }
|
||||||
|
public void setHomeId(long homeId) { this.homeId = homeId; }
|
||||||
|
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
|
||||||
|
public String getGeoName() { return geoName; }
|
||||||
|
public void setGeoName(String geoName) { this.geoName = geoName; }
|
||||||
|
|
||||||
|
public String getRole() { return role; }
|
||||||
|
public void setRole(String role) { this.role = role; }
|
||||||
|
|
||||||
|
public int getDeviceCount() { return deviceCount; }
|
||||||
|
public void setDeviceCount(int deviceCount) { this.deviceCount = deviceCount; }
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
package com.xhai.iot.service;
|
||||||
|
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.thingclips.smart.api.MicroContext;
|
||||||
|
import com.thingclips.smart.commonbiz.bizbundle.family.api.AbsBizBundleFamilyService;
|
||||||
|
import com.thingclips.smart.commonbiz.relation.api.AbsRelationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦 UI BizBundle 家庭服务实现。
|
||||||
|
* 面板(Panel)等 BizBundle 需要该服务来获取当前家庭。
|
||||||
|
* <p>
|
||||||
|
* 重要:除了 AbsBizBundleFamilyService,涂鸦面板内部(gotoRNActivity)还会读取
|
||||||
|
* AbsRelationService.getCurrentGid(),若 gid==0 会静默 return 不打开面板。
|
||||||
|
* 因此 {@link #updateCurrentHomeId(long, String)} 会同时同步这两个服务。
|
||||||
|
*/
|
||||||
|
public class BizBundleFamilyServiceImpl extends AbsBizBundleFamilyService {
|
||||||
|
|
||||||
|
private static final String TAG = "BizBundleFamily";
|
||||||
|
|
||||||
|
private static volatile long sCurrentHomeId;
|
||||||
|
private static volatile String sCurrentHomeName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 供 App 内其它模块同步当前家庭。
|
||||||
|
* 同时同步 RelationService 的 gid(面板跳转依赖,缺失会导致面板静默打不开)。
|
||||||
|
*/
|
||||||
|
public static void updateCurrentHomeId(long homeId, String homeName) {
|
||||||
|
sCurrentHomeId = homeId;
|
||||||
|
sCurrentHomeName = homeName;
|
||||||
|
try {
|
||||||
|
AbsRelationService relationService = MicroContext.getServiceManager()
|
||||||
|
.findServiceByInterface(AbsRelationService.class.getName());
|
||||||
|
if (relationService != null) {
|
||||||
|
relationService.shiftCurrentRelation(homeId, homeName != null ? homeName : "");
|
||||||
|
Log.i(TAG, "RelationService gid 已同步: " + homeId);
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "AbsRelationService 未注册,面板可能无法打开");
|
||||||
|
}
|
||||||
|
} catch (Throwable t) {
|
||||||
|
Log.e(TAG, "同步 RelationService 失败: " + t.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兼容旧调用(仅 homeId) */
|
||||||
|
public static void updateCurrentHomeId(long homeId) {
|
||||||
|
updateCurrentHomeId(homeId, sCurrentHomeName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前同步的涂鸦家庭 gid(静态,供面板路由判断是否需要同步) */
|
||||||
|
public static long getCurrentGid() {
|
||||||
|
return sCurrentHomeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getCurrentHomeId() {
|
||||||
|
return sCurrentHomeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void shiftCurrentFamily(long familyId, String curName) {
|
||||||
|
super.shiftCurrentFamily(familyId, curName);
|
||||||
|
sCurrentHomeId = familyId;
|
||||||
|
sCurrentHomeName = curName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package com.xhai.iot.service;
|
||||||
|
|
||||||
|
import com.xhai.iot.model.DevicePlatform;
|
||||||
|
import com.xhai.iot.model.PlatformDevice;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能家居平台服务接口
|
||||||
|
* 每个平台(涂鸦/谷歌/华为/小米等)实现此接口,提供统一的能力抽象。
|
||||||
|
* 设计目标:App 端通过 PlatformServiceManager 统一调用,实现多平台设备聚合接入。
|
||||||
|
*/
|
||||||
|
public interface IPlatformService {
|
||||||
|
|
||||||
|
/** 当前服务对应的平台 */
|
||||||
|
DevicePlatform getPlatform();
|
||||||
|
|
||||||
|
/** 该平台是否已绑定账号 */
|
||||||
|
boolean isBound();
|
||||||
|
|
||||||
|
/** 获取平台家庭列表 */
|
||||||
|
List<PlatformHomeInfo> getHomes();
|
||||||
|
|
||||||
|
/** 获取指定家庭下的设备列表 */
|
||||||
|
List<PlatformDevice> getDevices(long homeId);
|
||||||
|
|
||||||
|
/** 获取该平台所有设备(跨家庭聚合) */
|
||||||
|
List<PlatformDevice> getAllDevices();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下发设备控制指令
|
||||||
|
* @param deviceId 设备ID
|
||||||
|
* @param commands 指令(dp/属性映射,如 {"1": true} 或 {"power": "on"})
|
||||||
|
*/
|
||||||
|
void controlDevice(String deviceId, Map<String, Object> commands,
|
||||||
|
ControlCallback callback);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解绑平台账号
|
||||||
|
*/
|
||||||
|
void unbind();
|
||||||
|
|
||||||
|
/** 简单家庭信息 */
|
||||||
|
class PlatformHomeInfo {
|
||||||
|
public long homeId;
|
||||||
|
public String name;
|
||||||
|
public int deviceCount;
|
||||||
|
|
||||||
|
public PlatformHomeInfo(long homeId, String name, int deviceCount) {
|
||||||
|
this.homeId = homeId;
|
||||||
|
this.name = name;
|
||||||
|
this.deviceCount = deviceCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 控制结果回调 */
|
||||||
|
interface ControlCallback {
|
||||||
|
void onSuccess();
|
||||||
|
void onError(String code, String error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,159 @@
|
|||||||
|
package com.xhai.iot.service;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.DevicePlatform;
|
||||||
|
import com.xhai.iot.model.PlatformDevice;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 平台服务管理器
|
||||||
|
* 统一管理各平台服务实例,向 UI 层提供多平台设备聚合能力。
|
||||||
|
* 新增平台时:在 register 中注册对应 IPlatformService 实现即可。
|
||||||
|
*/
|
||||||
|
public class PlatformServiceManager {
|
||||||
|
|
||||||
|
private static final String TAG = "PlatformServiceManager";
|
||||||
|
|
||||||
|
private static PlatformServiceManager instance;
|
||||||
|
|
||||||
|
private final Map<DevicePlatform, IPlatformService> services = new LinkedHashMap<>();
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
private PlatformServiceManager(Context context) {
|
||||||
|
this.context = context.getApplicationContext();
|
||||||
|
register(new TuyaPlatformService(this.context));
|
||||||
|
// 后续新增平台在此注册:
|
||||||
|
// register(new GooglePlatformService(this.context));
|
||||||
|
// register(new HuaweiPlatformService(this.context));
|
||||||
|
// register(new XiaomiPlatformService(this.context));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized PlatformServiceManager getInstance(Context context) {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new PlatformServiceManager(context);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 注册平台服务 */
|
||||||
|
public void register(IPlatformService service) {
|
||||||
|
services.put(service.getPlatform(), service);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取指定平台服务 */
|
||||||
|
public IPlatformService getService(DevicePlatform platform) {
|
||||||
|
return services.get(platform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有已注册平台服务 */
|
||||||
|
public List<IPlatformService> getAllServices() {
|
||||||
|
return new ArrayList<>(services.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有已绑定平台的服务 */
|
||||||
|
public List<IPlatformService> getBoundServices() {
|
||||||
|
List<IPlatformService> bound = new ArrayList<>();
|
||||||
|
for (IPlatformService s : services.values()) {
|
||||||
|
if (s.isBound()) {
|
||||||
|
bound.add(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合所有已绑定平台的设备 */
|
||||||
|
public List<PlatformDevice> getAllBoundDevices() {
|
||||||
|
List<PlatformDevice> result = new ArrayList<>();
|
||||||
|
for (IPlatformService s : getBoundServices()) {
|
||||||
|
try {
|
||||||
|
List<PlatformDevice> devices = s.getAllDevices();
|
||||||
|
if (devices != null) {
|
||||||
|
result.addAll(devices);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 单个平台异常不影响其他平台
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下发控制指令(自动按设备平台分发) */
|
||||||
|
public void controlDevice(PlatformDevice device, Map<String, Object> commands,
|
||||||
|
IPlatformService.ControlCallback callback) {
|
||||||
|
IPlatformService service = getService(device.getPlatform());
|
||||||
|
if (service == null) {
|
||||||
|
if (callback != null) {
|
||||||
|
callback.onError("unsupported", "不支持的设备平台: " + device.getPlatform());
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 统一在成功回调里上报控制动态:APP 走 SDK 直连不经后端,
|
||||||
|
// 不加这一层的话首页「控制记录/近期动态」永远为空(所有控制入口都经过这里)
|
||||||
|
service.controlDevice(device.getDeviceId(), commands,
|
||||||
|
new IPlatformService.ControlCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
reportActivity(device, commands);
|
||||||
|
if (callback != null) callback.onSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
if (callback != null) callback.onError(code, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上报控制动态到后端(失败静默,不影响控制) */
|
||||||
|
private void reportActivity(PlatformDevice device, Map<String, Object> commands) {
|
||||||
|
try {
|
||||||
|
Context ctx = context;
|
||||||
|
if (ctx == null || device == null) return;
|
||||||
|
String auth = SessionManager.getInstance(ctx).getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
String action = "adjust";
|
||||||
|
for (Map.Entry<String, Object> en : commands.entrySet()) {
|
||||||
|
if (en.getValue() instanceof Boolean) {
|
||||||
|
action = ((Boolean) en.getValue()) ? "switch_on" : "switch_off";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String name = device.getName() != null ? device.getName() : "设备";
|
||||||
|
String desc;
|
||||||
|
if ("switch_on".equals(action)) desc = "打开 " + name;
|
||||||
|
else if ("switch_off".equals(action)) desc = "关闭 " + name;
|
||||||
|
else desc = "调节 " + name;
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("action", action);
|
||||||
|
body.put("actionDesc", desc);
|
||||||
|
body.put("detail", commands);
|
||||||
|
body.put("commandId", java.util.UUID.randomUUID().toString());
|
||||||
|
ApiClient.getApiService()
|
||||||
|
.reportDeviceActivity(auth, device.getDeviceId(), body)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<Void>> call,
|
||||||
|
retrofit2.Response<Result<Void>> response) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
|
||||||
|
Log.w(TAG, "上报控制动态失败: " + t.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "上报控制动态异常: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
package com.xhai.iot.service;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import com.thingclips.smart.android.blemesh.api.IThingBlueMeshDevice;
|
||||||
|
import com.thingclips.smart.home.sdk.ThingHomeSdk;
|
||||||
|
import com.thingclips.smart.sdk.api.IResultCallback;
|
||||||
|
import com.thingclips.smart.sdk.api.IThingDevice;
|
||||||
|
import com.thingclips.smart.sdk.bean.DeviceBean;
|
||||||
|
import com.thingclips.sdk.os.ThingOSMesh;
|
||||||
|
import com.xhai.iot.model.DevicePlatform;
|
||||||
|
import com.xhai.iot.model.PlatformDevice;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦平台服务实现
|
||||||
|
* 基于涂鸦 thingsmart SDK,实现 IPlatformService 统一接口。
|
||||||
|
* 这是多平台架构的第一个实现,后续谷歌/华为/小米按同样接口扩展。
|
||||||
|
*/
|
||||||
|
public class TuyaPlatformService implements IPlatformService {
|
||||||
|
|
||||||
|
private static final String TAG = "TuyaPlatformService";
|
||||||
|
|
||||||
|
private final Context context;
|
||||||
|
|
||||||
|
public TuyaPlatformService(Context context) {
|
||||||
|
this.context = context.getApplicationContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public DevicePlatform getPlatform() {
|
||||||
|
return DevicePlatform.TUYA;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isBound() {
|
||||||
|
return SessionManager.getInstance(context).isTuyaBound();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PlatformHomeInfo> getHomes() {
|
||||||
|
List<PlatformHomeInfo> result = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
ThingHomeSdk.getHomeManagerInstance().queryHomeList(
|
||||||
|
new com.thingclips.smart.home.sdk.callback.IThingGetHomeListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(
|
||||||
|
List<com.thingclips.smart.home.sdk.bean.HomeBean> homes) {
|
||||||
|
if (homes != null) {
|
||||||
|
for (com.thingclips.smart.home.sdk.bean.HomeBean home : homes) {
|
||||||
|
int count = home.getDeviceList() != null
|
||||||
|
? home.getDeviceList().size() : 0;
|
||||||
|
result.add(new PlatformHomeInfo(
|
||||||
|
home.getHomeId(), home.getName(), count));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
Log.e(TAG, "获取家庭列表失败: " + code + " " + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "获取家庭列表异常", e);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PlatformDevice> getDevices(long homeId) {
|
||||||
|
List<PlatformDevice> result = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
com.thingclips.smart.home.sdk.api.IThingHome home =
|
||||||
|
ThingHomeSdk.newHomeInstance(homeId);
|
||||||
|
com.thingclips.smart.home.sdk.bean.HomeBean homeBean = home.getHomeBean();
|
||||||
|
if (homeBean != null && homeBean.getDeviceList() != null) {
|
||||||
|
for (DeviceBean dev : homeBean.getDeviceList()) {
|
||||||
|
result.add(toPlatformDevice(dev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "获取设备列表异常", e);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<PlatformDevice> getAllDevices() {
|
||||||
|
List<PlatformDevice> result = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
// 遍历所有家庭,聚合设备
|
||||||
|
List<PlatformHomeInfo> homes = getHomes();
|
||||||
|
for (PlatformHomeInfo home : homes) {
|
||||||
|
List<DeviceBean> devices =
|
||||||
|
ThingHomeSdk.getDataInstance().getHomeDeviceList(home.homeId);
|
||||||
|
if (devices != null) {
|
||||||
|
for (DeviceBean dev : devices) {
|
||||||
|
PlatformDevice pd = toPlatformDevice(dev);
|
||||||
|
pd.setHomeId(home.homeId);
|
||||||
|
pd.setHomeName(home.name);
|
||||||
|
result.add(pd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "获取全部设备异常", e);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private PlatformDevice toPlatformDevice(DeviceBean dev) {
|
||||||
|
PlatformDevice pd = new PlatformDevice();
|
||||||
|
pd.setDeviceId(dev.getDevId());
|
||||||
|
pd.setName(dev.getName());
|
||||||
|
pd.setPlatform(DevicePlatform.TUYA);
|
||||||
|
pd.setOnline(Boolean.TRUE.equals(dev.getIsOnline()));
|
||||||
|
pd.setCategory(dev.getCategory());
|
||||||
|
pd.setProductId(dev.getProductId());
|
||||||
|
pd.setIconUrl(dev.getIconUrl());
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void controlDevice(String deviceId, Map<String, Object> commands,
|
||||||
|
ControlCallback callback) {
|
||||||
|
try {
|
||||||
|
DeviceBean dev = ThingHomeSdk.getDataInstance().getDeviceBean(deviceId);
|
||||||
|
// 蓝牙 Mesh 子设备:走 Mesh 本地控制通道(云端 publishDps 对离线 Mesh 子设备会失败)
|
||||||
|
if (dev != null && dev.isSigMesh()) {
|
||||||
|
controlSigMeshDevice(dev, commands, callback);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 普通设备:云端 publishDps(统一把 DP 数字 id 转换为涂鸦 code)
|
||||||
|
IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId);
|
||||||
|
Map<String, Object> codeCommands =
|
||||||
|
TuyaSdkService.convertDpIdToCode(deviceId, commands);
|
||||||
|
String json = JSONObject.toJSONString(codeCommands);
|
||||||
|
device.publishDps(json, new IResultCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
if (callback != null) callback.onSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
if (callback != null) callback.onError(code, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
if (callback != null) callback.onError("ex", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 控制蓝牙 Mesh 子设备(走 Mesh 本地通道) */
|
||||||
|
private void controlSigMeshDevice(DeviceBean dev, Map<String, Object> commands,
|
||||||
|
ControlCallback callback) {
|
||||||
|
try {
|
||||||
|
String meshId = dev.getMeshId();
|
||||||
|
String nodeId = dev.getNodeId();
|
||||||
|
String category = dev.getCategory() != null ? dev.getCategory() : dev.getCategoryCode();
|
||||||
|
// Mesh publishCommands 需要的是涂鸦 DP code("switch_1"),不是数字 id("1")
|
||||||
|
Map<String, Object> codeCommands =
|
||||||
|
TuyaSdkService.convertDpIdToCode(dev.getDevId(), commands);
|
||||||
|
Log.i(TAG, "Mesh子设备控制 meshId=" + meshId + " nodeId=" + nodeId
|
||||||
|
+ " category=" + category
|
||||||
|
+ " cmd=" + codeCommands);
|
||||||
|
IThingBlueMeshDevice meshDev = ThingOSMesh.newDevice(meshId);
|
||||||
|
meshDev.publishCommands(nodeId, category, codeCommands, new IResultCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
Log.i(TAG, "Mesh控制成功 nodeId=" + nodeId);
|
||||||
|
if (callback != null) callback.onSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
Log.e(TAG, "Mesh控制失败 nodeId=" + nodeId + " code=" + code
|
||||||
|
+ " err=" + error);
|
||||||
|
if (callback != null) callback.onError(code, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Mesh控制异常: " + e.getMessage(), e);
|
||||||
|
if (callback != null) callback.onError("ex", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void unbind() {
|
||||||
|
SessionManager.getInstance(context).setTuyaBound(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
1420
Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java
Normal file
1420
Android/app/src/main/java/com/xhai/iot/service/TuyaSdkService.java
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,478 @@
|
|||||||
|
package com.xhai.iot.speech;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.media.AudioFormat;
|
||||||
|
import android.media.AudioRecord;
|
||||||
|
import android.media.MediaRecorder;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Base64;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.json.JSONArray;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.WebSocket;
|
||||||
|
import okhttp3.WebSocketListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讯飞「语音听写(流式版)」WebAPI 客户端。
|
||||||
|
*
|
||||||
|
* <p>链路:AudioRecord 采 16kHz/16bit/单声道 PCM → 按 40ms(1280 字节)分帧 base64
|
||||||
|
* 通过 WebSocket 推给 iat-api.xfyun.cn → 边收边通过 {@link Listener#onPartial} 回传中间结果,
|
||||||
|
* 服务端检测到停顿(eos)后返回 status=2,以 {@link Listener#onFinal} 通知最终文本。
|
||||||
|
*
|
||||||
|
* <p>所有回调都在主线程;使用完必须调用 {@link #release()} 释放录音与连接。
|
||||||
|
*
|
||||||
|
* <p>鉴权方式:调用方先通过后端 {@code GET /api/v1/ai/speech/auth} 领取带签名的临时
|
||||||
|
* wss 地址(APISecret 全程不出后端),再传入 {@link #start(String, String)}。
|
||||||
|
*/
|
||||||
|
public class XfyunIatClient {
|
||||||
|
|
||||||
|
/** 识别结果回调,均在主线程触发 */
|
||||||
|
public interface Listener {
|
||||||
|
/** 实时中间结果(动态修正后的完整文本) */
|
||||||
|
void onPartial(String text);
|
||||||
|
|
||||||
|
/** 最终结果;用户全程没说话时可能为空串 */
|
||||||
|
void onFinal(String text);
|
||||||
|
|
||||||
|
/** 失败原因,已是可直接展示给用户的文案 */
|
||||||
|
void onError(String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String TAG = "XfyunIat";
|
||||||
|
|
||||||
|
private static final int SAMPLE_RATE = 16000;
|
||||||
|
/** 16k / 16bit / 单声道下每 40ms 的字节数,官方建议的发送节奏 */
|
||||||
|
private static final int FRAME_BYTES = 1280;
|
||||||
|
/** 服务端静音检测时长,停顿超过该时长自动结束本轮识别 */
|
||||||
|
private static final int EOS_MS = 3000;
|
||||||
|
/** 握手超时 */
|
||||||
|
private static final long HANDSHAKE_TIMEOUT_MS = 10_000L;
|
||||||
|
/** 单次识别总时长上限(官方限制 60s),留出收尾余量 */
|
||||||
|
private static final long MAX_DURATION_MS = 55_000L;
|
||||||
|
/** 主动结束后等待服务端吐完结果的时间 */
|
||||||
|
private static final long FLUSH_TIMEOUT_MS = 2_000L;
|
||||||
|
|
||||||
|
private final Listener listener;
|
||||||
|
private final Handler main = new Handler(Looper.getMainLooper());
|
||||||
|
private final OkHttpClient client;
|
||||||
|
/** sn -> 该片段文本,用有序表按序拼接,并在动态修正(rpl)时按 rg 区间删除 */
|
||||||
|
private final TreeMap<Integer, String> segments = new TreeMap<>();
|
||||||
|
private final AtomicBoolean firstFrame = new AtomicBoolean(true);
|
||||||
|
private final AtomicBoolean finished = new AtomicBoolean(false);
|
||||||
|
|
||||||
|
/** 后端签发的讯飞 APPID(首帧 common.app_id 用) */
|
||||||
|
private String appId;
|
||||||
|
|
||||||
|
private WebSocket socket;
|
||||||
|
private AudioRecord recorder;
|
||||||
|
private Thread recordThread;
|
||||||
|
private volatile boolean recording;
|
||||||
|
private long startedAt;
|
||||||
|
|
||||||
|
private final Runnable handshakeTimeout = () -> finishWithError("连接讯飞超时,请检查网络后重试");
|
||||||
|
private final Runnable flushTimeout = () -> finishNormal();
|
||||||
|
private final Runnable maxDurationTimeout = () -> {
|
||||||
|
// 到达上限还没结束,按当前已识别内容收尾
|
||||||
|
if (!finished.get()) {
|
||||||
|
stop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public XfyunIatClient(Listener listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
this.client = new OkHttpClient.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
// WebSocket 是长连接,不能设读超时,否则空闲时会被断开
|
||||||
|
.readTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.writeTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始一轮识别(异步,结果通过 listener 回调)。
|
||||||
|
*
|
||||||
|
* @param appId 讯飞 APPID(后端签发)
|
||||||
|
* @param wssUrl 后端签发的带签名 wss 地址(时效约 5 分钟,领取后立即使用)
|
||||||
|
*/
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
public void start(String appId, String wssUrl) {
|
||||||
|
this.appId = appId;
|
||||||
|
if (wssUrl == null || wssUrl.trim().isEmpty()) {
|
||||||
|
finishWithError("未获取到语音服务鉴权地址,请稍后重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int minBuffer = AudioRecord.getMinBufferSize(SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO, AudioFormat.ENCODING_PCM_16BIT);
|
||||||
|
if (minBuffer <= 0) {
|
||||||
|
finishWithError("当前设备不支持 16kHz 录音,请改用文字输入");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
recorder = new AudioRecord(MediaRecorder.AudioSource.VOICE_RECOGNITION,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
Math.max(minBuffer, FRAME_BYTES * 4));
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "AudioRecord init failed", e);
|
||||||
|
finishWithError("录音初始化失败,请检查录音权限");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (recorder.getState() != AudioRecord.STATE_INITIALIZED) {
|
||||||
|
releaseRecorder();
|
||||||
|
finishWithError("录音初始化失败,请检查录音权限");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
startedAt = System.currentTimeMillis();
|
||||||
|
socket = client.newWebSocket(new Request.Builder().url(wssUrl).build(), new WsListener());
|
||||||
|
main.postDelayed(handshakeTimeout, HANDSHAKE_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主动结束本轮识别(用户再点一次麦克风,或到达时长上限)。
|
||||||
|
* 会先停止录音并通知服务端收尾,随后以已识别内容回调 onFinal。
|
||||||
|
*/
|
||||||
|
public void stop() {
|
||||||
|
if (finished.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
releaseRecorder();
|
||||||
|
WebSocket ws = socket;
|
||||||
|
if (ws != null) {
|
||||||
|
try {
|
||||||
|
JSONObject root = new JSONObject();
|
||||||
|
JSONObject data = new JSONObject();
|
||||||
|
data.put("status", 2);
|
||||||
|
data.put("format", "audio/L16;rate=" + SAMPLE_RATE);
|
||||||
|
data.put("encoding", "raw");
|
||||||
|
data.put("audio", "");
|
||||||
|
root.put("data", data);
|
||||||
|
ws.send(root.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "send empty tail frame failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 服务端正常会立刻回 status=2;万一没回,用当前结果收尾,避免一直卡在"聆听中"
|
||||||
|
main.postDelayed(flushTimeout, FLUSH_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 释放录音与网络资源,务必在 Activity.onDestroy 中调用 */
|
||||||
|
public void release() {
|
||||||
|
finished.set(true);
|
||||||
|
recording = false;
|
||||||
|
main.removeCallbacksAndMessages(null);
|
||||||
|
releaseRecorder();
|
||||||
|
closeSocket();
|
||||||
|
try {
|
||||||
|
client.connectionPool().evictAll();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== WebSocket ====================
|
||||||
|
|
||||||
|
private class WsListener extends WebSocketListener {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onOpen(@NonNull WebSocket webSocket, @NonNull Response response) {
|
||||||
|
if (finished.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
main.removeCallbacks(handshakeTimeout);
|
||||||
|
main.postDelayed(maxDurationTimeout, MAX_DURATION_MS);
|
||||||
|
startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMessage(@NonNull WebSocket webSocket, @NonNull String text) {
|
||||||
|
handleMessage(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(@NonNull WebSocket webSocket, @NonNull Throwable t,
|
||||||
|
@Nullable Response response) {
|
||||||
|
if (finished.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int code = response == null ? 0 : response.code();
|
||||||
|
String detail = t.getMessage() == null ? t.getClass().getSimpleName() : t.getMessage();
|
||||||
|
finishWithError("连接讯飞失败:" + detail + (code > 0 ? "(HTTP " + code + ")" : ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClosed(@NonNull WebSocket webSocket, int code, @NonNull String reason) {
|
||||||
|
finishNormal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleMessage(String text) {
|
||||||
|
try {
|
||||||
|
JSONObject root = new JSONObject(text);
|
||||||
|
int code = root.optInt("code", -1);
|
||||||
|
if (code != 0) {
|
||||||
|
finishWithError(friendlyError(code, root.optString("message", "")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject data = root.optJSONObject("data");
|
||||||
|
if (data == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject result = data.optJSONObject("result");
|
||||||
|
if (result != null) {
|
||||||
|
handleResult(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.optInt("status", 0) == 2) {
|
||||||
|
finishNormal();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "handleMessage failed: " + text, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResult(JSONObject result) {
|
||||||
|
int sn = result.optInt("sn", 0);
|
||||||
|
String pgs = result.optString("pgs", "");
|
||||||
|
|
||||||
|
// 动态修正:rpl 表示要替换 rg 区间内已经吐出的片段
|
||||||
|
if ("rpl".equals(pgs)) {
|
||||||
|
JSONArray rg = result.optJSONArray("rg");
|
||||||
|
if (rg != null && rg.length() >= 2) {
|
||||||
|
for (int i = rg.optInt(0); i <= rg.optInt(1); i++) {
|
||||||
|
segments.remove(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String piece = joinWords(result.optJSONArray("ws"));
|
||||||
|
if (!piece.isEmpty()) {
|
||||||
|
segments.put(sn, piece);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先取好快照再切回主线程,避免多线程读写同一集合
|
||||||
|
final String snapshot = joinSegments();
|
||||||
|
main.post(() -> {
|
||||||
|
if (!finished.get()) {
|
||||||
|
listener.onPartial(snapshot);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把 ws 数组里每个词的候选(取第一个)拼成本次片段文本 */
|
||||||
|
private String joinWords(JSONArray ws) {
|
||||||
|
if (ws == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (int i = 0; i < ws.length(); i++) {
|
||||||
|
JSONObject word = ws.optJSONObject(i);
|
||||||
|
if (word == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JSONArray candidates = word.optJSONArray("cw");
|
||||||
|
if (candidates == null || candidates.length() == 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
JSONObject first = candidates.optJSONObject(0);
|
||||||
|
if (first != null) {
|
||||||
|
sb.append(first.optString("w", ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String joinSegments() {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (Map.Entry<Integer, String> entry : segments.entrySet()) {
|
||||||
|
sb.append(entry.getValue());
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 录音发送 ====================
|
||||||
|
|
||||||
|
private void startRecording() {
|
||||||
|
AudioRecord target = recorder;
|
||||||
|
if (target == null) {
|
||||||
|
finishWithError("录音初始化失败,请检查录音权限");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
target.startRecording();
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "startRecording failed", e);
|
||||||
|
finishWithError("录音启动失败,请重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recording = true;
|
||||||
|
recordThread = new Thread(this::recordLoop, "xfyun-record");
|
||||||
|
recordThread.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordLoop() {
|
||||||
|
byte[] buffer = new byte[FRAME_BYTES];
|
||||||
|
while (recording && !finished.get()) {
|
||||||
|
AudioRecord target = recorder;
|
||||||
|
if (target == null) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
int read;
|
||||||
|
try {
|
||||||
|
read = target.read(buffer, 0, buffer.length);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "read failed", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (read <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
boolean last = System.currentTimeMillis() - startedAt >= MAX_DURATION_MS;
|
||||||
|
sendFrame(buffer, read, last);
|
||||||
|
if (last) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendFrame(byte[] data, int length, boolean last) {
|
||||||
|
WebSocket ws = socket;
|
||||||
|
if (ws == null || finished.get()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
boolean isFirst = firstFrame.compareAndSet(true, false);
|
||||||
|
try {
|
||||||
|
JSONObject root = new JSONObject();
|
||||||
|
if (isFirst) {
|
||||||
|
JSONObject common = new JSONObject();
|
||||||
|
common.put("app_id", appId);
|
||||||
|
|
||||||
|
JSONObject business = new JSONObject();
|
||||||
|
business.put("language", "zh_cn");
|
||||||
|
business.put("domain", "iat");
|
||||||
|
business.put("accent", "mandarin");
|
||||||
|
// wpgs:开启动态修正,边说边出字(配合上面的 rpl 处理)
|
||||||
|
business.put("dwa", "wpgs");
|
||||||
|
// 停顿多久算一句话结束
|
||||||
|
business.put("eos", EOS_MS);
|
||||||
|
// 数字转阿拉伯,便于后端解析"调到25度"这类指令
|
||||||
|
business.put("nunum", 1);
|
||||||
|
// 自动加标点,提升可读性
|
||||||
|
business.put("ptt", 1);
|
||||||
|
|
||||||
|
root.put("common", common);
|
||||||
|
root.put("business", business);
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject payload = new JSONObject();
|
||||||
|
payload.put("status", last ? 2 : (isFirst ? 0 : 1));
|
||||||
|
payload.put("format", "audio/L16;rate=" + SAMPLE_RATE);
|
||||||
|
payload.put("encoding", "raw");
|
||||||
|
payload.put("audio", Base64.encodeToString(data, 0, length, Base64.NO_WRAP));
|
||||||
|
root.put("data", payload);
|
||||||
|
|
||||||
|
ws.send(root.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.w(TAG, "sendFrame failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 收尾 ====================
|
||||||
|
|
||||||
|
private void finishNormal() {
|
||||||
|
if (!finished.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
main.removeCallbacks(handshakeTimeout);
|
||||||
|
main.removeCallbacks(flushTimeout);
|
||||||
|
main.removeCallbacks(maxDurationTimeout);
|
||||||
|
releaseRecorder();
|
||||||
|
closeSocket();
|
||||||
|
final String text = joinSegments();
|
||||||
|
main.post(() -> listener.onFinal(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishWithError(String message) {
|
||||||
|
if (!finished.compareAndSet(false, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
main.removeCallbacks(handshakeTimeout);
|
||||||
|
main.removeCallbacks(flushTimeout);
|
||||||
|
main.removeCallbacks(maxDurationTimeout);
|
||||||
|
releaseRecorder();
|
||||||
|
closeSocket();
|
||||||
|
main.post(() -> listener.onError(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void releaseRecorder() {
|
||||||
|
recording = false;
|
||||||
|
AudioRecord target = recorder;
|
||||||
|
recorder = null;
|
||||||
|
if (target == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (target.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING) {
|
||||||
|
target.stop();
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
target.release();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void closeSocket() {
|
||||||
|
WebSocket ws = socket;
|
||||||
|
socket = null;
|
||||||
|
if (ws != null) {
|
||||||
|
try {
|
||||||
|
ws.close(1000, "bye");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 讯飞错误码转成用户能看懂的提示 */
|
||||||
|
private String friendlyError(int code, String message) {
|
||||||
|
switch (code) {
|
||||||
|
case 10161:
|
||||||
|
return "讯飞鉴权失败,请检查 APIKey / APISecret,并确认手机时间准确";
|
||||||
|
case 10313:
|
||||||
|
return "讯飞 AppID 与 Key 不匹配,请核对凭证配置";
|
||||||
|
case 10202:
|
||||||
|
return "讯飞授权已失效,请重新申请";
|
||||||
|
case 11201:
|
||||||
|
return "讯飞识别额度已用尽,请改用文字输入";
|
||||||
|
case 10163:
|
||||||
|
case 10160:
|
||||||
|
return "讯飞请求参数错误,请核对音频参数与凭证";
|
||||||
|
case 10165:
|
||||||
|
return "识别会话超时,请重试";
|
||||||
|
case 10700:
|
||||||
|
return "讯飞识别引擎未初始化,请稍后重试";
|
||||||
|
default:
|
||||||
|
String suffix = message == null || message.isEmpty() ? "" : ":" + message;
|
||||||
|
return "讯飞识别失败(" + code + ")" + suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1955
Android/app/src/main/java/com/xhai/iot/ui/AddDeviceActivity.java
Normal file
1955
Android/app/src/main/java/com/xhai/iot/ui/AddDeviceActivity.java
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,203 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.BackendDevice;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加子设备:把未归属的设备绑定到当前网关(device.parent_device_id = 网关id)。
|
||||||
|
* 真实硬件配网时:子设备入网后会自动出现在本平台设备列表,再在此处选择绑定;
|
||||||
|
* 列表可点"刷新"重新加载。
|
||||||
|
*/
|
||||||
|
public class AddSubDeviceActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private String gatewayId;
|
||||||
|
private String gatewayName;
|
||||||
|
private SessionManager session;
|
||||||
|
|
||||||
|
private TextView tvGatewayHint;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private RecyclerView rvAvailable;
|
||||||
|
private AvailableAdapter adapter;
|
||||||
|
|
||||||
|
private final List<BackendDevice> available = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_add_sub_device);
|
||||||
|
|
||||||
|
gatewayId = getIntent().getStringExtra("deviceId");
|
||||||
|
gatewayName = getIntent().getStringExtra("deviceName");
|
||||||
|
if (gatewayId == null) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
|
||||||
|
tvGatewayHint = findViewById(R.id.tv_gateway_hint);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
rvAvailable = findViewById(R.id.rv_available);
|
||||||
|
tvGatewayHint.setText("当前网关:" + (gatewayName != null ? gatewayName : gatewayId));
|
||||||
|
|
||||||
|
findViewById(R.id.tv_back).setOnClickListener(v -> finish());
|
||||||
|
findViewById(R.id.tv_refresh).setOnClickListener(v -> loadAvailable());
|
||||||
|
|
||||||
|
adapter = new AvailableAdapter();
|
||||||
|
rvAvailable.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvAvailable.setAdapter(adapter);
|
||||||
|
|
||||||
|
loadAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载"未归属任何网关且非网关"的设备(可选设备) */
|
||||||
|
private void loadAvailable() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
tvEmpty.setText("加载中...");
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
ApiClient.getApiService().getDevices(auth, 1, 200, null, null, null)
|
||||||
|
.enqueue(new Callback<Result<PageData<BackendDevice>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<PageData<BackendDevice>>> call,
|
||||||
|
Response<Result<PageData<BackendDevice>>> response) {
|
||||||
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
|
handleResponse(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<PageData<BackendDevice>>> call, Throwable t) {
|
||||||
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
|
tvEmpty.setText("加载失败: " + t.getMessage());
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResponse(Response<Result<PageData<BackendDevice>>> response) {
|
||||||
|
if (response.isSuccessful() && response.body() != null && response.body().isSuccess()
|
||||||
|
&& response.body().getData() != null) {
|
||||||
|
available.clear();
|
||||||
|
List<BackendDevice> all = response.body().getData().getRecords();
|
||||||
|
if (all != null) {
|
||||||
|
for (BackendDevice d : all) {
|
||||||
|
// 非网关 + 未绑定父网关 = 可选
|
||||||
|
if (!d.isGateway() && (d.getParentDeviceId() == null
|
||||||
|
|| d.getParentDeviceId().isEmpty())) {
|
||||||
|
available.add(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
tvEmpty.setVisibility(available.isEmpty() ? View.VISIBLE : View.GONE);
|
||||||
|
if (available.isEmpty()) {
|
||||||
|
tvEmpty.setText("暂无可添加的设备");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定设备到网关 */
|
||||||
|
private void bindToGateway(BackendDevice device) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("parentDeviceId", gatewayId);
|
||||||
|
ApiClient.getApiService().bindDeviceParent(auth, device.getDeviceId(), body)
|
||||||
|
.enqueue(new Callback<Result<Integer>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Integer>> call,
|
||||||
|
Response<Result<Integer>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(AddSubDeviceActivity.this,
|
||||||
|
"已将" + device.getDeviceName() + "添加到网关",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
// 刷新列表并返回网关面板
|
||||||
|
setResult(RESULT_OK);
|
||||||
|
finish();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(AddSubDeviceActivity.this,
|
||||||
|
"添加失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Integer>> call, Throwable t) {
|
||||||
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
|
Toast.makeText(AddSubDeviceActivity.this,
|
||||||
|
"添加失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AvailableAdapter extends RecyclerView.Adapter<AvailableAdapter.VH> {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_gateway_sub_device, parent, false);
|
||||||
|
return new VH(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull VH holder, int position) {
|
||||||
|
BackendDevice d = available.get(position);
|
||||||
|
holder.tvName.setText(d.getDeviceName() != null ? d.getDeviceName() : d.getDeviceId());
|
||||||
|
String type = d.getDeviceType() != null && !d.getDeviceType().isEmpty()
|
||||||
|
? d.getDeviceType() : d.getPlatform();
|
||||||
|
boolean online = d.isOnline();
|
||||||
|
holder.tvStatus.setText((type != null ? type : "设备") + " · "
|
||||||
|
+ (online ? "在线" : "离线"));
|
||||||
|
holder.tvStatus.setTextColor(online ? 0xFF07C160 : 0xFF909399);
|
||||||
|
holder.itemView.setOnClickListener(v -> bindToGateway(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return available.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
class VH extends RecyclerView.ViewHolder {
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvStatus;
|
||||||
|
|
||||||
|
VH(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_sub_name);
|
||||||
|
tvStatus = itemView.findViewById(R.id.tv_sub_status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,937 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.speech.RecognitionListener;
|
||||||
|
import android.speech.RecognizerIntent;
|
||||||
|
import android.speech.SpeechRecognizer;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.view.inputmethod.InputMethodManager;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.HorizontalScrollView;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.app.ActivityCompat;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.AiCommandResult;
|
||||||
|
import com.xhai.iot.model.AiDeviceInfo;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.model.SpeechAuth;
|
||||||
|
import com.xhai.iot.speech.XfyunIatClient;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HomeAI 助手(设计稿 AIPage)
|
||||||
|
* <p>
|
||||||
|
* 真实对接后端 iot-rule 的 AI 指令解析链路:
|
||||||
|
* · POST /api/v1/ai/command 自然语言指令(L0 规则模板 + L1 大模型兜底,由服务端决定)
|
||||||
|
* · GET /api/v1/ai/devices 可被 AI 控制的设备(用于生成快捷建议)
|
||||||
|
* 特性:
|
||||||
|
* · 展示解析来源(规则模板 / 大模型)与实际耗时
|
||||||
|
* · 指令存在歧义时,服务端返回 needConfirm + candidates,界面给出候选按钮,
|
||||||
|
* 用户点选后携带 targetType/targetId 重新下发,服务端会顺势学习该说法为别名
|
||||||
|
* · 语音输入使用系统 SpeechRecognizer,识别结果自动发送
|
||||||
|
*/
|
||||||
|
public class AiAssistantActivity extends AppCompatActivity implements RecognitionListener {
|
||||||
|
|
||||||
|
private static final int REQ_AUDIO = 1001;
|
||||||
|
|
||||||
|
/** 由首页长按 AI 图标进入时置位:进页后自动唤起一次语音输入 */
|
||||||
|
public static final String EXTRA_AUTO_VOICE = "extra_auto_voice";
|
||||||
|
|
||||||
|
private static final String HINT_DEFAULT = "控制设备、查询状态、执行场景…";
|
||||||
|
private static final int MAX_SUGGESTION_DEVICES = 3;
|
||||||
|
|
||||||
|
/** 后端不可用时的兜底建议 */
|
||||||
|
private static final String[] FALLBACK_SUGGESTIONS = {
|
||||||
|
"打开客厅的灯", "空调调到26度", "启动就寝模式", "帮我关掉所有灯"
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 消息体 */
|
||||||
|
private static class Msg {
|
||||||
|
final boolean user;
|
||||||
|
String text;
|
||||||
|
final String time;
|
||||||
|
/** 解析来源 · 耗时(仅 AI 消息) */
|
||||||
|
String meta;
|
||||||
|
/** 正在思考/执行中 */
|
||||||
|
boolean thinking;
|
||||||
|
/** 产生歧义时用户的原始说法,点选候选后据此重新下发 */
|
||||||
|
String confirmSourceText;
|
||||||
|
/** 歧义候选 */
|
||||||
|
List<AiCommandResult.Candidate> candidates;
|
||||||
|
|
||||||
|
Msg(boolean user, String text, String time) {
|
||||||
|
this.user = user;
|
||||||
|
this.text = text;
|
||||||
|
this.time = time;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final List<Msg> messages = new ArrayList<>();
|
||||||
|
private MsgAdapter adapter;
|
||||||
|
private RecyclerView rvMessages;
|
||||||
|
private EditText etInput;
|
||||||
|
private TextView tvStatus;
|
||||||
|
private TextView btnSend;
|
||||||
|
private SessionManager session;
|
||||||
|
|
||||||
|
private int accentColor = 0xFF6B7280;
|
||||||
|
private int accentTintBg = 0x1F6B7280;
|
||||||
|
private int mutedColor = 0xFF9CA3AF;
|
||||||
|
private int normalFgColor = 0xFF111827;
|
||||||
|
|
||||||
|
private SpeechRecognizer speechRecognizer;
|
||||||
|
/** 讯飞语音听写客户端,仅在讯飞识别进行期间非空 */
|
||||||
|
private XfyunIatClient iatClient;
|
||||||
|
private boolean listening = false;
|
||||||
|
private boolean sending = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
setContentView(R.layout.activity_ai_assistant);
|
||||||
|
ThemeManager.getInstance(this).applySystemBars(this);
|
||||||
|
|
||||||
|
ThemeManager tm = ThemeManager.getInstance(this);
|
||||||
|
accentColor = tm.accent().value;
|
||||||
|
accentTintBg = tm.accentTintBg();
|
||||||
|
mutedColor = ContextCompat.getColor(this, R.color.c_muted_fg);
|
||||||
|
normalFgColor = ContextCompat.getColor(this, R.color.c_fg);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
|
||||||
|
rvMessages = findViewById(R.id.rv_messages);
|
||||||
|
etInput = findViewById(R.id.et_input);
|
||||||
|
tvStatus = findViewById(R.id.tv_status);
|
||||||
|
btnSend = findViewById(R.id.btn_send);
|
||||||
|
|
||||||
|
adapter = new MsgAdapter();
|
||||||
|
rvMessages.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvMessages.setAdapter(adapter);
|
||||||
|
|
||||||
|
messages.add(welcome());
|
||||||
|
|
||||||
|
// 返回 / 清空 / 指令记录
|
||||||
|
findViewById(R.id.btn_back).setOnClickListener(v -> finish());
|
||||||
|
findViewById(R.id.btn_clear).setOnClickListener(v -> {
|
||||||
|
sending = false;
|
||||||
|
messages.clear();
|
||||||
|
messages.add(welcome());
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
scrollToBottom();
|
||||||
|
});
|
||||||
|
findViewById(R.id.btn_history).setOnClickListener(v -> showHistory());
|
||||||
|
|
||||||
|
// 发送
|
||||||
|
btnSend.getBackground().mutate().setTint(accentColor);
|
||||||
|
btnSend.setOnClickListener(v -> send(etInput.getText().toString()));
|
||||||
|
|
||||||
|
// 语音输入(系统语音识别)
|
||||||
|
findViewById(R.id.btn_voice).setOnClickListener(v -> onVoiceClicked());
|
||||||
|
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
loadSuggestions();
|
||||||
|
|
||||||
|
// 首页长按 AI 图标进入:稍后自动唤起语音输入。
|
||||||
|
// 延后一小段时间等页面转场完成,否则 Activity 尚未获得焦点,识别窗口可能弹不出来。
|
||||||
|
if (getIntent().getBooleanExtra(EXTRA_AUTO_VOICE, false)) {
|
||||||
|
etInput.postDelayed(() -> {
|
||||||
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
|
onVoiceClicked();
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Msg welcome() {
|
||||||
|
return new Msg(false,
|
||||||
|
"你好!我是 HomeAI 助手 🏠\n直接用日常说法就能控制设备,例如:\n"
|
||||||
|
+ "· 打开客厅的灯\n"
|
||||||
|
+ "· 空调调到 26 度\n"
|
||||||
|
+ "· 启动就寝模式",
|
||||||
|
now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 指令下发 ====================
|
||||||
|
|
||||||
|
private void send(String raw) {
|
||||||
|
String text = raw == null ? "" : raw.trim();
|
||||||
|
if (text.isEmpty()) return;
|
||||||
|
if (sending) {
|
||||||
|
Toast.makeText(this, "正在处理上一条指令,请稍候", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
etInput.setText("");
|
||||||
|
messages.add(new Msg(true, text, now()));
|
||||||
|
adapter.notifyItemInserted(messages.size() - 1);
|
||||||
|
|
||||||
|
int thinkIdx = addThinking("正在理解你的指令…");
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
dispatch(text, null, null, thinkIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新增一条「思考中」气泡,返回其下标 */
|
||||||
|
private int addThinking(String text) {
|
||||||
|
Msg thinking = new Msg(false, text, now());
|
||||||
|
thinking.thinking = true;
|
||||||
|
messages.add(thinking);
|
||||||
|
adapter.notifyItemInserted(messages.size() - 1);
|
||||||
|
return messages.size() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用后端 AI 指令接口
|
||||||
|
*
|
||||||
|
* @param targetType/targetId 仅歧义消解时传入(用户已点选候选)
|
||||||
|
* @param thinkIdx 思考气泡下标,结果回来后就地替换
|
||||||
|
*/
|
||||||
|
private void dispatch(String text, String targetType, String targetId, int thinkIdx) {
|
||||||
|
String auth = session == null ? null : session.getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
bindAi(thinkIdx, "请先登录后再使用 AI 助手。", null, null, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("text", text);
|
||||||
|
if (!TextUtils.isEmpty(targetType) && !TextUtils.isEmpty(targetId)) {
|
||||||
|
body.put("targetType", targetType);
|
||||||
|
body.put("targetId", targetId);
|
||||||
|
// 让服务端记住这次消歧结果,下次同样的说法可直接命中
|
||||||
|
body.put("learn", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
final String sourceText = text;
|
||||||
|
ApiClient.getSceneApiService().aiCommand(auth, body)
|
||||||
|
.enqueue(new Callback<Result<AiCommandResult>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(@NonNull Call<Result<AiCommandResult>> call,
|
||||||
|
@NonNull Response<Result<AiCommandResult>> response) {
|
||||||
|
setSending(false);
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
|
||||||
|
Result<AiCommandResult> res = response.body();
|
||||||
|
if (res != null && res.isSuccess() && res.getData() != null) {
|
||||||
|
applyResult(thinkIdx, sourceText, res.getData());
|
||||||
|
} else {
|
||||||
|
String msg = (res != null && !TextUtils.isEmpty(res.getMessage()))
|
||||||
|
? res.getMessage()
|
||||||
|
: ("HTTP " + response.code());
|
||||||
|
bindAi(thinkIdx, "抱歉,指令处理失败:" + msg, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(@NonNull Call<Result<AiCommandResult>> call,
|
||||||
|
@NonNull Throwable t) {
|
||||||
|
setSending(false);
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
bindAi(thinkIdx,
|
||||||
|
"网络异常,没能连上 AI 服务:" + (t.getMessage() == null ? "" : t.getMessage()),
|
||||||
|
null, null, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把后端结果渲染到思考气泡上 */
|
||||||
|
private void applyResult(int idx, String sourceText, AiCommandResult r) {
|
||||||
|
String meta = null;
|
||||||
|
String label = r.getParseSourceLabel();
|
||||||
|
if (label != null) {
|
||||||
|
meta = "由" + label + "理解";
|
||||||
|
if (r.getCostMs() != null) {
|
||||||
|
meta = meta + " · " + r.getCostMs() + "ms";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<AiCommandResult.Candidate> candidates =
|
||||||
|
r.isNeedConfirm() ? r.getCandidates() : null;
|
||||||
|
if (candidates != null && candidates.isEmpty()) {
|
||||||
|
candidates = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String reply = r.getReplyOrEmpty();
|
||||||
|
if (TextUtils.isEmpty(reply)) {
|
||||||
|
reply = "已处理你的指令。";
|
||||||
|
}
|
||||||
|
bindAi(idx, reply, meta, candidates, sourceText);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 就地更新某条 AI 消息 */
|
||||||
|
private void bindAi(int idx, String text, String meta,
|
||||||
|
List<AiCommandResult.Candidate> candidates, String confirmSourceText) {
|
||||||
|
if (idx < 0 || idx >= messages.size()) return;
|
||||||
|
Msg m = messages.get(idx);
|
||||||
|
m.text = text;
|
||||||
|
m.meta = meta;
|
||||||
|
m.candidates = candidates;
|
||||||
|
m.confirmSourceText = confirmSourceText;
|
||||||
|
m.thinking = false;
|
||||||
|
adapter.notifyItemChanged(idx);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户点选歧义候选:携带目标再次下发,服务端会学习该别名 */
|
||||||
|
private void onCandidateChosen(Msg msg, AiCommandResult.Candidate c) {
|
||||||
|
if (msg.candidates == null) return;
|
||||||
|
int idx = messages.indexOf(msg);
|
||||||
|
if (idx < 0) return;
|
||||||
|
|
||||||
|
msg.candidates = null;
|
||||||
|
msg.meta = null;
|
||||||
|
msg.text = "你指的是哪一个?";
|
||||||
|
adapter.notifyItemChanged(idx);
|
||||||
|
|
||||||
|
messages.add(new Msg(true, c.getTargetName(), now()));
|
||||||
|
adapter.notifyItemInserted(messages.size() - 1);
|
||||||
|
|
||||||
|
int thinkIdx = addThinking("正在执行…");
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
dispatch(msg.confirmSourceText, c.getTargetType(), c.getTargetId(), thinkIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setSending(boolean value) {
|
||||||
|
sending = value;
|
||||||
|
if (btnSend != null) {
|
||||||
|
btnSend.setAlpha(value ? 0.5f : 1f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 快捷建议 ====================
|
||||||
|
|
||||||
|
/** 拉取可控设备,生成贴近真实设备的快捷指令 */
|
||||||
|
private void loadSuggestions() {
|
||||||
|
String auth = session == null ? null : session.getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
renderSuggestions(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ApiClient.getSceneApiService().getAiDevices(auth)
|
||||||
|
.enqueue(new Callback<Result<List<AiDeviceInfo>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(@NonNull Call<Result<List<AiDeviceInfo>>> call,
|
||||||
|
@NonNull Response<Result<List<AiDeviceInfo>>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Result<List<AiDeviceInfo>> res = response.body();
|
||||||
|
List<AiDeviceInfo> devices =
|
||||||
|
(res != null && res.isSuccess()) ? res.getData() : null;
|
||||||
|
renderSuggestions(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(@NonNull Call<Result<List<AiDeviceInfo>>> call,
|
||||||
|
@NonNull Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
renderSuggestions(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderSuggestions(List<AiDeviceInfo> devices) {
|
||||||
|
LinearLayout box = findViewById(R.id.ll_suggestions);
|
||||||
|
if (box == null) return;
|
||||||
|
box.removeAllViews();
|
||||||
|
|
||||||
|
List<String> items = new ArrayList<>();
|
||||||
|
if (devices != null && !devices.isEmpty()) {
|
||||||
|
// 在线优先、常用类型优先、名称短的优先
|
||||||
|
List<AiDeviceInfo> sorted = new ArrayList<>(devices);
|
||||||
|
Collections.sort(sorted, AiAssistantActivity::devicePriorityComparator);
|
||||||
|
|
||||||
|
// 取前 N 个设备生成单设备快捷指令
|
||||||
|
int limit = Math.min(sorted.size(), MAX_SUGGESTION_DEVICES);
|
||||||
|
for (int i = 0; i < limit; i++) {
|
||||||
|
AiDeviceInfo d = sorted.get(i);
|
||||||
|
if (d == null || TextUtils.isEmpty(d.getDeviceName())) continue;
|
||||||
|
items.add("打开" + d.getDeviceName());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加聚合建议(家里有对应类型设备时才出现)
|
||||||
|
if (hasDeviceType(sorted, "light")) {
|
||||||
|
items.add("打开所有灯");
|
||||||
|
items.add("关闭所有灯");
|
||||||
|
}
|
||||||
|
if (hasDeviceType(sorted, "switch")) {
|
||||||
|
items.add("打开所有开关");
|
||||||
|
items.add("关闭所有开关");
|
||||||
|
}
|
||||||
|
if (hasDeviceType(sorted, "air_conditioner")) {
|
||||||
|
items.add("打开所有空调");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tvStatus != null) {
|
||||||
|
int online = countOnlineDevices(devices);
|
||||||
|
tvStatus.setText("在线 " + online + " 台 · 可控设备 " + devices.size() + " 台");
|
||||||
|
}
|
||||||
|
} else if (tvStatus != null) {
|
||||||
|
tvStatus.setText("在线 · 由 AI 驱动");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.isEmpty()) {
|
||||||
|
Collections.addAll(items, FALLBACK_SUGGESTIONS);
|
||||||
|
} else {
|
||||||
|
items.add("启动就寝模式");
|
||||||
|
}
|
||||||
|
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(this);
|
||||||
|
for (String s : items) {
|
||||||
|
TextView chip = (TextView) inflater.inflate(R.layout.item_room_chip, box, false);
|
||||||
|
chip.setText(s);
|
||||||
|
chip.setOnClickListener(v -> send(s));
|
||||||
|
box.addView(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备推荐排序:在线 > 常用类型 > 使用频次 > 名称短 */
|
||||||
|
private static int devicePriorityComparator(AiDeviceInfo a, AiDeviceInfo b) {
|
||||||
|
int onlineA = "online".equalsIgnoreCase(a.getStatus()) ? 1 : 0;
|
||||||
|
int onlineB = "online".equalsIgnoreCase(b.getStatus()) ? 1 : 0;
|
||||||
|
if (onlineA != onlineB) return Integer.compare(onlineB, onlineA);
|
||||||
|
|
||||||
|
int typeA = deviceTypePriority(a.getDeviceType());
|
||||||
|
int typeB = deviceTypePriority(b.getDeviceType());
|
||||||
|
if (typeA != typeB) return Integer.compare(typeA, typeB);
|
||||||
|
|
||||||
|
int useA = a.getUseCount();
|
||||||
|
int useB = b.getUseCount();
|
||||||
|
if (useA != useB) return Integer.compare(useB, useA);
|
||||||
|
|
||||||
|
String nameA = a.getDeviceName() == null ? "" : a.getDeviceName();
|
||||||
|
String nameB = b.getDeviceName() == null ? "" : b.getDeviceName();
|
||||||
|
return Integer.compare(nameA.length(), nameB.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备类型常用优先级(数字越小越靠前) */
|
||||||
|
private static int deviceTypePriority(String type) {
|
||||||
|
if (type == null) return 99;
|
||||||
|
switch (type.toLowerCase(Locale.ROOT)) {
|
||||||
|
case "light": return 1;
|
||||||
|
case "switch": return 2;
|
||||||
|
case "socket": return 3;
|
||||||
|
case "air_conditioner": return 4;
|
||||||
|
case "fan": return 5;
|
||||||
|
case "curtain": return 6;
|
||||||
|
case "sensor": return 7;
|
||||||
|
default: return 99;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean hasDeviceType(List<AiDeviceInfo> devices, String type) {
|
||||||
|
if (devices == null || type == null) return false;
|
||||||
|
String lower = type.toLowerCase(Locale.ROOT);
|
||||||
|
for (AiDeviceInfo d : devices) {
|
||||||
|
if (d != null && lower.equals(d.getDeviceType() == null ? "" : d.getDeviceType().toLowerCase(Locale.ROOT))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int countOnlineDevices(List<AiDeviceInfo> devices) {
|
||||||
|
int count = 0;
|
||||||
|
if (devices == null) return 0;
|
||||||
|
for (AiDeviceInfo d : devices) {
|
||||||
|
if (d != null && "online".equalsIgnoreCase(d.getStatus())) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 语音输入 ====================
|
||||||
|
|
||||||
|
private void onVoiceClicked() {
|
||||||
|
// 聆听中再次点击 = 主动结束,不必等静音检测
|
||||||
|
if (listening) {
|
||||||
|
if (iatClient != null) {
|
||||||
|
iatClient.stop();
|
||||||
|
} else if (speechRecognizer != null) {
|
||||||
|
speechRecognizer.stopListening();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ActivityCompat.requestPermissions(this,
|
||||||
|
new String[]{Manifest.permission.RECORD_AUDIO}, REQ_AUDIO);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startListening();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备没有可用的语音识别服务(国内 ROM 未内置 GMS 语音服务时很常见)时的兜底:
|
||||||
|
* 聚焦输入框并拉起软键盘,避免「长按 AI 图标后什么都没发生」。
|
||||||
|
*/
|
||||||
|
private void fallbackToKeyboard(String tip) {
|
||||||
|
Toast.makeText(this, tip, Toast.LENGTH_LONG).show();
|
||||||
|
if (etInput == null) return;
|
||||||
|
etInput.setFocusableInTouchMode(true);
|
||||||
|
etInput.requestFocus();
|
||||||
|
InputMethodManager imm =
|
||||||
|
(InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE);
|
||||||
|
if (imm != null) {
|
||||||
|
imm.showSoftInput(etInput, InputMethodManager.SHOW_IMPLICIT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讯飞语音听写(流式版):边说边出字,停顿一下自动结束;聆听中再点一次麦克风可提前结束。
|
||||||
|
* 识别完成后自动填入输入框并直接发送,无需用户再点发送。
|
||||||
|
*/
|
||||||
|
private void startXfyunListening(String appId, String wssUrl) {
|
||||||
|
listening = true;
|
||||||
|
etInput.setHint("正在聆听…(停顿一下自动结束)");
|
||||||
|
|
||||||
|
iatClient = new XfyunIatClient(new XfyunIatClient.Listener() {
|
||||||
|
@Override
|
||||||
|
public void onPartial(String text) {
|
||||||
|
if (isFinishing() || isDestroyed() || !listening) return;
|
||||||
|
etInput.setText(text);
|
||||||
|
etInput.setSelection(etInput.getText().length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFinal(String text) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
stopListeningUi();
|
||||||
|
if (TextUtils.isEmpty(text)) {
|
||||||
|
Toast.makeText(AiAssistantActivity.this, "没有听清,请再说一次",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
etInput.setText(text);
|
||||||
|
send(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
stopListeningUi();
|
||||||
|
Toast.makeText(AiAssistantActivity.this, message, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
iatClient.start(appId, wssUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startListening() {
|
||||||
|
if (listening) return;
|
||||||
|
|
||||||
|
String auth = session == null ? null : session.getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
fallbackToKeyboard("请先登录后再使用语音输入");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先走后端签发的讯飞语音听写:凭证只在服务端,App 领取签名地址后直连;
|
||||||
|
// 国内 ROM 普遍没有系统识别服务,且讯飞支持边说边出字
|
||||||
|
listening = true;
|
||||||
|
etInput.setHint("正在连接语音服务…");
|
||||||
|
ApiClient.getSceneApiService().getSpeechAuth(auth)
|
||||||
|
.enqueue(new Callback<Result<SpeechAuth>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(@NonNull Call<Result<SpeechAuth>> call,
|
||||||
|
@NonNull Response<Result<SpeechAuth>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Result<SpeechAuth> res = response.body();
|
||||||
|
if (res != null && res.isSuccess() && res.getData() != null
|
||||||
|
&& res.getData().isEnabled()) {
|
||||||
|
startXfyunListening(res.getData().getAppId(), res.getData().getUrl());
|
||||||
|
} else {
|
||||||
|
// 后台未启用讯飞:回退系统识别 / 键盘
|
||||||
|
startSystemListening();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(@NonNull Call<Result<SpeechAuth>> call,
|
||||||
|
@NonNull Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
// 鉴权接口异常不影响使用,静默回退系统识别 / 键盘
|
||||||
|
startSystemListening();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统语音识别(后端未启用讯飞 / 鉴权接口异常时的回退)。
|
||||||
|
* 国内无 GMS 的 ROM 普遍没有内置识别服务,此时进一步退化为键盘输入。
|
||||||
|
*/
|
||||||
|
private void startSystemListening() {
|
||||||
|
if (!SpeechRecognizer.isRecognitionAvailable(this)) {
|
||||||
|
stopListeningUi();
|
||||||
|
fallbackToKeyboard("当前设备未内置语音识别服务,请直接输入指令");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (speechRecognizer == null) {
|
||||||
|
speechRecognizer = SpeechRecognizer.createSpeechRecognizer(this);
|
||||||
|
speechRecognizer.setRecognitionListener(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
|
||||||
|
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL,
|
||||||
|
RecognizerIntent.LANGUAGE_MODEL_FREE_FORM);
|
||||||
|
intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN");
|
||||||
|
intent.putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true);
|
||||||
|
intent.putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
etInput.setHint("正在聆听…");
|
||||||
|
speechRecognizer.startListening(intent);
|
||||||
|
listening = true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
stopListeningUi();
|
||||||
|
Toast.makeText(this, "语音识别启动失败:" + e.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopListeningUi() {
|
||||||
|
listening = false;
|
||||||
|
if (iatClient != null) {
|
||||||
|
iatClient.release();
|
||||||
|
iatClient = null;
|
||||||
|
}
|
||||||
|
if (etInput != null) {
|
||||||
|
etInput.setHint(HINT_DEFAULT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String firstResult(Bundle bundle) {
|
||||||
|
if (bundle == null) return null;
|
||||||
|
ArrayList<String> list = bundle.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
|
||||||
|
if (list != null && !list.isEmpty()) {
|
||||||
|
return list.get(0);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String errorText(int error) {
|
||||||
|
switch (error) {
|
||||||
|
case SpeechRecognizer.ERROR_NO_MATCH:
|
||||||
|
return "没有听清";
|
||||||
|
case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
|
||||||
|
return "未检测到说话";
|
||||||
|
case SpeechRecognizer.ERROR_NETWORK:
|
||||||
|
case SpeechRecognizer.ERROR_NETWORK_TIMEOUT:
|
||||||
|
return "网络异常";
|
||||||
|
case SpeechRecognizer.ERROR_AUDIO:
|
||||||
|
return "录音异常";
|
||||||
|
case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS:
|
||||||
|
return "缺少录音权限";
|
||||||
|
default:
|
||||||
|
return "错误码 " + error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
|
||||||
|
@NonNull int[] grantResults) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
if (requestCode == REQ_AUDIO) {
|
||||||
|
if (grantResults.length > 0
|
||||||
|
&& grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||||
|
startListening();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(this, "需要录音权限才能使用语音输入", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== RecognitionListener ====================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onReadyForSpeech(Bundle params) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBeginningOfSpeech() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onRmsChanged(float rmsdB) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBufferReceived(byte[] buffer) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEndOfSpeech() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(int error) {
|
||||||
|
stopListeningUi();
|
||||||
|
Toast.makeText(this, "语音识别失败(" + errorText(error) + "),请重试或改用文字输入",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResults(Bundle results) {
|
||||||
|
stopListeningUi();
|
||||||
|
String text = firstResult(results);
|
||||||
|
if (TextUtils.isEmpty(text)) {
|
||||||
|
Toast.makeText(this, "没有听清,请再说一次", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
etInput.setText(text);
|
||||||
|
send(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPartialResults(Bundle partialResults) {
|
||||||
|
String text = firstResult(partialResults);
|
||||||
|
if (!TextUtils.isEmpty(text) && etInput != null) {
|
||||||
|
etInput.setText(text);
|
||||||
|
etInput.setSelection(etInput.getText().length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onEvent(int eventType, Bundle params) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
if (iatClient != null) {
|
||||||
|
iatClient.release();
|
||||||
|
iatClient = null;
|
||||||
|
}
|
||||||
|
if (speechRecognizer != null) {
|
||||||
|
speechRecognizer.destroy();
|
||||||
|
speechRecognizer = null;
|
||||||
|
}
|
||||||
|
super.onDestroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 指令记录 ====================
|
||||||
|
|
||||||
|
/** 查看最近的 AI 指令记录(每条都标注解析来源与耗时,便于回溯效果) */
|
||||||
|
private void showHistory() {
|
||||||
|
String auth = session == null ? null : session.getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
Toast.makeText(this, "请先登录后再查看记录", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ApiClient.getSceneApiService().getAiLogs(auth, 20)
|
||||||
|
.enqueue(new Callback<Result<List<Map<String, Object>>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(@NonNull Call<Result<List<Map<String, Object>>>> call,
|
||||||
|
@NonNull Response<Result<List<Map<String, Object>>>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Result<List<Map<String, Object>>> res = response.body();
|
||||||
|
List<Map<String, Object>> list =
|
||||||
|
(res != null && res.isSuccess()) ? res.getData() : null;
|
||||||
|
showHistoryDialog(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(@NonNull Call<Result<List<Map<String, Object>>>> call,
|
||||||
|
@NonNull Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(AiAssistantActivity.this,
|
||||||
|
"读取记录失败:" + (t.getMessage() == null ? "" : t.getMessage()),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showHistoryDialog(List<Map<String, Object>> list) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
|
||||||
|
String content;
|
||||||
|
if (list == null || list.isEmpty()) {
|
||||||
|
content = "暂无 AI 指令记录";
|
||||||
|
} else {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
int index = 0;
|
||||||
|
for (Map<String, Object> item : list) {
|
||||||
|
if (item == null) continue;
|
||||||
|
index++;
|
||||||
|
|
||||||
|
String input = str(item.get("inputText"));
|
||||||
|
String reply = str(item.get("reply"));
|
||||||
|
String source = str(item.get("parseSource"));
|
||||||
|
|
||||||
|
sb.append(index).append(". ").append(input.isEmpty() ? "(空)" : input);
|
||||||
|
if ("llm".equalsIgnoreCase(source)) {
|
||||||
|
sb.append(" [大模型]");
|
||||||
|
} else if ("rule".equalsIgnoreCase(source)) {
|
||||||
|
sb.append(" [规则]");
|
||||||
|
}
|
||||||
|
if (item.get("costMs") != null) {
|
||||||
|
sb.append(" ").append(item.get("costMs")).append("ms");
|
||||||
|
}
|
||||||
|
if (Boolean.FALSE.equals(item.get("success"))) {
|
||||||
|
sb.append(" ✗ 未执行");
|
||||||
|
}
|
||||||
|
sb.append("\n → ").append(reply.isEmpty() ? "-" : reply).append("\n");
|
||||||
|
}
|
||||||
|
content = sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("最近 AI 指令记录")
|
||||||
|
.setMessage(content)
|
||||||
|
.setPositiveButton("关闭", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String str(Object value) {
|
||||||
|
return value == null ? "" : String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 列表 ====================
|
||||||
|
|
||||||
|
private void scrollToBottom() {
|
||||||
|
if (rvMessages == null || adapter == null) return;
|
||||||
|
rvMessages.post(() -> {
|
||||||
|
int count = adapter.getItemCount();
|
||||||
|
if (count > 0) {
|
||||||
|
rvMessages.scrollToPosition(count - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private String now() {
|
||||||
|
return new SimpleDateFormat("HH:mm", Locale.CHINA).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 消息适配器 */
|
||||||
|
private class MsgAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
|
||||||
|
|
||||||
|
private static final int TYPE_AI = 0;
|
||||||
|
private static final int TYPE_USER = 1;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemViewType(int position) {
|
||||||
|
return messages.get(position).user ? TYPE_USER : TYPE_AI;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(parent.getContext());
|
||||||
|
if (viewType == TYPE_USER) {
|
||||||
|
return new VH(inflater.inflate(R.layout.item_ai_msg_user, parent, false));
|
||||||
|
}
|
||||||
|
return new AiVH(inflater.inflate(R.layout.item_ai_msg_ai, parent, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
|
||||||
|
Msg msg = messages.get(position);
|
||||||
|
|
||||||
|
if (holder instanceof VH) {
|
||||||
|
VH vh = (VH) holder;
|
||||||
|
vh.tvMsg.setText(msg.text);
|
||||||
|
vh.tvTime.setText(msg.time);
|
||||||
|
vh.tvMsg.getBackground().mutate().setTint(accentColor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AiVH vh = (AiVH) holder;
|
||||||
|
vh.tvMsg.setText(msg.text);
|
||||||
|
vh.tvTime.setText(msg.time);
|
||||||
|
vh.tvMsg.setTextColor(msg.thinking ? mutedColor : normalFgColor);
|
||||||
|
|
||||||
|
// 解析来源 · 耗时
|
||||||
|
if (TextUtils.isEmpty(msg.meta)) {
|
||||||
|
vh.tvMeta.setVisibility(View.GONE);
|
||||||
|
} else {
|
||||||
|
vh.tvMeta.setText(msg.meta);
|
||||||
|
vh.tvMeta.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 歧义候选
|
||||||
|
vh.llCandidates.removeAllViews();
|
||||||
|
if (msg.candidates == null || msg.candidates.isEmpty()) {
|
||||||
|
vh.svCandidates.setVisibility(View.GONE);
|
||||||
|
} else {
|
||||||
|
vh.svCandidates.setVisibility(View.VISIBLE);
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(AiAssistantActivity.this);
|
||||||
|
for (AiCommandResult.Candidate c : msg.candidates) {
|
||||||
|
TextView chip = (TextView) inflater.inflate(
|
||||||
|
R.layout.item_room_chip, vh.llCandidates, false);
|
||||||
|
chip.setText(c.getTargetName());
|
||||||
|
chip.getBackground().mutate().setTint(accentTintBg);
|
||||||
|
chip.setTextColor(accentColor);
|
||||||
|
chip.setOnClickListener(v -> onCandidateChosen(msg, c));
|
||||||
|
vh.llCandidates.addView(chip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return messages.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户消息 */
|
||||||
|
class VH extends RecyclerView.ViewHolder {
|
||||||
|
TextView tvMsg;
|
||||||
|
TextView tvTime;
|
||||||
|
|
||||||
|
VH(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
tvMsg = itemView.findViewById(R.id.tv_msg);
|
||||||
|
tvTime = itemView.findViewById(R.id.tv_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 消息(含来源标注与歧义候选) */
|
||||||
|
class AiVH extends RecyclerView.ViewHolder {
|
||||||
|
TextView tvMsg;
|
||||||
|
TextView tvTime;
|
||||||
|
TextView tvMeta;
|
||||||
|
HorizontalScrollView svCandidates;
|
||||||
|
LinearLayout llCandidates;
|
||||||
|
|
||||||
|
AiVH(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
tvMsg = itemView.findViewById(R.id.tv_msg);
|
||||||
|
tvTime = itemView.findViewById(R.id.tv_time);
|
||||||
|
tvMeta = itemView.findViewById(R.id.tv_meta);
|
||||||
|
svCandidates = itemView.findViewById(R.id.sv_candidates);
|
||||||
|
llCandidates = itemView.findViewById(R.id.ll_candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,178 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.GridLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外观与主题色(设计稿 AppearanceSheet)
|
||||||
|
* 显示模式(浅色/深色/跟随系统) + 8 种主题色预设 + 自定义 HEX 颜色
|
||||||
|
*/
|
||||||
|
public class AppearanceSheetFragment extends BottomSheetDialogFragment {
|
||||||
|
|
||||||
|
private ThemeManager theme;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.sheet_appearance, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
theme = ThemeManager.getInstance(requireContext());
|
||||||
|
|
||||||
|
TextView btnLight = view.findViewById(R.id.btn_mode_light);
|
||||||
|
TextView btnDark = view.findViewById(R.id.btn_mode_dark);
|
||||||
|
TextView btnSystem = view.findViewById(R.id.btn_mode_system);
|
||||||
|
EditText etHex = view.findViewById(R.id.et_custom_hex);
|
||||||
|
TextView btnApply = view.findViewById(R.id.btn_custom_apply);
|
||||||
|
|
||||||
|
// 显示模式
|
||||||
|
View.OnClickListener modeClick = v -> {
|
||||||
|
String mode;
|
||||||
|
if (v == btnLight) mode = ThemeManager.MODE_LIGHT;
|
||||||
|
else if (v == btnDark) mode = ThemeManager.MODE_DARK;
|
||||||
|
else mode = ThemeManager.MODE_SYSTEM;
|
||||||
|
theme.setMode(mode);
|
||||||
|
styleModeButtons(btnLight, btnDark, btnSystem);
|
||||||
|
notifyHost();
|
||||||
|
Toast.makeText(requireContext(), "已切换显示模式", Toast.LENGTH_SHORT).show();
|
||||||
|
};
|
||||||
|
btnLight.setOnClickListener(modeClick);
|
||||||
|
btnDark.setOnClickListener(modeClick);
|
||||||
|
btnSystem.setOnClickListener(modeClick);
|
||||||
|
styleModeButtons(btnLight, btnDark, btnSystem);
|
||||||
|
|
||||||
|
// 主题色网格
|
||||||
|
buildAccentGrid();
|
||||||
|
|
||||||
|
// 自定义颜色
|
||||||
|
btnApply.getBackground().mutate().setTint(theme.accent().value);
|
||||||
|
btnApply.setOnClickListener(v -> {
|
||||||
|
String hex = etHex.getText().toString().trim();
|
||||||
|
if (hex.length() != 6) {
|
||||||
|
Toast.makeText(requireContext(), "请输入 6 位 HEX 颜色值",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String full = "#" + hex.toUpperCase();
|
||||||
|
try {
|
||||||
|
Color.parseColor(full);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Toast.makeText(requireContext(), "颜色格式不正确", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
theme.setAccent("自定义", full);
|
||||||
|
Toast.makeText(requireContext(), "已应用自定义颜色", Toast.LENGTH_SHORT).show();
|
||||||
|
buildAccentGrid();
|
||||||
|
notifyHost();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主题变化后通知宿主(ProfileFragment 重渲染 + MainActivity 刷新导航色) */
|
||||||
|
private void notifyHost() {
|
||||||
|
androidx.fragment.app.Fragment parent = getParentFragment();
|
||||||
|
if (parent instanceof ProfileFragment) {
|
||||||
|
((ProfileFragment) parent).refreshTheme();
|
||||||
|
} else if (getActivity() instanceof MainActivity) {
|
||||||
|
((MainActivity) getActivity()).refreshNavTint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建/刷新主题色网格 */
|
||||||
|
private void buildAccentGrid() {
|
||||||
|
View root = getView();
|
||||||
|
if (root == null) return;
|
||||||
|
GridLayout grid = root.findViewById(R.id.grid_accents);
|
||||||
|
TextView tvName = root.findViewById(R.id.tv_accent_name);
|
||||||
|
grid.removeAllViews();
|
||||||
|
|
||||||
|
ThemeManager.Accent current = theme.accent();
|
||||||
|
tvName.setText(current.name);
|
||||||
|
|
||||||
|
for (int i = 0; i < ThemeManager.PRESETS.length; i++) {
|
||||||
|
final int idx = i;
|
||||||
|
ThemeManager.Accent p = ThemeManager.PRESETS[i];
|
||||||
|
boolean active = p.value == current.value;
|
||||||
|
TextView swatch = new TextView(requireContext());
|
||||||
|
GridLayout.LayoutParams params = new GridLayout.LayoutParams();
|
||||||
|
params.width = 0;
|
||||||
|
params.height = dp(74);
|
||||||
|
params.columnSpec = GridLayout.spec(GridLayout.UNDEFINED, 1f);
|
||||||
|
params.setMargins(dp(6), dp(6), dp(6), dp(6));
|
||||||
|
swatch.setLayoutParams(params);
|
||||||
|
swatch.setGravity(android.view.Gravity.CENTER);
|
||||||
|
swatch.setTextSize(11);
|
||||||
|
swatch.setTextColor(p.fg);
|
||||||
|
swatch.setText(active ? "✓" : p.name);
|
||||||
|
GradientDrawable bg = new GradientDrawable();
|
||||||
|
bg.setCornerRadius(dp(16));
|
||||||
|
bg.setColor(p.value);
|
||||||
|
if (active) {
|
||||||
|
bg.setStroke(dp(2), 0xFFFFFFFF);
|
||||||
|
}
|
||||||
|
swatch.setBackground(bg);
|
||||||
|
swatch.setOnClickListener(v -> {
|
||||||
|
ThemeManager.Accent selected = ThemeManager.PRESETS[idx];
|
||||||
|
theme.setAccent(selected.name,
|
||||||
|
String.format("#%06X", 0xFFFFFF & selected.value));
|
||||||
|
Toast.makeText(requireContext(), "主题色:" + selected.name,
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
buildAccentGrid();
|
||||||
|
notifyHost();
|
||||||
|
});
|
||||||
|
grid.addView(swatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义按钮也随主题色变化
|
||||||
|
TextView btnApply = root.findViewById(R.id.btn_custom_apply);
|
||||||
|
if (btnApply.getBackground() != null) {
|
||||||
|
btnApply.getBackground().mutate().setTint(current.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void styleModeButtons(TextView btnLight, TextView btnDark, TextView btnSystem) {
|
||||||
|
String mode = theme.mode();
|
||||||
|
styleMode(btnLight, ThemeManager.MODE_LIGHT.equals(mode));
|
||||||
|
styleMode(btnDark, ThemeManager.MODE_DARK.equals(mode));
|
||||||
|
styleMode(btnSystem, ThemeManager.MODE_SYSTEM.equals(mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void styleMode(TextView btn, boolean active) {
|
||||||
|
btn.setBackgroundResource(active
|
||||||
|
? R.drawable.bg_chip_accent : R.drawable.bg_chip_muted);
|
||||||
|
if (active) {
|
||||||
|
android.graphics.drawable.Drawable bg = btn.getBackground();
|
||||||
|
if (bg != null) {
|
||||||
|
bg = bg.mutate();
|
||||||
|
bg.setTint(theme.accent().value);
|
||||||
|
btn.setBackground(bg);
|
||||||
|
}
|
||||||
|
btn.setTextColor(0xFFFFFFFF);
|
||||||
|
} else {
|
||||||
|
btn.setTextColor(requireContext().getColor(R.color.c_muted_fg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int dp(int v) {
|
||||||
|
return Math.round(getResources().getDisplayMetrics().density * v);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,490 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.model.SceneItem;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
import com.xhai.iot.ui.widget.PillSwitch;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动化页(设计稿 AutomationPage)
|
||||||
|
* 统计卡 + 规则列表(规则 = 场景下的联动规则,开关走 toggleRule,执行走 executeScene)
|
||||||
|
*/
|
||||||
|
public class AutomationFragment extends Fragment {
|
||||||
|
|
||||||
|
/** 规则列表项 */
|
||||||
|
private static class RuleRow {
|
||||||
|
Long ruleId;
|
||||||
|
Long sceneId;
|
||||||
|
String sceneName;
|
||||||
|
String ruleName;
|
||||||
|
String condition;
|
||||||
|
String actionDesc;
|
||||||
|
boolean enabled;
|
||||||
|
String icon = "⚡";
|
||||||
|
}
|
||||||
|
|
||||||
|
private RecyclerView rvAuto;
|
||||||
|
private ProgressBar progressBar;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private TextView tvSummary;
|
||||||
|
private TextView tvStatScenes;
|
||||||
|
private TextView tvStatNormal;
|
||||||
|
private TextView btnNewScene;
|
||||||
|
|
||||||
|
private SessionManager session;
|
||||||
|
private final List<RuleRow> rows = new ArrayList<>();
|
||||||
|
private RuleAdapter adapter;
|
||||||
|
private int accentColor = 0xFF6B7280;
|
||||||
|
private int sceneCount = 0;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_automation, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
session = SessionManager.getInstance(requireContext());
|
||||||
|
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
|
||||||
|
|
||||||
|
rvAuto = view.findViewById(R.id.rv_automation);
|
||||||
|
progressBar = view.findViewById(R.id.progress_auto);
|
||||||
|
tvEmpty = view.findViewById(R.id.tv_empty);
|
||||||
|
tvSummary = view.findViewById(R.id.tv_auto_summary);
|
||||||
|
tvStatScenes = view.findViewById(R.id.tv_stat_scenes);
|
||||||
|
tvStatNormal = view.findViewById(R.id.tv_stat_normal);
|
||||||
|
btnNewScene = view.findViewById(R.id.btn_new_scene);
|
||||||
|
|
||||||
|
// 新建按钮染主题色
|
||||||
|
btnNewScene.setBackgroundResource(R.drawable.bg_btn_accent);
|
||||||
|
android.graphics.drawable.Drawable bg2 = btnNewScene.getBackground();
|
||||||
|
if (bg2 != null) {
|
||||||
|
bg2 = bg2.mutate();
|
||||||
|
bg2.setTint(accentColor);
|
||||||
|
btnNewScene.setBackground(bg2);
|
||||||
|
}
|
||||||
|
btnNewScene.setOnClickListener(v -> showCreateDialog());
|
||||||
|
|
||||||
|
adapter = new RuleAdapter();
|
||||||
|
rvAuto.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
|
rvAuto.setAdapter(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
|
||||||
|
refreshAccentUi();
|
||||||
|
loadScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 用 hide/show 切换时不走 onResume,需在此刷新主题色 */
|
||||||
|
@Override
|
||||||
|
public void onHiddenChanged(boolean hidden) {
|
||||||
|
super.onHiddenChanged(hidden);
|
||||||
|
if (!hidden && isAdded()) {
|
||||||
|
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
|
||||||
|
refreshAccentUi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新强调控件主色(新建按钮 + 列表) */
|
||||||
|
private void refreshAccentUi() {
|
||||||
|
if (btnNewScene != null && btnNewScene.getBackground() != null) {
|
||||||
|
android.graphics.drawable.Drawable bg = btnNewScene.getBackground().mutate();
|
||||||
|
bg.setTint(accentColor);
|
||||||
|
btnNewScene.setBackground(bg);
|
||||||
|
}
|
||||||
|
if (adapter != null) {
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLoading(boolean show) {
|
||||||
|
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取场景列表,然后聚合每个场景下的规则 */
|
||||||
|
private void loadScenes() {
|
||||||
|
showLoading(true);
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("请先登录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ApiClient.getSceneApiService().getScenes(auth, 1, 100)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<PageData<SceneItem>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<PageData<SceneItem>>> call,
|
||||||
|
retrofit2.Response<Result<PageData<SceneItem>>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
List<SceneItem> scenes = response.isSuccessful()
|
||||||
|
&& response.body() != null && response.body().isSuccess()
|
||||||
|
&& response.body().getData() != null
|
||||||
|
? response.body().getData().getRecords()
|
||||||
|
: new ArrayList<>();
|
||||||
|
sceneCount = scenes.size();
|
||||||
|
tvStatScenes.setText(String.valueOf(sceneCount));
|
||||||
|
tvSummary.setText(sceneCount + " 个场景");
|
||||||
|
loadAllRules(scenes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<PageData<SceneItem>>> call, Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("连接服务器失败: " + t.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 聚合所有场景的规则 */
|
||||||
|
private void loadAllRules(List<SceneItem> scenes) {
|
||||||
|
rows.clear();
|
||||||
|
if (scenes.isEmpty()) {
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvStatNormal.setText("正常");
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int[] pending = {scenes.size()};
|
||||||
|
for (SceneItem scene : scenes) {
|
||||||
|
ApiClient.getSceneApiService().getRules(session.getBearerToken(),
|
||||||
|
scene.getId(), 1, 50)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<PageData<Map<String, Object>>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(
|
||||||
|
retrofit2.Call<Result<PageData<Map<String, Object>>>> call,
|
||||||
|
retrofit2.Response<Result<PageData<Map<String, Object>>>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()
|
||||||
|
&& response.body().getData() != null) {
|
||||||
|
List<Map<String, Object>> records =
|
||||||
|
response.body().getData().getRecords();
|
||||||
|
if (records != null) {
|
||||||
|
for (Map<String, Object> rule : records) {
|
||||||
|
rows.add(toRow(rule, scene));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending[0]--;
|
||||||
|
if (pending[0] <= 0) {
|
||||||
|
showLoading(false);
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
tvEmpty.setVisibility(rows.isEmpty()
|
||||||
|
? View.VISIBLE : View.GONE);
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(
|
||||||
|
retrofit2.Call<Result<PageData<Map<String, Object>>>> call,
|
||||||
|
Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
pending[0]--;
|
||||||
|
if (pending[0] <= 0) {
|
||||||
|
showLoading(false);
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
tvEmpty.setVisibility(rows.isEmpty()
|
||||||
|
? View.VISIBLE : View.GONE);
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private RuleRow toRow(Map<String, Object> rule, SceneItem scene) {
|
||||||
|
RuleRow row = new RuleRow();
|
||||||
|
Object id = rule.get("id");
|
||||||
|
if (id instanceof Number) {
|
||||||
|
row.ruleId = ((Number) id).longValue();
|
||||||
|
} else if (id != null) {
|
||||||
|
try {
|
||||||
|
row.ruleId = Long.parseLong(id.toString());
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.sceneId = scene.getId();
|
||||||
|
row.sceneName = scene.getSceneName();
|
||||||
|
String name = str(rule.get("ruleName"));
|
||||||
|
row.ruleName = !name.isEmpty() ? name : "未命名规则";
|
||||||
|
row.condition = str(rule.get("condition"));
|
||||||
|
String ak = str(rule.get("actionKey"));
|
||||||
|
String av = str(rule.get("actionValue"));
|
||||||
|
row.actionDesc = ak.isEmpty() ? "动作" : ak + (av.isEmpty() ? "" : " = " + av);
|
||||||
|
Boolean en = rule.get("enabled") instanceof Boolean
|
||||||
|
? (Boolean) rule.get("enabled") : true;
|
||||||
|
row.enabled = Boolean.TRUE.equals(en);
|
||||||
|
row.icon = pickIcon(row.condition + row.actionDesc);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String str(Object o) {
|
||||||
|
return o != null ? o.toString() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据条件/动作关键字选图标 */
|
||||||
|
private String pickIcon(String text) {
|
||||||
|
if (text.contains("light") || text.contains("开关") || text.contains("switch")) {
|
||||||
|
return "💡";
|
||||||
|
}
|
||||||
|
if (text.contains("温度") || text.contains("temp") || text.contains("空调")) {
|
||||||
|
return "❄️";
|
||||||
|
}
|
||||||
|
if (text.contains("门锁") || text.contains("lock") || text.contains("安防")) {
|
||||||
|
return "🔒";
|
||||||
|
}
|
||||||
|
if (text.contains("窗帘") || text.contains("curtain")) {
|
||||||
|
return "🪟";
|
||||||
|
}
|
||||||
|
if (text.contains("pm25") || text.contains("空气") || text.contains("湿度")) {
|
||||||
|
return "🌿";
|
||||||
|
}
|
||||||
|
return "⚡";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStats() {
|
||||||
|
int enabled = 0;
|
||||||
|
for (RuleRow r : rows) {
|
||||||
|
if (r.enabled) enabled++;
|
||||||
|
}
|
||||||
|
tvStatNormal.setText(rows.isEmpty() ? "正常" : enabled + "/" + rows.size() + " 启用");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 新建场景对话框(走 createScene API) */
|
||||||
|
private void showCreateDialog() {
|
||||||
|
android.widget.EditText input = new android.widget.EditText(requireContext());
|
||||||
|
input.setHint("如:回家模式");
|
||||||
|
input.setPadding(dp(20), dp(12), dp(20), dp(12));
|
||||||
|
|
||||||
|
new androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle("新建场景")
|
||||||
|
.setView(input)
|
||||||
|
.setPositiveButton("创建", (d, w) -> {
|
||||||
|
String name = input.getText().toString().trim();
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), "请输入场景名称",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createScene(name, name + "-一键场景");
|
||||||
|
})
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void createScene(String name, String desc) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
Map<String, Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("sceneName", name);
|
||||||
|
body.put("sceneType", "one_click");
|
||||||
|
body.put("description", desc);
|
||||||
|
ApiClient.getSceneApiService().createScene(auth, body)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<Long>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<Long>> call,
|
||||||
|
retrofit2.Response<Result<Long>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(requireContext(), "场景创建成功",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
loadScenes();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(), "创建失败: "
|
||||||
|
+ (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<Long>> call, Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
Toast.makeText(requireContext(), "创建失败: " + t.getMessage(),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启用/禁用规则 */
|
||||||
|
private void toggleRule(RuleRow row, boolean enable) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null || row.ruleId == null) return;
|
||||||
|
ApiClient.getSceneApiService().toggleRule(auth, row.ruleId,
|
||||||
|
enable ? "enable" : "disable")
|
||||||
|
.enqueue(new retrofit2.Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<Void>> call,
|
||||||
|
retrofit2.Response<Result<Void>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
boolean ok = response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess();
|
||||||
|
if (ok) {
|
||||||
|
row.enabled = enable;
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
updateStats();
|
||||||
|
} else {
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
Toast.makeText(requireContext(), "操作失败",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
Toast.makeText(requireContext(), "操作失败: " + t.getMessage(),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一键执行场景 */
|
||||||
|
private void executeScene(RuleRow row) {
|
||||||
|
if (row.sceneId == null) return;
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getSceneApiService().executeScene(auth, row.sceneId)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<List<Map<String, Object>>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(
|
||||||
|
retrofit2.Call<Result<List<Map<String, Object>>>> call,
|
||||||
|
retrofit2.Response<Result<List<Map<String, Object>>>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
boolean ok = response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess();
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
ok ? "已执行「" + row.sceneName + "」" : "执行失败",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(
|
||||||
|
retrofit2.Call<Result<List<Map<String, Object>>>> call,
|
||||||
|
Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
Toast.makeText(requireContext(), "执行失败: " + t.getMessage(),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int dp(int v) {
|
||||||
|
return Math.round(getResources().getDisplayMetrics().density * v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 规则列表适配器 */
|
||||||
|
private class RuleAdapter extends RecyclerView.Adapter<RuleAdapter.VH> {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_automation, parent, false);
|
||||||
|
return new VH(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull VH h, int position) {
|
||||||
|
RuleRow row = rows.get(position);
|
||||||
|
h.tvIcon.setText(row.icon);
|
||||||
|
h.tvName.setText(row.ruleName);
|
||||||
|
String desc = (row.condition == null || row.condition.isEmpty()
|
||||||
|
? "手动触发" : row.condition)
|
||||||
|
+ " · " + row.actionDesc;
|
||||||
|
h.tvDesc.setText(desc);
|
||||||
|
h.vIconBg.setBackgroundResource(R.drawable.bg_icon_square_pastel);
|
||||||
|
android.graphics.drawable.Drawable iconBg = h.vIconBg.getBackground();
|
||||||
|
if (iconBg != null) {
|
||||||
|
iconBg = iconBg.mutate();
|
||||||
|
if (row.enabled) {
|
||||||
|
// 开启:12% 透明主色(设计稿 rgba(primary,0.12))
|
||||||
|
iconBg.setTint(android.graphics.Color.argb(
|
||||||
|
Math.round(255 * 0.12f),
|
||||||
|
android.graphics.Color.red(accentColor),
|
||||||
|
android.graphics.Color.green(accentColor),
|
||||||
|
android.graphics.Color.blue(accentColor)));
|
||||||
|
} else {
|
||||||
|
iconBg.setTint(requireContext().getColor(R.color.c_muted));
|
||||||
|
}
|
||||||
|
h.vIconBg.setBackground(iconBg);
|
||||||
|
}
|
||||||
|
h.pill.setAccentColor(accentColor);
|
||||||
|
h.pill.setChecked(row.enabled);
|
||||||
|
h.pill.setOnUserToggleListener(checked -> toggleRule(row, checked));
|
||||||
|
h.btnRun.getBackground().mutate().setTint(accentColor);
|
||||||
|
h.btnRun.setOnClickListener(v -> executeScene(row));
|
||||||
|
h.itemView.setOnClickListener(v -> {
|
||||||
|
// 进入场景详情管理规则
|
||||||
|
if (row.sceneId != null) {
|
||||||
|
Intent it = new Intent(requireContext(), SceneDetailActivity.class);
|
||||||
|
it.putExtra("sceneId", row.sceneId);
|
||||||
|
it.putExtra("sceneName", row.sceneName);
|
||||||
|
startActivity(it);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return rows.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
class VH extends RecyclerView.ViewHolder {
|
||||||
|
View vIconBg;
|
||||||
|
TextView tvIcon;
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvDesc;
|
||||||
|
TextView btnRun;
|
||||||
|
PillSwitch pill;
|
||||||
|
|
||||||
|
VH(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
vIconBg = itemView.findViewById(R.id.v_icon_bg);
|
||||||
|
tvIcon = itemView.findViewById(R.id.tv_scene_icon);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_scene_name);
|
||||||
|
tvDesc = itemView.findViewById(R.id.tv_scene_desc);
|
||||||
|
btnRun = itemView.findViewById(R.id.btn_run);
|
||||||
|
pill = itemView.findViewById(R.id.pill_switch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,145 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import com.thingclips.smart.home.sdk.ThingHomeSdk;
|
||||||
|
import com.thingclips.smart.sdk.api.IResultCallback;
|
||||||
|
import com.thingclips.smart.sdk.api.IThingDevice;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备操作对话框:长按设备弹出"编辑(重命名)/ 删除"
|
||||||
|
* SDK 操作 + 后端同步,供首页/设备页/详情页复用
|
||||||
|
*/
|
||||||
|
public final class DeviceActionDialogs {
|
||||||
|
|
||||||
|
public interface Callback {
|
||||||
|
/** 重命名成功(UI 更新名称) */
|
||||||
|
void onRenamed(String newName);
|
||||||
|
|
||||||
|
/** 删除成功(UI 移除该项) */
|
||||||
|
void onDeleted();
|
||||||
|
}
|
||||||
|
|
||||||
|
private DeviceActionDialogs() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹出"重命名 / 删除设备"选择 */
|
||||||
|
public static void showActions(Activity activity, String deviceId,
|
||||||
|
String deviceName, Callback callback) {
|
||||||
|
new androidx.appcompat.app.AlertDialog.Builder(activity)
|
||||||
|
.setTitle(deviceName)
|
||||||
|
.setItems(new String[]{"✏️ 重命名", "🗑 删除设备"}, (d, which) -> {
|
||||||
|
if (which == 0) {
|
||||||
|
showRenameDialog(activity, deviceId, deviceName, callback);
|
||||||
|
} else {
|
||||||
|
confirmDelete(activity, deviceId, deviceName, callback);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名对话框:SDK 改名 + 同步后端 */
|
||||||
|
public static void showRenameDialog(Activity activity, String deviceId,
|
||||||
|
String deviceName, Callback callback) {
|
||||||
|
EditText input = new EditText(activity);
|
||||||
|
input.setText(deviceName);
|
||||||
|
input.setSelection(input.getText().length());
|
||||||
|
input.setPadding(56, 32, 56, 32);
|
||||||
|
new androidx.appcompat.app.AlertDialog.Builder(activity)
|
||||||
|
.setTitle("重命名设备")
|
||||||
|
.setView(input)
|
||||||
|
.setPositiveButton("保存", (d, w) -> {
|
||||||
|
String newName = input.getText().toString().trim();
|
||||||
|
if (newName.isEmpty()) {
|
||||||
|
Toast.makeText(activity, "请输入名称", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
doRename(activity, deviceId, newName, callback);
|
||||||
|
})
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void doRename(Activity activity, String deviceId,
|
||||||
|
String newName, Callback callback) {
|
||||||
|
IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId);
|
||||||
|
device.renameDevice(newName, new IResultCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
syncRenameToBackend(activity, deviceId, newName);
|
||||||
|
activity.runOnUiThread(() -> {
|
||||||
|
Toast.makeText(activity, "重命名成功", Toast.LENGTH_SHORT).show();
|
||||||
|
if (callback != null) callback.onRenamed(newName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
activity.runOnUiThread(() -> Toast.makeText(activity,
|
||||||
|
"重命名失败: " + (error != null ? error : code),
|
||||||
|
Toast.LENGTH_LONG).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名后同步到后端 device 表 */
|
||||||
|
private static void syncRenameToBackend(Activity activity, String deviceId, String newName) {
|
||||||
|
try {
|
||||||
|
String auth = SessionManager.getInstance(activity).getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("deviceName", newName);
|
||||||
|
ApiClient.getApiService().renameTuyaDevice(auth, deviceId, body)
|
||||||
|
.enqueue(new retrofit2.Callback<com.xhai.iot.model.Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<com.xhai.iot.model.Result<Void>> call,
|
||||||
|
retrofit2.Response<com.xhai.iot.model.Result<Void>> response) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<com.xhai.iot.model.Result<Void>> call,
|
||||||
|
Throwable t) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除确认 + SDK 移除 */
|
||||||
|
private static void confirmDelete(Activity activity, String deviceId,
|
||||||
|
String deviceName, Callback callback) {
|
||||||
|
new androidx.appcompat.app.AlertDialog.Builder(activity)
|
||||||
|
.setTitle("删除设备")
|
||||||
|
.setMessage("确定删除设备「" + deviceName + "」吗?")
|
||||||
|
.setPositiveButton("删除", (d, w) -> doDelete(activity, deviceId, callback))
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void doDelete(Activity activity, String deviceId, Callback callback) {
|
||||||
|
IThingDevice device = ThingHomeSdk.newDeviceInstance(deviceId);
|
||||||
|
device.removeDevice(new IResultCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
activity.runOnUiThread(() -> {
|
||||||
|
Toast.makeText(activity, "设备已删除", Toast.LENGTH_SHORT).show();
|
||||||
|
if (callback != null) callback.onDeleted();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
activity.runOnUiThread(() -> Toast.makeText(activity,
|
||||||
|
"删除失败: " + (error != null ? error : code),
|
||||||
|
Toast.LENGTH_LONG).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
241
Android/app/src/main/java/com/xhai/iot/ui/DeviceActivity.java
Normal file
241
Android/app/src/main/java/com/xhai/iot/ui/DeviceActivity.java
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.recyclerview.widget.GridLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.ui.adapter.DeviceGridAdapter;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备列表页(用涂鸦 SDK 拉取设备,并同步到后端)
|
||||||
|
*/
|
||||||
|
public class DeviceActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private RecyclerView rvDevices;
|
||||||
|
private ProgressBar progressBar;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private TextView tvHomeName;
|
||||||
|
private TextView tvDeviceCount;
|
||||||
|
private SessionManager session;
|
||||||
|
private DeviceGridAdapter adapter;
|
||||||
|
private long currentHomeId;
|
||||||
|
private final List<TuyaSdkService.SdkHome> homeCache = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_device);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
initViews();
|
||||||
|
loadTuyaDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initViews() {
|
||||||
|
rvDevices = findViewById(R.id.rv_devices);
|
||||||
|
progressBar = findViewById(R.id.progress_devices);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
tvHomeName = findViewById(R.id.tv_home_name);
|
||||||
|
tvDeviceCount = findViewById(R.id.tv_device_count);
|
||||||
|
TextView tvAddDevice = findViewById(R.id.tv_add_device);
|
||||||
|
|
||||||
|
// "+" 添加设备(配网)
|
||||||
|
tvAddDevice.setOnClickListener(v ->
|
||||||
|
startActivity(new Intent(DeviceActivity.this, AddDeviceActivity.class)));
|
||||||
|
// 点击家庭名切换家庭
|
||||||
|
tvHomeName.setOnClickListener(v -> showHomeSwitchMenu(v));
|
||||||
|
// 管理入口(进设备管理页:重命名/删除)
|
||||||
|
TextView tvManage = findViewById(R.id.tv_manage);
|
||||||
|
tvManage.setOnClickListener(v -> {
|
||||||
|
Intent intent = new Intent(DeviceActivity.this, DeviceManagerActivity.class);
|
||||||
|
intent.putExtra("homeId", currentHomeId);
|
||||||
|
startActivity(intent);
|
||||||
|
});
|
||||||
|
|
||||||
|
adapter = new DeviceGridAdapter(new ArrayList<>(), (device, position) -> {
|
||||||
|
// 点击设备进入控制页
|
||||||
|
Intent intent = new Intent(DeviceActivity.this, DeviceControlActivity.class);
|
||||||
|
intent.putExtra("deviceId", device.getId());
|
||||||
|
intent.putExtra("deviceName", device.getName());
|
||||||
|
startActivity(intent);
|
||||||
|
});
|
||||||
|
rvDevices.setLayoutManager(new GridLayoutManager(this, 2));
|
||||||
|
rvDevices.setAdapter(adapter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用涂鸦 SDK 拉取设备列表 */
|
||||||
|
private void loadTuyaDevices() {
|
||||||
|
showLoading(true);
|
||||||
|
TuyaSdkService.getHomes(new TuyaSdkService.HomeListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<TuyaSdkService.SdkHome> homes) {
|
||||||
|
if (homes == null || homes.isEmpty()) {
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("暂无家庭");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
homeCache.clear();
|
||||||
|
homeCache.addAll(homes);
|
||||||
|
// 使用当前选中家庭(默认第一个)
|
||||||
|
TuyaSdkService.SdkHome home = null;
|
||||||
|
for (TuyaSdkService.SdkHome h : homeCache) {
|
||||||
|
if (h.homeId == currentHomeId) { home = h; break; }
|
||||||
|
}
|
||||||
|
if (home == null) home = homeCache.get(0);
|
||||||
|
currentHomeId = home.homeId;
|
||||||
|
tvHomeName.setText(home.name);
|
||||||
|
// 同步家庭给涂鸦 BizBundle(面板跳转依赖)
|
||||||
|
com.xhai.iot.service.BizBundleFamilyServiceImpl
|
||||||
|
.updateCurrentHomeId(currentHomeId, home.name);
|
||||||
|
// 拉取该家庭设备
|
||||||
|
TuyaSdkService.getDevices(home.homeId, new TuyaSdkService.DeviceListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<TuyaSdkService.SdkDevice> devices) {
|
||||||
|
showLoading(false);
|
||||||
|
// 先同步后端(必须在"空列表"分支之前):
|
||||||
|
// 涂鸦账号下设备被全部移除时,也要靠这次全量同步
|
||||||
|
// 把本地残留记录对账清理掉,否则旧设备会一直显示在列表里
|
||||||
|
syncToBackend(devices, currentHomeId);
|
||||||
|
if (devices == null || devices.isEmpty()) {
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("暂无设备");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tvDeviceCount.setText(devices.size() + " 台设备");
|
||||||
|
tvEmpty.setVisibility(View.GONE);
|
||||||
|
// 显示设备
|
||||||
|
displayDevices(devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("获取设备失败: " + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("获取家庭失败: " + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 家庭切换下拉菜单 */
|
||||||
|
private void showHomeSwitchMenu(View anchor) {
|
||||||
|
if (homeCache.isEmpty()) {
|
||||||
|
Toast.makeText(this, "暂无家庭", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
android.widget.PopupMenu popup = new android.widget.PopupMenu(this, anchor);
|
||||||
|
android.view.Menu menu = popup.getMenu();
|
||||||
|
int idx = 1;
|
||||||
|
for (TuyaSdkService.SdkHome home : homeCache) {
|
||||||
|
String label = (home.homeId == currentHomeId ? "✓ " : " ") + home.name;
|
||||||
|
menu.add(0, idx++, android.view.Menu.NONE, label);
|
||||||
|
}
|
||||||
|
popup.setOnMenuItemClickListener(item -> {
|
||||||
|
int homeIdx = item.getItemId() - 1;
|
||||||
|
if (homeIdx >= 0 && homeIdx < homeCache.size()) {
|
||||||
|
TuyaSdkService.SdkHome selected = homeCache.get(homeIdx);
|
||||||
|
currentHomeId = selected.homeId;
|
||||||
|
tvHomeName.setText(selected.name);
|
||||||
|
com.xhai.iot.service.BizBundleFamilyServiceImpl
|
||||||
|
.updateCurrentHomeId(currentHomeId, selected.name);
|
||||||
|
loadTuyaDevices();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
popup.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 显示设备(SDK 设备转成 UI 模型) */
|
||||||
|
private void displayDevices(List<TuyaSdkService.SdkDevice> devices) {
|
||||||
|
List<com.xhai.iot.model.TuyaDevice> uiDevices = new ArrayList<>();
|
||||||
|
for (TuyaSdkService.SdkDevice sdk : devices) {
|
||||||
|
com.xhai.iot.model.TuyaDevice dev = new com.xhai.iot.model.TuyaDevice();
|
||||||
|
dev.setId(sdk.devId);
|
||||||
|
dev.setName(sdk.name);
|
||||||
|
dev.setOnline(sdk.online);
|
||||||
|
dev.setCategory(sdk.category);
|
||||||
|
dev.setProductId(sdk.productId);
|
||||||
|
dev.setIcon(sdk.iconUrl);
|
||||||
|
uiDevices.add(dev);
|
||||||
|
}
|
||||||
|
adapter.update(uiDevices);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把设备同步到后端 device 表(全量同步,后端会据此对账清理) */
|
||||||
|
private void syncToBackend(List<TuyaSdkService.SdkDevice> devices, long homeId) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
|
||||||
|
// 构造设备详情列表(后端依赖前端数据)
|
||||||
|
List<Map<String, Object>> devList = new ArrayList<>();
|
||||||
|
if (devices != null) {
|
||||||
|
for (TuyaSdkService.SdkDevice sdk : devices) {
|
||||||
|
Map<String, Object> info = new HashMap<>();
|
||||||
|
info.put("deviceId", sdk.devId);
|
||||||
|
info.put("deviceName", sdk.name);
|
||||||
|
info.put("category", sdk.category);
|
||||||
|
info.put("online", sdk.online);
|
||||||
|
info.put("productId", sdk.productId);
|
||||||
|
devList.add(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("homeId", homeId);
|
||||||
|
body.put("devices", devList);
|
||||||
|
// 标记为全量同步:后端据此对账,清理已从涂鸦账号移除的设备。
|
||||||
|
// 只有 SDK 成功返回了完整列表才会走到这里,所以可以安全置 true;
|
||||||
|
// 空列表同样要发,否则被解绑/重置到其它账号的设备会永久残留。
|
||||||
|
body.put("fullSync", true);
|
||||||
|
|
||||||
|
ApiClient.getApiService().bindTuyaDevices(auth, body)
|
||||||
|
.enqueue(new Callback<Result<Integer>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Integer>> call, Response<Result<Integer>> response) {
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
// 同步成功(后端会自动对账:已不存在的设备会被清理)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Integer>> call, Throwable t) {
|
||||||
|
// 同步失败不阻塞显示
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLoading(boolean show) {
|
||||||
|
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,181 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.MenuItem;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.appcompat.widget.Toolbar;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.DeviceActivityItem;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操控动态列表页(首页"近期动态"点击进入)
|
||||||
|
* 分页加载 + 下拉刷新,支持滚动到底自动加载更多。
|
||||||
|
*/
|
||||||
|
public class DeviceActivityActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private RecyclerView rvActivities;
|
||||||
|
private SwipeRefreshLayout swipeRefresh;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private final List<DeviceActivityItem> items = new ArrayList<>();
|
||||||
|
private ActivityAdapter adapter;
|
||||||
|
|
||||||
|
private int pageNum = 1;
|
||||||
|
private boolean loading = false;
|
||||||
|
private boolean hasMore = true;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_device_activity);
|
||||||
|
|
||||||
|
Toolbar toolbar = findViewById(R.id.toolbar);
|
||||||
|
setSupportActionBar(toolbar);
|
||||||
|
if (getSupportActionBar() != null) {
|
||||||
|
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||||
|
getSupportActionBar().setTitle("操控动态");
|
||||||
|
}
|
||||||
|
|
||||||
|
rvActivities = findViewById(R.id.rv_activities);
|
||||||
|
swipeRefresh = findViewById(R.id.swipe_refresh);
|
||||||
|
adapter = new ActivityAdapter(items);
|
||||||
|
rvActivities.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvActivities.setAdapter(adapter);
|
||||||
|
|
||||||
|
swipeRefresh.setOnRefreshListener(this::refresh);
|
||||||
|
rvActivities.addOnScrollListener(new RecyclerView.OnScrollListener() {
|
||||||
|
@Override
|
||||||
|
public void onScrolled(@NonNull RecyclerView rv, int dx, int dy) {
|
||||||
|
LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
|
||||||
|
if (lm == null || loading || !hasMore) return;
|
||||||
|
int last = lm.findLastVisibleItemPosition();
|
||||||
|
if (last >= items.size() - 2 && items.size() > 0) {
|
||||||
|
pageNum++;
|
||||||
|
load(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void refresh() {
|
||||||
|
pageNum = 1;
|
||||||
|
hasMore = true;
|
||||||
|
load(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void load(boolean showSwipe) {
|
||||||
|
String auth = SessionManager.getInstance(this).getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
long homeId = SessionManager.getInstance(this).getCurrentHomeId();
|
||||||
|
loading = true;
|
||||||
|
if (showSwipe) swipeRefresh.setRefreshing(true);
|
||||||
|
ApiClient.getApiService()
|
||||||
|
.getDeviceActivities(auth, homeId > 0 ? homeId : null, null, pageNum, 20)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<PageData<DeviceActivityItem>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<PageData<DeviceActivityItem>>> call,
|
||||||
|
retrofit2.Response<Result<PageData<DeviceActivityItem>>> response) {
|
||||||
|
loading = false;
|
||||||
|
swipeRefresh.setRefreshing(false);
|
||||||
|
Result<PageData<DeviceActivityItem>> body = response.body();
|
||||||
|
if (body == null || !body.isSuccess() || body.getData() == null) {
|
||||||
|
if (items.isEmpty()) tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pageNum == 1) items.clear();
|
||||||
|
List<DeviceActivityItem> records = body.getData().getRecords();
|
||||||
|
if (records != null) items.addAll(records);
|
||||||
|
hasMore = body.getData().getTotal() > items.size();
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
if (items.isEmpty() && tvEmpty != null) {
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<PageData<DeviceActivityItem>>> call,
|
||||||
|
Throwable t) {
|
||||||
|
loading = false;
|
||||||
|
swipeRefresh.setRefreshing(false);
|
||||||
|
if (items.isEmpty() && tvEmpty != null) {
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onOptionsItemSelected(MenuItem item) {
|
||||||
|
if (item.getItemId() == android.R.id.home) {
|
||||||
|
finish();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.onOptionsItemSelected(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表适配器(复用首页动态行布局) */
|
||||||
|
private static class ActivityAdapter extends RecyclerView.Adapter<VH> {
|
||||||
|
private final List<DeviceActivityItem> items;
|
||||||
|
|
||||||
|
ActivityAdapter(List<DeviceActivityItem> items) {
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_activity_row, parent, false);
|
||||||
|
return new VH(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull VH h, int position) {
|
||||||
|
DeviceActivityItem item = items.get(position);
|
||||||
|
h.tvIcon.setText(item.icon());
|
||||||
|
h.tvDesc.setText(item.getActionDesc() != null ? item.getActionDesc()
|
||||||
|
: (item.getDeviceName() != null ? item.getDeviceName() : item.getDeviceId()));
|
||||||
|
h.tvSource.setText(item.sourceLabel());
|
||||||
|
h.tvTime.setText(item.relativeTime());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return items.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class VH extends RecyclerView.ViewHolder {
|
||||||
|
final TextView tvIcon, tvDesc, tvSource, tvTime;
|
||||||
|
|
||||||
|
VH(@NonNull View v) {
|
||||||
|
super(v);
|
||||||
|
tvIcon = v.findViewById(R.id.tv_act_icon);
|
||||||
|
tvDesc = v.findViewById(R.id.tv_act_desc);
|
||||||
|
tvSource = v.findViewById(R.id.tv_act_source);
|
||||||
|
tvTime = v.findViewById(R.id.tv_act_time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1203
Android/app/src/main/java/com/xhai/iot/ui/DeviceControlActivity.java
Normal file
1203
Android/app/src/main/java/com/xhai/iot/ui/DeviceControlActivity.java
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,198 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.thingclips.smart.home.sdk.ThingHomeSdk;
|
||||||
|
import com.thingclips.smart.sdk.api.IResultCallback;
|
||||||
|
import com.thingclips.smart.sdk.api.IThingDevice;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.ui.adapter.DeviceManageAdapter;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备管理页
|
||||||
|
* 家庭设备列表 + 重命名 + 删除
|
||||||
|
*/
|
||||||
|
public class DeviceManagerActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private long homeId;
|
||||||
|
private RecyclerView rvDevices;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private TextView tvDeviceCount;
|
||||||
|
private DeviceManageAdapter adapter;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_device_manager);
|
||||||
|
|
||||||
|
homeId = getIntent().getLongExtra("homeId", 0);
|
||||||
|
rvDevices = findViewById(R.id.rv_devices);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
tvDeviceCount = findViewById(R.id.tv_device_count);
|
||||||
|
TextView tvAddDevice = findViewById(R.id.tv_add_device);
|
||||||
|
|
||||||
|
// "+" 添加设备(配网)
|
||||||
|
tvAddDevice.setOnClickListener(v ->
|
||||||
|
startActivity(new Intent(this, AddDeviceActivity.class)));
|
||||||
|
|
||||||
|
adapter = new DeviceManageAdapter();
|
||||||
|
adapter.setOnEditListener(device -> showRenameDialog(device));
|
||||||
|
adapter.setOnDeleteListener(device -> confirmDelete(device));
|
||||||
|
rvDevices.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvDevices.setAdapter(adapter);
|
||||||
|
|
||||||
|
loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadDevices() {
|
||||||
|
TuyaSdkService.getDevices(homeId, new TuyaSdkService.DeviceListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<TuyaSdkService.SdkDevice> devices) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (devices == null || devices.isEmpty()) {
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvDeviceCount.setText("0 台设备");
|
||||||
|
} else {
|
||||||
|
tvEmpty.setVisibility(View.GONE);
|
||||||
|
tvDeviceCount.setText(devices.size() + " 台设备");
|
||||||
|
}
|
||||||
|
adapter.update(devices);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
runOnUiThread(() -> Toast.makeText(DeviceManagerActivity.this,
|
||||||
|
"加载设备失败: " + error, Toast.LENGTH_SHORT).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名对话框 */
|
||||||
|
private void showRenameDialog(TuyaSdkService.SdkDevice device) {
|
||||||
|
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||||
|
builder.setTitle("重命名设备");
|
||||||
|
final EditText etName = new EditText(this);
|
||||||
|
etName.setHint("请输入新名称");
|
||||||
|
etName.setText(device.name);
|
||||||
|
builder.setView(etName);
|
||||||
|
builder.setPositiveButton("确定", (dialog, which) -> {
|
||||||
|
String name = etName.getText().toString().trim();
|
||||||
|
if (TextUtils.isEmpty(name)) {
|
||||||
|
Toast.makeText(this, "请输入名称", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renameDevice(device, name);
|
||||||
|
});
|
||||||
|
builder.setNegativeButton("取消", null);
|
||||||
|
builder.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名设备(SDK 改名 + 同步后端) */
|
||||||
|
private void renameDevice(TuyaSdkService.SdkDevice device, String newName) {
|
||||||
|
try {
|
||||||
|
IThingDevice thingDevice = ThingHomeSdk.newDeviceInstance(device.devId);
|
||||||
|
thingDevice.renameDevice(newName, new IResultCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
// 涂鸦 SDK 改名成功,同步到本平台后端 device 表
|
||||||
|
syncRenameToBackend(device.devId, newName);
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
Toast.makeText(DeviceManagerActivity.this,
|
||||||
|
"重命名成功", Toast.LENGTH_SHORT).show();
|
||||||
|
loadDevices();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
runOnUiThread(() -> Toast.makeText(DeviceManagerActivity.this,
|
||||||
|
"重命名失败: " + (error != null ? error : code),
|
||||||
|
Toast.LENGTH_LONG).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Toast.makeText(this, "重命名失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名后同步到后端 device 表 */
|
||||||
|
private void syncRenameToBackend(String devId, String newName) {
|
||||||
|
String auth = SessionManager.getInstance(this).getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("deviceName", newName);
|
||||||
|
ApiClient.getApiService().renameTuyaDevice(auth, devId, body)
|
||||||
|
.enqueue(new Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Void>> call,
|
||||||
|
Response<Result<Void>> response) {
|
||||||
|
// 同步成功或失败都不影响前端显示
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Void>> call, Throwable t) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认删除设备 */
|
||||||
|
private void confirmDelete(TuyaSdkService.SdkDevice device) {
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("删除设备")
|
||||||
|
.setMessage("确定删除设备「" + device.name + "」吗?")
|
||||||
|
.setPositiveButton("删除", (dialog, which) -> removeDevice(device))
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除设备 */
|
||||||
|
private void removeDevice(TuyaSdkService.SdkDevice device) {
|
||||||
|
try {
|
||||||
|
IThingDevice thingDevice = ThingHomeSdk.newDeviceInstance(device.devId);
|
||||||
|
thingDevice.removeDevice(new IResultCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
Toast.makeText(DeviceManagerActivity.this,
|
||||||
|
"设备已删除", Toast.LENGTH_SHORT).show();
|
||||||
|
loadDevices();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
runOnUiThread(() -> Toast.makeText(DeviceManagerActivity.this,
|
||||||
|
"删除失败: " + (error != null ? error : code),
|
||||||
|
Toast.LENGTH_LONG).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
Toast.makeText(this, "删除失败: " + e.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
524
Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java
Normal file
524
Android/app/src/main/java/com/xhai/iot/ui/DevicesFragment.java
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.Editable;
|
||||||
|
import android.text.TextWatcher;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.BackendDevice;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.model.TuyaDevice;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.ui.adapter.DeviceRowAdapter;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备页(设计稿 DevicesPage)
|
||||||
|
* 标题统计 + 搜索 + 房间过滤 chips + 设备列表(行卡 + 胶囊开关直接控制)
|
||||||
|
*/
|
||||||
|
public class DevicesFragment extends Fragment {
|
||||||
|
|
||||||
|
private static final String[] ROOMS = {"全部", "客厅", "卧室", "厨房", "书房", "阳台"};
|
||||||
|
|
||||||
|
private RecyclerView rvDevices;
|
||||||
|
private ProgressBar progressBar;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private TextView tvSummary;
|
||||||
|
private EditText etSearch;
|
||||||
|
private LinearLayout llRoomChips;
|
||||||
|
|
||||||
|
private SessionManager session;
|
||||||
|
private DeviceRowAdapter adapter;
|
||||||
|
private List<TuyaDevice> allDevices = new ArrayList<>();
|
||||||
|
private String activeRoom = "全部";
|
||||||
|
private String keyword = "";
|
||||||
|
private int accentColor = 0xFF6B7280;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_devices, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
session = SessionManager.getInstance(requireContext());
|
||||||
|
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
|
||||||
|
|
||||||
|
rvDevices = view.findViewById(R.id.rv_devices);
|
||||||
|
progressBar = view.findViewById(R.id.progress_devices);
|
||||||
|
tvEmpty = view.findViewById(R.id.tv_empty);
|
||||||
|
tvSummary = view.findViewById(R.id.tv_devices_summary);
|
||||||
|
etSearch = view.findViewById(R.id.et_search);
|
||||||
|
llRoomChips = view.findViewById(R.id.ll_room_chips);
|
||||||
|
|
||||||
|
adapter = new DeviceRowAdapter((device, position) -> {
|
||||||
|
if (device.isGateway()) {
|
||||||
|
Intent gw = new Intent(requireContext(), GatewayPanelActivity.class);
|
||||||
|
gw.putExtra("deviceId", device.getId());
|
||||||
|
gw.putExtra("deviceName", device.getName());
|
||||||
|
gw.putExtra("online", device.isOnline());
|
||||||
|
startActivity(gw);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
com.xhai.iot.ui.panel.PanelRouter.openPanel(
|
||||||
|
(androidx.appcompat.app.AppCompatActivity) requireActivity(),
|
||||||
|
device.getId(),
|
||||||
|
device.getPlatform(),
|
||||||
|
device.getName());
|
||||||
|
});
|
||||||
|
adapter.setAccentColor(accentColor);
|
||||||
|
// 长按设备:编辑(重命名)/ 删除
|
||||||
|
adapter.setOnDeviceLongClickListener((device, position) ->
|
||||||
|
DeviceActionDialogs.showActions(requireActivity(),
|
||||||
|
device.getId(), device.getName(),
|
||||||
|
new DeviceActionDialogs.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onRenamed(String newName) {
|
||||||
|
if (rvDevices != null) {
|
||||||
|
rvDevices.post(() -> loadDevices());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDeleted() {
|
||||||
|
if (rvDevices != null) {
|
||||||
|
rvDevices.post(() -> loadDevices());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
rvDevices.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
|
rvDevices.setAdapter(adapter);
|
||||||
|
|
||||||
|
// 添加设备(染主题色)
|
||||||
|
View addBtn = view.findViewById(R.id.btn_add_device);
|
||||||
|
android.graphics.drawable.Drawable addBg = addBtn.getBackground();
|
||||||
|
if (addBg != null) {
|
||||||
|
addBg = addBg.mutate();
|
||||||
|
addBg.setTint(accentColor);
|
||||||
|
addBtn.setBackground(addBg);
|
||||||
|
}
|
||||||
|
addBtn.setOnClickListener(v ->
|
||||||
|
startActivity(new Intent(requireContext(), AddDeviceActivity.class)));
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
etSearch.addTextChangedListener(new TextWatcher() {
|
||||||
|
@Override public void beforeTextChanged(CharSequence s, int a, int b, int c) {}
|
||||||
|
@Override public void onTextChanged(CharSequence s, int a, int b, int c) {}
|
||||||
|
@Override
|
||||||
|
public void afterTextChanged(Editable s) {
|
||||||
|
keyword = s.toString().trim();
|
||||||
|
applyFilter();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 房间 chips
|
||||||
|
renderRoomChips();
|
||||||
|
|
||||||
|
// 设备状态变化时刷新(HomeFragment 已注册设备级监听,这里刷新 UI 即可)
|
||||||
|
TuyaSdkService.registerStatusCallback(statusCallback);
|
||||||
|
|
||||||
|
loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
refreshTheme();
|
||||||
|
// 每次回到设备页允许补同步一次(把涂鸦侧新增但本平台缺失的设备写回)
|
||||||
|
sdkMissingSyncDone = false;
|
||||||
|
loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 用 hide/show 切换时不走 onResume,需在此刷新主题色 */
|
||||||
|
@Override
|
||||||
|
public void onHiddenChanged(boolean hidden) {
|
||||||
|
super.onHiddenChanged(hidden);
|
||||||
|
if (!hidden && isAdded()) {
|
||||||
|
refreshTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新读取主题色并刷新所有强调控件 */
|
||||||
|
private void refreshTheme() {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
accentColor = ThemeManager.getInstance(requireContext()).accent().value;
|
||||||
|
adapter.setAccentColor(accentColor);
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
renderRoomChips();
|
||||||
|
// 添加按钮染主题色
|
||||||
|
View addBtn = getView() != null ? getView().findViewById(R.id.btn_add_device) : null;
|
||||||
|
if (addBtn != null && addBtn.getBackground() != null) {
|
||||||
|
android.graphics.drawable.Drawable bg = addBtn.getBackground().mutate();
|
||||||
|
bg.setTint(accentColor);
|
||||||
|
addBtn.setBackground(bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
super.onDestroyView();
|
||||||
|
TuyaSdkService.unregisterStatusCallback(statusCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private final TuyaSdkService.DeviceStatusCallback statusCallback =
|
||||||
|
new TuyaSdkService.DeviceStatusCallback() {
|
||||||
|
@Override
|
||||||
|
public void onDpUpdate(String devId, java.util.Map<String, Object> dps) {
|
||||||
|
if (rvDevices == null) return;
|
||||||
|
rvDevices.post(() -> {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
// 只更新该设备的 DP 并局部刷新该行。
|
||||||
|
// 原来每次 DP 变化都整表重载(网络请求 + 全量 notifyDataSetChanged),
|
||||||
|
// 会造成设备行重绘与顺序跳变,视觉上表现为"设备排列会闪"
|
||||||
|
TuyaDevice target = findDevice(devId);
|
||||||
|
if (target != null && dps != null) {
|
||||||
|
Map<String, Object> merged = target.getDps() == null
|
||||||
|
? new java.util.HashMap<>()
|
||||||
|
: new java.util.HashMap<>(target.getDps());
|
||||||
|
merged.putAll(dps);
|
||||||
|
target.setDps(merged);
|
||||||
|
}
|
||||||
|
adapter.refreshDevice(devId);
|
||||||
|
updateSummary();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onStatusChanged(String devId, boolean online) {
|
||||||
|
if (rvDevices == null) return;
|
||||||
|
rvDevices.post(() -> {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
TuyaDevice target = findDevice(devId);
|
||||||
|
if (target != null) {
|
||||||
|
target.setOnline(online);
|
||||||
|
}
|
||||||
|
// 在线状态会影响"在线/离线"分组排序,重新应用过滤(DiffUtil 增量,无闪烁)
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private void renderRoomChips() {
|
||||||
|
llRoomChips.removeAllViews();
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(requireContext());
|
||||||
|
for (String room : ROOMS) {
|
||||||
|
TextView chip = (TextView) inflater.inflate(
|
||||||
|
R.layout.item_room_chip, llRoomChips, false);
|
||||||
|
chip.setText(room);
|
||||||
|
chip.setOnClickListener(v -> {
|
||||||
|
activeRoom = room;
|
||||||
|
styleChips();
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
llRoomChips.addView(chip);
|
||||||
|
}
|
||||||
|
styleChips();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void styleChips() {
|
||||||
|
for (int i = 0; i < llRoomChips.getChildCount(); i++) {
|
||||||
|
View v = llRoomChips.getChildAt(i);
|
||||||
|
if (!(v instanceof TextView)) continue;
|
||||||
|
TextView chip = (TextView) v;
|
||||||
|
boolean active = ROOMS[i].equals(activeRoom);
|
||||||
|
chip.setBackgroundResource(active
|
||||||
|
? R.drawable.bg_chip_accent : R.drawable.bg_chip_muted);
|
||||||
|
if (active) {
|
||||||
|
// 染运行时主题色
|
||||||
|
android.graphics.drawable.Drawable bg = chip.getBackground();
|
||||||
|
if (bg != null) {
|
||||||
|
bg = bg.mutate();
|
||||||
|
bg.setTint(accentColor);
|
||||||
|
chip.setBackground(bg);
|
||||||
|
}
|
||||||
|
chip.setTextColor(0xFFFFFFFF);
|
||||||
|
} else {
|
||||||
|
chip.setTextColor(requireContext().getColor(R.color.c_muted_fg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设备上次显示顺序(deviceId → 序号),用于稳定排序,避免位置来回跳动 */
|
||||||
|
private final Map<String, Integer> orderMemo = new java.util.HashMap<>();
|
||||||
|
|
||||||
|
/** 按设备 id 查找设备 */
|
||||||
|
private TuyaDevice findDevice(String devId) {
|
||||||
|
if (devId == null) return null;
|
||||||
|
for (TuyaDevice d : allDevices) {
|
||||||
|
if (devId.equals(d.getId())) return d;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按房间(设备名近似匹配)+ 关键字过滤 */
|
||||||
|
private void applyFilter() {
|
||||||
|
List<TuyaDevice> filtered = new ArrayList<>();
|
||||||
|
for (TuyaDevice d : allDevices) {
|
||||||
|
boolean mr = "全部".equals(activeRoom)
|
||||||
|
|| (d.getName() != null && d.getName().contains(activeRoom));
|
||||||
|
boolean ms = keyword.isEmpty()
|
||||||
|
|| (d.getName() != null && d.getName().contains(keyword))
|
||||||
|
|| (d.getCategory() != null && d.getCategory().contains(keyword));
|
||||||
|
if (mr && ms) filtered.add(d);
|
||||||
|
}
|
||||||
|
// 在线设备排前面,离线排后面;组内保持"上次显示顺序"。
|
||||||
|
// 后端返回顺序不稳定时会造成设备位置来回跳动(视觉上的"排列会闪"),故用 orderMemo 固定
|
||||||
|
final Map<String, Integer> memo = orderMemo;
|
||||||
|
filtered.sort((a, b) -> {
|
||||||
|
int c = Boolean.compare(!a.isOnline(), !b.isOnline());
|
||||||
|
if (c != 0) return c;
|
||||||
|
int oa = memo.getOrDefault(a.getId(), Integer.MAX_VALUE);
|
||||||
|
int ob = memo.getOrDefault(b.getId(), Integer.MAX_VALUE);
|
||||||
|
if (oa != ob) return Integer.compare(oa, ob);
|
||||||
|
String na = a.getName() != null ? a.getName() : "";
|
||||||
|
String nb = b.getName() != null ? b.getName() : "";
|
||||||
|
return na.compareTo(nb);
|
||||||
|
});
|
||||||
|
// 记录本次顺序,作为下次的稳定排序依据
|
||||||
|
orderMemo.clear();
|
||||||
|
for (int i = 0; i < filtered.size(); i++) {
|
||||||
|
if (filtered.get(i).getId() != null) {
|
||||||
|
orderMemo.put(filtered.get(i).getId(), i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
adapter.update(filtered);
|
||||||
|
updateSummary();
|
||||||
|
tvEmpty.setVisibility(filtered.isEmpty() ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateSummary() {
|
||||||
|
int on = 0;
|
||||||
|
for (TuyaDevice d : allDevices) {
|
||||||
|
if (d.isOnline() && hasSwitchOn(d)) on++;
|
||||||
|
}
|
||||||
|
tvSummary.setText(allDevices.size() + " 台设备 · " + on + " 台运行中");
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean hasSwitchOn(TuyaDevice d) {
|
||||||
|
try {
|
||||||
|
String dpId = TuyaSdkService.findSwitchDpId(d.getId());
|
||||||
|
if (dpId == null || d.getDps() == null) return false;
|
||||||
|
Map<String, Object> probe = new java.util.HashMap<>();
|
||||||
|
probe.put(dpId, true);
|
||||||
|
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(d.getId(), probe);
|
||||||
|
Object v = null;
|
||||||
|
if (codeMap != null && !codeMap.isEmpty()) {
|
||||||
|
v = d.getDps().get(codeMap.keySet().iterator().next());
|
||||||
|
}
|
||||||
|
if (v == null) v = d.getDps().get(dpId);
|
||||||
|
return Boolean.TRUE.equals(v);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadDevices() {
|
||||||
|
// 已有数据时不再显示 loading,避免每次刷新闪一下
|
||||||
|
if (allDevices.isEmpty()) showLoading(true);
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("请先登录");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Long homeId = session.getCurrentHomeId() > 0
|
||||||
|
? session.getCurrentHomeId() : null;
|
||||||
|
ApiClient.getApiService().getDevices(auth, 1, 100, null, null, homeId)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<PageData<BackendDevice>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<PageData<BackendDevice>>> call,
|
||||||
|
retrofit2.Response<Result<PageData<BackendDevice>>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
showLoading(false);
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
List<BackendDevice> list = response.body().getData() != null
|
||||||
|
? response.body().getData().getRecords() : null;
|
||||||
|
allDevices = toTuyaDevices(list);
|
||||||
|
applyFilter();
|
||||||
|
// 补同步:涂鸦侧已存在但本平台缺失的设备(如蓝牙配网/涂鸦App新增)写回后端
|
||||||
|
maybeSyncMissingSdkDevices(homeId, list);
|
||||||
|
} else {
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("获取设备失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<PageData<BackendDevice>>> call,
|
||||||
|
Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
showLoading(false);
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
tvEmpty.setText("连接服务器失败: " + t.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每次进入页面只允许补同步一次,避免「刷新→同步→刷新」递归 */
|
||||||
|
private boolean sdkMissingSyncDone = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦侧设备已存在、但本平台 device 表缺失时,增量写回后端后刷新列表。
|
||||||
|
* 典型场景:蓝牙配网成功但没落库、或在涂鸦官方 App 里新增的设备。
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* platformHomeId 是本平台家庭ID(用于写库与过滤);
|
||||||
|
* 读取设备要用「涂鸦侧默认家庭」的 homeId —— 两套 ID 体系不同,不能混用。
|
||||||
|
*/
|
||||||
|
private void maybeSyncMissingSdkDevices(final Long platformHomeId, List<BackendDevice> backendList) {
|
||||||
|
if (sdkMissingSyncDone || platformHomeId == null || !isAdded()) return;
|
||||||
|
sdkMissingSyncDone = true;
|
||||||
|
com.xhai.iot.service.TuyaSdkService.getHomes(
|
||||||
|
new com.xhai.iot.service.TuyaSdkService.HomeListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<com.xhai.iot.service.TuyaSdkService.SdkHome> homes) {
|
||||||
|
if (!isAdded() || homes == null || homes.isEmpty()) return;
|
||||||
|
// 涂鸦侧统一默认家庭(设备数最多)
|
||||||
|
com.xhai.iot.service.TuyaSdkService.SdkHome best = homes.get(0);
|
||||||
|
for (com.xhai.iot.service.TuyaSdkService.SdkHome h : homes) {
|
||||||
|
if (h != null && best != null && h.deviceCount > best.deviceCount) best = h;
|
||||||
|
}
|
||||||
|
final long tuyaHomeId = (best == null) ? 0L : best.homeId;
|
||||||
|
if (tuyaHomeId <= 0) return;
|
||||||
|
com.xhai.iot.service.TuyaSdkService.getDevices(tuyaHomeId,
|
||||||
|
new com.xhai.iot.service.TuyaSdkService.DeviceListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<com.xhai.iot.service.TuyaSdkService.SdkDevice> devices) {
|
||||||
|
bindMissingDevices(platformHomeId, backendList, devices);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
android.util.Log.w("DevicesFragment", "补同步读取SDK设备失败: " + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
android.util.Log.w("DevicesFragment", "补同步读取家庭失败: " + error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bindMissingDevices(Long platformHomeId, List<BackendDevice> backendList,
|
||||||
|
List<com.xhai.iot.service.TuyaSdkService.SdkDevice> devices) {
|
||||||
|
if (!isAdded() || devices == null || devices.isEmpty()) return;
|
||||||
|
java.util.Set<String> known = new java.util.HashSet<>();
|
||||||
|
if (backendList != null) {
|
||||||
|
for (BackendDevice b : backendList) {
|
||||||
|
if (b.getDeviceId() != null) known.add(b.getDeviceId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
java.util.List<java.util.Map<String, Object>> missing = new java.util.ArrayList<>();
|
||||||
|
for (com.xhai.iot.service.TuyaSdkService.SdkDevice d : devices) {
|
||||||
|
if (d == null || d.devId == null || known.contains(d.devId)) continue;
|
||||||
|
java.util.Map<String, Object> info = new java.util.HashMap<>();
|
||||||
|
info.put("deviceId", d.devId);
|
||||||
|
info.put("deviceName", d.name);
|
||||||
|
info.put("category", d.category);
|
||||||
|
info.put("online", d.online);
|
||||||
|
info.put("productId", d.productId);
|
||||||
|
missing.add(info);
|
||||||
|
}
|
||||||
|
if (missing.isEmpty()) return;
|
||||||
|
android.util.Log.i("DevicesFragment", "补同步缺失设备 count=" + missing.size());
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
java.util.Map<String, Object> body = new java.util.HashMap<>();
|
||||||
|
body.put("homeId", platformHomeId);
|
||||||
|
body.put("devices", missing);
|
||||||
|
body.put("fullSync", false);
|
||||||
|
com.xhai.iot.data.ApiClient.getApiService().bindTuyaDevices(auth, body)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<Integer>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<Integer>> call,
|
||||||
|
retrofit2.Response<Result<Integer>> response) {
|
||||||
|
if (isAdded()) loadDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<Integer>> call, Throwable t) {
|
||||||
|
android.util.Log.w("DevicesFragment", "补同步失败: " + t.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TuyaDevice> toTuyaDevices(List<BackendDevice> list) {
|
||||||
|
List<TuyaDevice> result = new ArrayList<>();
|
||||||
|
if (list == null) return result;
|
||||||
|
for (BackendDevice bd : list) {
|
||||||
|
TuyaDevice td = new TuyaDevice();
|
||||||
|
td.setId(bd.getDeviceId());
|
||||||
|
td.setName(bd.getDeviceName());
|
||||||
|
td.setOnline(bd.isOnline());
|
||||||
|
td.setIcon(bd.getIcon());
|
||||||
|
td.setPlatform(bd.getPlatform());
|
||||||
|
td.setParentDeviceId(bd.getParentDeviceId());
|
||||||
|
td.setIsGateway(bd.getIsGateway());
|
||||||
|
// 后端设备类型作为兜底(涂鸦设备若 SDK 有 category 会覆盖)
|
||||||
|
if (bd.getDeviceType() != null && !bd.getDeviceType().isEmpty()) {
|
||||||
|
td.setCategory(bd.getDeviceType());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
com.thingclips.smart.sdk.bean.DeviceBean devBean =
|
||||||
|
com.thingclips.smart.home.sdk.ThingHomeSdk.getDataInstance()
|
||||||
|
.getDeviceBean(bd.getDeviceId());
|
||||||
|
if (devBean != null) {
|
||||||
|
// 在线状态以涂鸦 SDK 实时数据为准(后端 status 可能未及时刷新)
|
||||||
|
boolean sdkOnline = com.xhai.iot.service.TuyaSdkService.resolveDeviceOnline(
|
||||||
|
bd.getDeviceId(), devBean, bd.isOnline(), bd.getParentDeviceId());
|
||||||
|
td.setOnline(sdkOnline);
|
||||||
|
if (sdkOnline != bd.isOnline()) {
|
||||||
|
com.xhai.iot.service.TuyaSdkService
|
||||||
|
.reportStatusToBackend(bd.getDeviceId(), sdkOnline, null);
|
||||||
|
}
|
||||||
|
if (devBean.getDps() != null) {
|
||||||
|
td.setDps(devBean.getDps());
|
||||||
|
}
|
||||||
|
if (devBean.getCategory() != null && !devBean.getCategory().isEmpty()) {
|
||||||
|
td.setCategory(devBean.getCategory());
|
||||||
|
}
|
||||||
|
if (devBean.getProductId() != null) {
|
||||||
|
td.setProductId(devBean.getProductId());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
result.add(td);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLoading(boolean show) {
|
||||||
|
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发现页面(AI 应用列表)
|
||||||
|
* 纯静态展示:AI 应用列表(图标 + 标题 + 描述 + 箭头)
|
||||||
|
*/
|
||||||
|
public class DiscoverFragment extends Fragment {
|
||||||
|
|
||||||
|
/** AI 应用列表(标题 + 描述 + 表情符号图标) */
|
||||||
|
private static final AiItem[] ITEMS = {
|
||||||
|
new AiItem("🛡️", "AI 安全守护", "全天候智能安全诊断,精准识别潜在风险"),
|
||||||
|
new AiItem("☁️", "AI 云存储", "录像云端存储,支持自定义告警、智能检索"),
|
||||||
|
new AiItem("⚡", "AI 节能", "了解家庭智能设备的能耗状况,获取节能建议"),
|
||||||
|
new AiItem("💬", "短信和电话通知", "接收设备告警和场景通知消息"),
|
||||||
|
new AiItem("💡", "AI 调光", "快速生成个性化灯效组合,高效打造灯光场景"),
|
||||||
|
new AiItem("🥗", "AI 卡路里", "扫一扫识别食物卡路里"),
|
||||||
|
new AiItem("📔", "AI 笔记", "录音转写,一键总结"),
|
||||||
|
new AiItem("🅰️", "AI 翻译", "AI 大模型驱动,准确又自然"),
|
||||||
|
};
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_discover, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
LinearLayout container = view.findViewById(R.id.ll_discover_container);
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(requireContext());
|
||||||
|
|
||||||
|
// 插入分隔区
|
||||||
|
for (int i = 0; i < ITEMS.length; i++) {
|
||||||
|
if (i > 0) {
|
||||||
|
View spacer = new View(requireContext());
|
||||||
|
LinearLayout.LayoutParams sp = new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, 12);
|
||||||
|
container.addView(spacer, sp);
|
||||||
|
}
|
||||||
|
View item = inflater.inflate(R.layout.item_discover_ai, container, false);
|
||||||
|
TextView ivIcon = item.findViewById(R.id.iv_ai_icon);
|
||||||
|
TextView tvTitle = item.findViewById(R.id.tv_ai_title);
|
||||||
|
TextView tvDesc = item.findViewById(R.id.tv_ai_desc);
|
||||||
|
AiItem ai = ITEMS[i];
|
||||||
|
ivIcon.setText(ai.icon);
|
||||||
|
tvTitle.setText(ai.title);
|
||||||
|
tvDesc.setText(ai.desc);
|
||||||
|
container.addView(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AI 应用数据 */
|
||||||
|
private static class AiItem {
|
||||||
|
final String icon;
|
||||||
|
final String title;
|
||||||
|
final String desc;
|
||||||
|
|
||||||
|
AiItem(String icon, String title, String desc) {
|
||||||
|
this.icon = icon;
|
||||||
|
this.title = title;
|
||||||
|
this.desc = desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,280 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.Switch;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.bumptech.glide.Glide;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.BackendDevice;
|
||||||
|
import com.xhai.iot.model.DevicePlatform;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.PlatformDevice;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.service.IPlatformService;
|
||||||
|
import com.xhai.iot.service.PlatformServiceManager;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 网关面板(我们自己的网关页面,不依赖涂鸦原生面板)
|
||||||
|
*
|
||||||
|
* 架构:网关与子设备的父子关系由我们后端维护(device.parent_device_id),
|
||||||
|
* 该页面展示某网关下的子设备列表(真实数量/在线状态),
|
||||||
|
* 点击子设备进入通用控制面板(DeviceControlActivity,涂鸦 SDK 控制)。
|
||||||
|
*/
|
||||||
|
public class GatewayPanelActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final int REQ_ADD_SUB = 1001;
|
||||||
|
|
||||||
|
private String gatewayId;
|
||||||
|
private String gatewayName;
|
||||||
|
private SessionManager session;
|
||||||
|
|
||||||
|
private TextView tvTitle;
|
||||||
|
private TextView tvGatewayName;
|
||||||
|
private TextView tvGatewayStatus;
|
||||||
|
private TextView tvSubDeviceCount;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private RecyclerView rvSubDevices;
|
||||||
|
private SubDeviceAdapter adapter;
|
||||||
|
|
||||||
|
private final List<BackendDevice> subDevices = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_gateway_panel);
|
||||||
|
|
||||||
|
gatewayId = getIntent().getStringExtra("deviceId");
|
||||||
|
gatewayName = getIntent().getStringExtra("deviceName");
|
||||||
|
if (gatewayId == null) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
|
||||||
|
tvTitle = findViewById(R.id.tv_gateway_title);
|
||||||
|
tvGatewayName = findViewById(R.id.tv_gateway_name);
|
||||||
|
tvGatewayStatus = findViewById(R.id.tv_gateway_status);
|
||||||
|
tvSubDeviceCount = findViewById(R.id.tv_sub_device_count);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
rvSubDevices = findViewById(R.id.rv_sub_devices);
|
||||||
|
|
||||||
|
tvTitle.setText(gatewayName != null ? gatewayName : "网关");
|
||||||
|
tvGatewayName.setText(gatewayName != null ? gatewayName : "网关");
|
||||||
|
findViewById(R.id.tv_back).setOnClickListener(v -> finish());
|
||||||
|
|
||||||
|
// 网关在线状态从 Intent 带过(默认在线,稍后从子设备统计可校验)
|
||||||
|
boolean online = getIntent().getBooleanExtra("online", true);
|
||||||
|
updateGatewayStatus(online);
|
||||||
|
|
||||||
|
adapter = new SubDeviceAdapter();
|
||||||
|
rvSubDevices.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvSubDevices.setAdapter(adapter);
|
||||||
|
|
||||||
|
// 添加子设备:进入添加页面(选择未归属设备绑定,或等待真实配网入网后刷新)
|
||||||
|
findViewById(R.id.btn_add_sub_device).setOnClickListener(v -> {
|
||||||
|
android.content.Intent it = new android.content.Intent(this,
|
||||||
|
AddSubDeviceActivity.class);
|
||||||
|
it.putExtra("deviceId", gatewayId);
|
||||||
|
it.putExtra("deviceName", gatewayName);
|
||||||
|
startActivityForResult(it, REQ_ADD_SUB);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
// 每次回到网关面板刷新子设备(从添加页返回/添加成功后显示最新)
|
||||||
|
loadSubDevices();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode,
|
||||||
|
@Nullable android.content.Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode == REQ_ADD_SUB && resultCode == RESULT_OK) {
|
||||||
|
loadSubDevices();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateGatewayStatus(boolean online) {
|
||||||
|
tvGatewayStatus.setText(online ? "● 在线" : "● 离线");
|
||||||
|
tvGatewayStatus.setTextColor(online ? 0xFF07C160 : 0xFF909399);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从后端查该网关下的子设备(device.parent_device_id = 网关id) */
|
||||||
|
private void loadSubDevices() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getApiService().getDevices(auth, 1, 200, gatewayId, null, null)
|
||||||
|
.enqueue(new Callback<Result<PageData<BackendDevice>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<PageData<BackendDevice>>> call,
|
||||||
|
Response<Result<PageData<BackendDevice>>> response) {
|
||||||
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
|
handleResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<PageData<BackendDevice>>> call, Throwable t) {
|
||||||
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
|
tvEmpty.setText("加载子设备失败: " + t.getMessage());
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleResult(Response<Result<PageData<BackendDevice>>> response) {
|
||||||
|
if (response.isSuccessful() && response.body() != null && response.body().isSuccess()
|
||||||
|
&& response.body().getData() != null) {
|
||||||
|
List<BackendDevice> list = response.body().getData().getRecords();
|
||||||
|
subDevices.clear();
|
||||||
|
if (list != null) subDevices.addAll(list);
|
||||||
|
adapter.notifyDataSetChanged();
|
||||||
|
int onlineCount = 0;
|
||||||
|
for (BackendDevice d : subDevices) {
|
||||||
|
if (d.isOnline()) onlineCount++;
|
||||||
|
}
|
||||||
|
tvSubDeviceCount.setText("在线 " + onlineCount + " 个 / 共 " + subDevices.size() + " 个");
|
||||||
|
tvEmpty.setVisibility(subDevices.isEmpty() ? View.VISIBLE : View.GONE);
|
||||||
|
} else {
|
||||||
|
tvEmpty.setText("获取子设备失败");
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子设备列表适配器 */
|
||||||
|
private class SubDeviceAdapter extends RecyclerView.Adapter<SubDeviceAdapter.VH> {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_gateway_sub_device, parent, false);
|
||||||
|
return new VH(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull VH holder, int position) {
|
||||||
|
BackendDevice d = subDevices.get(position);
|
||||||
|
holder.tvName.setText(d.getDeviceName() != null ? d.getDeviceName() : d.getDeviceId());
|
||||||
|
String type = d.getDeviceType() != null && !d.getDeviceType().isEmpty()
|
||||||
|
? d.getDeviceType() : d.getPlatform();
|
||||||
|
boolean online = d.isOnline();
|
||||||
|
holder.tvStatus.setText((type != null ? type : "设备") + " · " + (online ? "在线" : "离线"));
|
||||||
|
holder.tvStatus.setTextColor(online ? 0xFF07C160 : 0xFF909399);
|
||||||
|
|
||||||
|
// 加载子设备图标
|
||||||
|
String icon = d.getIcon();
|
||||||
|
if (icon != null && !icon.isEmpty()) {
|
||||||
|
Glide.with(holder.itemView.getContext())
|
||||||
|
.load(icon)
|
||||||
|
.placeholder(R.drawable.ic_device_default)
|
||||||
|
.error(R.drawable.ic_device_default)
|
||||||
|
.into(holder.ivIcon);
|
||||||
|
} else {
|
||||||
|
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 快捷开关(tuya 在线且有开关功能才显示)
|
||||||
|
holder.swQuick.setOnCheckedChangeListener(null);
|
||||||
|
holder.swQuick.setVisibility(View.GONE);
|
||||||
|
boolean canSwitch = "tuya".equals(d.getPlatform()) && online;
|
||||||
|
String switchDpId = canSwitch
|
||||||
|
? TuyaSdkService.findSwitchDpId(d.getDeviceId()) : null;
|
||||||
|
if (switchDpId != null) {
|
||||||
|
holder.swQuick.setVisibility(View.VISIBLE);
|
||||||
|
holder.swQuick.setEnabled(true);
|
||||||
|
holder.swQuick.setChecked(false);
|
||||||
|
holder.swQuick.setOnCheckedChangeListener((b, isChecked) -> {
|
||||||
|
if (!b.isPressed()) return;
|
||||||
|
Map<String, Object> cmd = new HashMap<>();
|
||||||
|
cmd.put(switchDpId, isChecked);
|
||||||
|
PlatformServiceManager.getInstance(GatewayPanelActivity.this)
|
||||||
|
.controlDevice(asPlatformDevice(d), cmd,
|
||||||
|
new IPlatformService.ControlCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
android.util.Log.i("GatewayPanel",
|
||||||
|
"子设备控制成功: " + d.getDeviceId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
holder.swQuick.setChecked(!isChecked);
|
||||||
|
Toast.makeText(GatewayPanelActivity.this,
|
||||||
|
"控制失败: " + (error != null ? error : code),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 子设备点击:进入控制面板(通用面板/原生面板)
|
||||||
|
holder.itemView.setOnClickListener(v -> com.xhai.iot.ui.panel.PanelRouter.openPanel(
|
||||||
|
GatewayPanelActivity.this,
|
||||||
|
d.getDeviceId(),
|
||||||
|
d.getPlatform(),
|
||||||
|
d.getDeviceName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return subDevices.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
class VH extends RecyclerView.ViewHolder {
|
||||||
|
ImageView ivIcon;
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvStatus;
|
||||||
|
Switch swQuick;
|
||||||
|
|
||||||
|
VH(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
ivIcon = itemView.findViewById(R.id.iv_sub_icon);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_sub_name);
|
||||||
|
tvStatus = itemView.findViewById(R.id.tv_sub_status);
|
||||||
|
swQuick = itemView.findViewById(R.id.sw_sub_quick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BackendDevice → PlatformDevice(控制链路用) */
|
||||||
|
private PlatformDevice asPlatformDevice(BackendDevice d) {
|
||||||
|
PlatformDevice pd = new PlatformDevice();
|
||||||
|
pd.setDeviceId(d.getDeviceId());
|
||||||
|
pd.setName(d.getDeviceName());
|
||||||
|
pd.setPlatform(DevicePlatform.TUYA);
|
||||||
|
pd.setOnline(d.isOnline());
|
||||||
|
pd.setIconUrl(d.getIcon());
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
}
|
||||||
1135
Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java
Normal file
1135
Android/app/src/main/java/com/xhai/iot/ui/HomeFragment.java
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,209 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.BackendHome;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.ui.adapter.HomeListAdapter;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家庭管理页(数据源:本平台后端 /api/v1/homes,不对接涂鸦)
|
||||||
|
* 家庭列表 + 添加家庭 + 删除家庭
|
||||||
|
*/
|
||||||
|
public class HomeManagerActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private RecyclerView rvHomes;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private HomeListAdapter adapter;
|
||||||
|
private SessionManager session;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
setContentView(R.layout.activity_home_manager);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
|
||||||
|
rvHomes = findViewById(R.id.rv_homes);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
TextView tvAddHome = findViewById(R.id.tv_add_home);
|
||||||
|
|
||||||
|
adapter = new HomeListAdapter();
|
||||||
|
adapter.setOnDeleteListener((home, position) -> confirmDelete(home));
|
||||||
|
rvHomes.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvHomes.setAdapter(adapter);
|
||||||
|
|
||||||
|
tvAddHome.setOnClickListener(v -> showAddHomeDialog());
|
||||||
|
|
||||||
|
loadHomes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从后端加载我的家庭列表 */
|
||||||
|
private void loadHomes() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getApiService().getMyHomes(auth, session.getMemberId())
|
||||||
|
.enqueue(new retrofit2.Callback<Result<List<BackendHome>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<List<BackendHome>>> call,
|
||||||
|
retrofit2.Response<Result<List<BackendHome>>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
List<BackendHome> homes = response.isSuccessful()
|
||||||
|
&& response.body() != null && response.body().isSuccess()
|
||||||
|
? response.body().getData() : null;
|
||||||
|
if (homes == null) {
|
||||||
|
Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"加载家庭失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
tvEmpty.setVisibility(homes.isEmpty()
|
||||||
|
? View.VISIBLE : View.GONE);
|
||||||
|
adapter.update(toSdkHomes(homes));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<List<BackendHome>>> call,
|
||||||
|
Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
runOnUiThread(() -> Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"加载家庭失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端家庭 → 列表适配器模型(复用现有 UI) */
|
||||||
|
private List<TuyaSdkService.SdkHome> toSdkHomes(List<BackendHome> homes) {
|
||||||
|
List<TuyaSdkService.SdkHome> result = new ArrayList<>();
|
||||||
|
if (homes != null) {
|
||||||
|
for (BackendHome h : homes) {
|
||||||
|
if (h.getId() == null) continue;
|
||||||
|
result.add(new TuyaSdkService.SdkHome(h.getId(),
|
||||||
|
h.getName() != null ? h.getName() : "未命名家庭",
|
||||||
|
h.getDeviceCount() != null ? h.getDeviceCount() : 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加家庭对话框 */
|
||||||
|
private void showAddHomeDialog() {
|
||||||
|
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||||
|
builder.setTitle("添加家庭");
|
||||||
|
|
||||||
|
final EditText etName = new EditText(this);
|
||||||
|
etName.setHint("请输入家庭名称");
|
||||||
|
builder.setView(etName);
|
||||||
|
builder.setPositiveButton("确定", (dialog, which) -> {
|
||||||
|
String name = etName.getText().toString().trim();
|
||||||
|
if (TextUtils.isEmpty(name)) {
|
||||||
|
Toast.makeText(this, "请输入家庭名称", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createHome(name);
|
||||||
|
});
|
||||||
|
builder.setNegativeButton("取消", null);
|
||||||
|
builder.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建家庭(后端) */
|
||||||
|
private void createHome(String name) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("name", name);
|
||||||
|
ApiClient.getApiService().createHome(auth, session.getMemberId(), body)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<BackendHome>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<BackendHome>> call,
|
||||||
|
retrofit2.Response<Result<BackendHome>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"家庭创建成功", Toast.LENGTH_SHORT).show();
|
||||||
|
loadHomes();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"创建失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<BackendHome>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"创建失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认删除家庭 */
|
||||||
|
private void confirmDelete(TuyaSdkService.SdkHome home) {
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("删除家庭")
|
||||||
|
.setMessage("确定删除家庭「" + home.name + "」吗?")
|
||||||
|
.setPositiveButton("删除", (dialog, which) -> removeHome(home))
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除家庭(后端,仅 owner) */
|
||||||
|
private void removeHome(TuyaSdkService.SdkHome home) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getApiService().deleteHome(auth, home.homeId, session.getMemberId())
|
||||||
|
.enqueue(new retrofit2.Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<Void>> call,
|
||||||
|
retrofit2.Response<Result<Void>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"家庭已删除", Toast.LENGTH_SHORT).show();
|
||||||
|
loadHomes();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"删除失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(HomeManagerActivity.this,
|
||||||
|
"删除失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
194
Android/app/src/main/java/com/xhai/iot/ui/LoginActivity.java
Normal file
194
Android/app/src/main/java/com/xhai/iot/ui/LoginActivity.java
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.Editable;
|
||||||
|
import android.text.InputType;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.text.TextWatcher;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.ImageButton;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.LoginData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员登录页(对接 iotsaas 后端)
|
||||||
|
*/
|
||||||
|
public class LoginActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private EditText etUsername;
|
||||||
|
private EditText etPassword;
|
||||||
|
private Button btnLogin;
|
||||||
|
private ProgressBar progressBar;
|
||||||
|
private ImageButton btnClearPassword;
|
||||||
|
private ImageButton btnTogglePassword;
|
||||||
|
private boolean passwordVisible = false;
|
||||||
|
private SessionManager session;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_login);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
|
||||||
|
// 首次启动请求把 MumIot 固定到主屏幕(登录页弹出,避免被后续跳转覆盖)
|
||||||
|
android.content.SharedPreferences sp = getSharedPreferences("home_shortcut", MODE_PRIVATE);
|
||||||
|
if (!sp.getBoolean("mumiot_pinned_requested", false)) {
|
||||||
|
com.xhai.iot.util.HomeShortcutHelper.requestPinMumIot(this);
|
||||||
|
sp.edit().putBoolean("mumiot_pinned_requested", true).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已登录直接进入主界面
|
||||||
|
if (session.isLoggedIn()) {
|
||||||
|
goMain();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
initViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initViews() {
|
||||||
|
etUsername = findViewById(R.id.et_username);
|
||||||
|
etPassword = findViewById(R.id.et_password);
|
||||||
|
btnLogin = findViewById(R.id.btn_login);
|
||||||
|
progressBar = findViewById(R.id.progress_login);
|
||||||
|
TextView btnRegister = findViewById(R.id.btn_go_register);
|
||||||
|
btnClearPassword = findViewById(R.id.btn_clear_password);
|
||||||
|
btnTogglePassword = findViewById(R.id.btn_toggle_password);
|
||||||
|
|
||||||
|
btnLogin.setOnClickListener(v -> doLogin());
|
||||||
|
btnRegister.setOnClickListener(v ->
|
||||||
|
startActivity(new Intent(this, RegisterActivity.class)));
|
||||||
|
|
||||||
|
applyThemeColors();
|
||||||
|
setupPasswordActions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用主题色到登录按钮和注册链接 */
|
||||||
|
private void applyThemeColors() {
|
||||||
|
ThemeManager tm = ThemeManager.getInstance(this);
|
||||||
|
int accent = tm.accent().value;
|
||||||
|
int accentFg = tm.accentFgOnBg();
|
||||||
|
|
||||||
|
btnLogin.setTextColor(accentFg);
|
||||||
|
GradientDrawable gd = new GradientDrawable();
|
||||||
|
gd.setColor(accent);
|
||||||
|
gd.setCornerRadius(26f * getResources().getDisplayMetrics().density);
|
||||||
|
btnLogin.setBackground(gd);
|
||||||
|
|
||||||
|
TextView btnRegister = findViewById(R.id.btn_go_register);
|
||||||
|
btnRegister.setTextColor(accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 密码框右侧操作:清空 + 显示/隐藏密码 */
|
||||||
|
private void setupPasswordActions() {
|
||||||
|
btnClearPassword.setOnClickListener(v -> {
|
||||||
|
etPassword.setText("");
|
||||||
|
etPassword.requestFocus();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnTogglePassword.setOnClickListener(v -> {
|
||||||
|
passwordVisible = !passwordVisible;
|
||||||
|
int type = InputType.TYPE_CLASS_TEXT
|
||||||
|
| (passwordVisible ? InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
|
||||||
|
: InputType.TYPE_TEXT_VARIATION_PASSWORD);
|
||||||
|
etPassword.setInputType(type);
|
||||||
|
etPassword.setSelection(etPassword.getText().length());
|
||||||
|
btnTogglePassword.setImageResource(passwordVisible ? R.drawable.ic_eye : R.drawable.ic_eye_off);
|
||||||
|
});
|
||||||
|
|
||||||
|
etPassword.addTextChangedListener(new TextWatcher() {
|
||||||
|
@Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
|
||||||
|
@Override public void onTextChanged(CharSequence s, int start, int before, int count) {}
|
||||||
|
@Override
|
||||||
|
public void afterTextChanged(Editable s) {
|
||||||
|
boolean hasText = s != null && s.length() > 0;
|
||||||
|
btnClearPassword.setVisibility(hasText ? View.VISIBLE : View.GONE);
|
||||||
|
btnTogglePassword.setVisibility(hasText ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doLogin() {
|
||||||
|
String username = etUsername.getText().toString().trim();
|
||||||
|
String password = etPassword.getText().toString().trim();
|
||||||
|
|
||||||
|
if (TextUtils.isEmpty(username)) {
|
||||||
|
Toast.makeText(this, "请输入用户名", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (TextUtils.isEmpty(password)) {
|
||||||
|
Toast.makeText(this, "请输入密码", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("username", username);
|
||||||
|
body.put("password", password);
|
||||||
|
|
||||||
|
ApiService api = ApiClient.getApiService();
|
||||||
|
api.login(body).enqueue(new Callback<Result<LoginData>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<LoginData>> call, Response<Result<LoginData>> response) {
|
||||||
|
showLoading(false);
|
||||||
|
if (response.isSuccessful() && response.body() != null) {
|
||||||
|
Result<LoginData> result = response.body();
|
||||||
|
if (result.isSuccess() && result.getData() != null) {
|
||||||
|
LoginData data = result.getData();
|
||||||
|
session.saveLogin(data.getToken(), data.getRefreshToken(),
|
||||||
|
data.getMemberId(), data.getUsername(), data.getNickname());
|
||||||
|
Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show();
|
||||||
|
goMain();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(LoginActivity.this,
|
||||||
|
result.getMessage() != null ? result.getMessage() : "登录失败",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(LoginActivity.this, "网络错误,请重试", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<LoginData>> call, Throwable t) {
|
||||||
|
showLoading(false);
|
||||||
|
Toast.makeText(LoginActivity.this,
|
||||||
|
"连接服务器失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLoading(boolean show) {
|
||||||
|
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
btnLogin.setEnabled(!show);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void goMain() {
|
||||||
|
startActivity(new Intent(this, MainActivity.class));
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
437
Android/app/src/main/java/com/xhai/iot/ui/MainActivity.java
Normal file
437
Android/app/src/main/java/com/xhai/iot/ui/MainActivity.java
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.fragment.app.FragmentManager;
|
||||||
|
import androidx.fragment.app.FragmentTransaction;
|
||||||
|
|
||||||
|
import com.google.android.material.bottomnavigation.BottomNavigationView;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主界面(底部 4 Tab + AI 悬浮按钮,对应设计稿 BottomNav/AIPage)
|
||||||
|
* Tab1 - 首页 / Tab2 - 设备 / Tab3 - 自动化 / Tab4 - 我的
|
||||||
|
*/
|
||||||
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private final Fragment homeFragment = new HomeFragment();
|
||||||
|
private final Fragment devicesFragment = new DevicesFragment();
|
||||||
|
private final Fragment automationFragment = new AutomationFragment();
|
||||||
|
private final Fragment profileFragment = new ProfileFragment();
|
||||||
|
private Fragment currentFragment;
|
||||||
|
|
||||||
|
/** "开启蓝牙"提示框引用:蓝牙恢复开启时自动收起,避免提示框残留打扰 */
|
||||||
|
private androidx.appcompat.app.AlertDialog btDialog;
|
||||||
|
|
||||||
|
/** 蓝牙开关状态广播:蓝牙被打开(含下拉通知栏快捷开关)时自动收起提示 */
|
||||||
|
private final android.content.BroadcastReceiver btStateReceiver =
|
||||||
|
new android.content.BroadcastReceiver() {
|
||||||
|
@Override
|
||||||
|
public void onReceive(android.content.Context context,
|
||||||
|
android.content.Intent intent) {
|
||||||
|
int state = intent.getIntExtra(
|
||||||
|
android.bluetooth.BluetoothAdapter.EXTRA_STATE, -1);
|
||||||
|
if (state == android.bluetooth.BluetoothAdapter.STATE_ON) {
|
||||||
|
dismissBtDialogIfShowing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
android.util.Log.i("DiagTuya", "==== onCreate 进入 ====");
|
||||||
|
|
||||||
|
// 应用用户选择的主题(浅色/深色/跟随系统)
|
||||||
|
ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
setContentView(R.layout.activity_main);
|
||||||
|
// 状态栏/导航栏颜色跟随背景色(修复深色模式顶部白条)
|
||||||
|
ThemeManager.getInstance(this).applySystemBars(this);
|
||||||
|
|
||||||
|
// 未登录则返回登录页
|
||||||
|
if (!SessionManager.getInstance(this).isLoggedIn()) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步涂鸦绑定状态:涂鸦 SDK 登录态独立于 member 会话,
|
||||||
|
// 退出登录不清涂鸦 token,重新登录后自动恢复绑定(无需重绑)。
|
||||||
|
// 涂鸦会话过期(refreshToken 失效)时,用本地加密凭据自动续登,绑定一次永久有效。
|
||||||
|
try {
|
||||||
|
boolean tuyaLoggedIn = com.thingclips.smart.home.sdk.ThingHomeSdk
|
||||||
|
.getUserInstance().isLogin();
|
||||||
|
SessionManager.getInstance(this).setTuyaBound(tuyaLoggedIn);
|
||||||
|
if (!tuyaLoggedIn && com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(this)) {
|
||||||
|
com.xhai.iot.util.TuyaAutoRelogin.relogin(this,
|
||||||
|
new com.xhai.iot.util.TuyaAutoRelogin.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(com.thingclips.smart.android.user.bean.User user) {
|
||||||
|
SessionManager.getInstance(MainActivity.this).setTuyaBound(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message) {
|
||||||
|
SessionManager.getInstance(MainActivity.this).setTuyaBound(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 涂鸦 SDK 未初始化等情况,保持原状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// 涂鸦会话校验:isLogin()=true 但云端 token 已过期(USER_SESSION_LOSS)时
|
||||||
|
// SDK 不会自愈,主动用轻量接口校验一次,失效则用本地凭据静默续登
|
||||||
|
ensureTuyaSession();
|
||||||
|
|
||||||
|
BottomNavigationView bottomNav = findViewById(R.id.bottom_nav);
|
||||||
|
applyNavTint(bottomNav);
|
||||||
|
bottomNav.setOnItemSelectedListener(item -> {
|
||||||
|
int id = item.getItemId();
|
||||||
|
if (id == R.id.nav_home) {
|
||||||
|
switchTo(homeFragment, "home");
|
||||||
|
return true;
|
||||||
|
} else if (id == R.id.nav_devices) {
|
||||||
|
switchTo(devicesFragment, "devices");
|
||||||
|
return true;
|
||||||
|
} else if (id == R.id.nav_automation) {
|
||||||
|
switchTo(automationFragment, "automation");
|
||||||
|
return true;
|
||||||
|
} else if (id == R.id.nav_profile) {
|
||||||
|
switchTo(profileFragment, "profile");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// AI 悬浮按钮:单击进入 HomeAI 助手,长按直接开始语音控制
|
||||||
|
View btnAiFloat = findViewById(R.id.btn_ai_float);
|
||||||
|
btnAiFloat.setOnClickListener(v ->
|
||||||
|
startActivity(new Intent(this, AiAssistantActivity.class)));
|
||||||
|
btnAiFloat.setOnLongClickListener(v -> {
|
||||||
|
// 返回 true 消费事件,松手后不会再补一次单击(触觉反馈系统会自动发送)
|
||||||
|
startActivity(new Intent(this, AiAssistantActivity.class)
|
||||||
|
.putExtra(AiAssistantActivity.EXTRA_AUTO_VOICE, true));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 默认显示首页
|
||||||
|
bottomNav.setSelectedItemId(R.id.nav_home);
|
||||||
|
|
||||||
|
// 申请蓝牙"附近设备"权限(Mesh 子设备本地通道必需,缺失会导致面板显示设备离线)
|
||||||
|
requestNearbyPermissions();
|
||||||
|
|
||||||
|
// 蓝牙未开启时提示用户打开(蓝牙 Mesh/BLE 子设备的本地通道依赖手机蓝牙)
|
||||||
|
maybePromptBluetooth();
|
||||||
|
|
||||||
|
// 监听蓝牙开关:用户打开蓝牙(无论从本 APP 的引导、系统设置还是
|
||||||
|
// 下拉通知栏快捷开关)时,自动收起"开启蓝牙"提示
|
||||||
|
androidx.core.content.ContextCompat.registerReceiver(this, btStateReceiver,
|
||||||
|
new android.content.IntentFilter(
|
||||||
|
android.bluetooth.BluetoothAdapter.ACTION_STATE_CHANGED),
|
||||||
|
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
try {
|
||||||
|
unregisterReceiver(btStateReceiver);
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int REQ_NEARBY_PERMISSIONS = 2001;
|
||||||
|
private static final int REQ_ENABLE_BT = 2002;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 蓝牙未开启时提示用户打开。
|
||||||
|
* <p>
|
||||||
|
* 蓝牙 Mesh / BLE 子设备(如 JD6BTSA 这类 SigMesh 插座)的本地通道依赖手机蓝牙:
|
||||||
|
* 蓝牙关闭时手机与设备无法建立本地直连,只能依赖云端路由(且这类低功耗设备
|
||||||
|
* 大部分时间处于休眠、不在云端路由),在设备列表和涂鸦面板里就会表现为
|
||||||
|
* "设备离线"或控制无响应。因此打开 APP 时检测并引导开启。
|
||||||
|
* <p>
|
||||||
|
* 注意:Android 12+ 上弹出系统"启用蓝牙"对话框需要 BLUETOOTH_CONNECT
|
||||||
|
* 运行时权限,权限未授予前先跳过({@link #requestNearbyPermissions()} 的
|
||||||
|
* 授权回调里会再检查一次)。
|
||||||
|
*/
|
||||||
|
private void maybePromptBluetooth() {
|
||||||
|
try {
|
||||||
|
android.bluetooth.BluetoothManager bm =
|
||||||
|
getSystemService(android.bluetooth.BluetoothManager.class);
|
||||||
|
if (bm == null || bm.getAdapter() == null) {
|
||||||
|
return; // 设备没有蓝牙模块,无从提示
|
||||||
|
}
|
||||||
|
if (bm.getAdapter().isEnabled()) {
|
||||||
|
return; // 已开启
|
||||||
|
}
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||||
|
&& androidx.core.content.ContextCompat.checkSelfPermission(this,
|
||||||
|
android.Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
android.util.Log.i("MainActivity",
|
||||||
|
"蓝牙未开启但缺少 BLUETOOTH_CONNECT 权限,等权限授予后再提示");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
androidx.appcompat.app.AlertDialog dialog =
|
||||||
|
new com.google.android.material.dialog.MaterialAlertDialogBuilder(this)
|
||||||
|
.setTitle("开启蓝牙")
|
||||||
|
.setMessage("手机蓝牙未开启。蓝牙 Mesh / 蓝牙子设备需要通过手机蓝牙"
|
||||||
|
+ "直连,开启后设备才能正常显示在线并进行本地控制。")
|
||||||
|
.setPositiveButton("去开启", (d, w) -> {
|
||||||
|
try {
|
||||||
|
startActivityForResult(new Intent(
|
||||||
|
android.bluetooth.BluetoothAdapter.ACTION_REQUEST_ENABLE),
|
||||||
|
REQ_ENABLE_BT);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
android.util.Log.w("MainActivity",
|
||||||
|
"请求开启蓝牙失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setNegativeButton("暂不", null)
|
||||||
|
.create();
|
||||||
|
dialog.show();
|
||||||
|
btDialog = dialog;
|
||||||
|
android.util.Log.i("MainActivity", "蓝牙未开启,已弹窗提示");
|
||||||
|
} catch (Throwable e) {
|
||||||
|
android.util.Log.w("MainActivity", "检查蓝牙状态失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 蓝牙已开启时收起"开启蓝牙"提示(提示框不再打扰已开蓝牙的用户) */
|
||||||
|
private void dismissBtDialogIfShowing() {
|
||||||
|
try {
|
||||||
|
android.bluetooth.BluetoothManager bm =
|
||||||
|
getSystemService(android.bluetooth.BluetoothManager.class);
|
||||||
|
boolean btOn = bm != null && bm.getAdapter() != null && bm.getAdapter().isEnabled();
|
||||||
|
if (btDialog != null && btDialog.isShowing() && btOn) {
|
||||||
|
btDialog.dismiss();
|
||||||
|
android.util.Log.i("MainActivity", "蓝牙已开启,自动收起提示");
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode == REQ_ENABLE_BT && resultCode == RESULT_OK) {
|
||||||
|
// 蓝牙已开启:重建 Mesh 本地通道(此前可能因蓝牙关闭而连接失败)
|
||||||
|
try {
|
||||||
|
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
|
||||||
|
if (gid > 0) {
|
||||||
|
com.xhai.iot.service.TuyaSdkService.ensureMeshConnected(gid);
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
android.util.Log.w("MainActivity",
|
||||||
|
"开启蓝牙后重建 Mesh 失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 申请蓝牙"附近设备"权限。
|
||||||
|
* <p>
|
||||||
|
* Android 12+ 上 BLUETOOTH_SCAN / BLUETOOTH_CONNECT / BLUETOOTH_ADVERTISE 均为运行时权限,
|
||||||
|
* 缺少时涂鸦 SDK 的 Mesh 客户端 {@code ThingSigMeshClient.startClient} 会抛 SecurityException,
|
||||||
|
* Mesh 子设备无法建立本地通道,在涂鸦原生面板里就表现为"设备不在线"。
|
||||||
|
* 因此启动时统一申请一次(授权后会自动重建 Mesh 连接)。
|
||||||
|
*/
|
||||||
|
private void requestNearbyPermissions() {
|
||||||
|
try {
|
||||||
|
java.util.List<String> need = new java.util.ArrayList<>();
|
||||||
|
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
|
||||||
|
android.Manifest.permission.BLUETOOTH_SCAN)
|
||||||
|
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
need.add(android.Manifest.permission.BLUETOOTH_SCAN);
|
||||||
|
}
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
|
||||||
|
android.Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
need.add(android.Manifest.permission.BLUETOOTH_CONNECT);
|
||||||
|
}
|
||||||
|
if (androidx.core.content.ContextCompat.checkSelfPermission(this,
|
||||||
|
android.Manifest.permission.BLUETOOTH_ADVERTISE)
|
||||||
|
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
need.add(android.Manifest.permission.BLUETOOTH_ADVERTISE);
|
||||||
|
}
|
||||||
|
} else if (androidx.core.content.ContextCompat.checkSelfPermission(this,
|
||||||
|
android.Manifest.permission.ACCESS_FINE_LOCATION)
|
||||||
|
!= android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
// Android 11 及以下:BLE 扫描依赖定位权限
|
||||||
|
need.add(android.Manifest.permission.ACCESS_FINE_LOCATION);
|
||||||
|
}
|
||||||
|
if (!need.isEmpty()) {
|
||||||
|
android.util.Log.i("MainActivity", "申请蓝牙权限: " + need);
|
||||||
|
androidx.core.app.ActivityCompat.requestPermissions(this,
|
||||||
|
need.toArray(new String[0]), REQ_NEARBY_PERMISSIONS);
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
android.util.Log.w("MainActivity", "申请蓝牙权限失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 蓝牙权限申请结果:授权后重建 Mesh 网络(此前因缺权限连接失败) */
|
||||||
|
@Override
|
||||||
|
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
|
||||||
|
@NonNull int[] grantResults) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||||
|
if (requestCode != REQ_NEARBY_PERMISSIONS) return;
|
||||||
|
boolean allGranted = grantResults.length > 0;
|
||||||
|
for (int result : grantResults) {
|
||||||
|
if (result != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||||
|
allGranted = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android.util.Log.i("MainActivity", "蓝牙权限申请结果: allGranted=" + allGranted);
|
||||||
|
if (allGranted) {
|
||||||
|
try {
|
||||||
|
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
|
||||||
|
if (gid > 0) {
|
||||||
|
com.xhai.iot.service.TuyaSdkService.ensureMeshConnected(gid);
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
android.util.Log.w("MainActivity", "重建 Mesh 网络失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
// Android 12+:弹系统蓝牙启用对话框需要 BLUETOOTH_CONNECT 权限,
|
||||||
|
// 首次安装时 onCreate 阶段权限还没批下来,这里授权后再补一次检查
|
||||||
|
maybePromptBluetooth();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 底部导航选中色跟随主题色 */
|
||||||
|
private void applyNavTint(BottomNavigationView nav) {
|
||||||
|
int accent = ThemeManager.getInstance(this).accent().value;
|
||||||
|
android.content.res.ColorStateList sl = new android.content.res.ColorStateList(
|
||||||
|
new int[][]{
|
||||||
|
{android.R.attr.state_checked},
|
||||||
|
{}
|
||||||
|
},
|
||||||
|
new int[]{accent, 0xFF8A8F99});
|
||||||
|
nav.setItemIconTintList(sl);
|
||||||
|
nav.setItemTextColor(sl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供 Fragment 在主题色变化后刷新底部导航(弹窗关闭不触发 onResume,需手动调) */
|
||||||
|
public void refreshNavTint() {
|
||||||
|
BottomNavigationView nav = findViewById(R.id.bottom_nav);
|
||||||
|
if (nav != null) {
|
||||||
|
applyNavTint(nav);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
// 主题可能已在外观设置中修改,回来时刷新导航色
|
||||||
|
applyNavTint((BottomNavigationView) findViewById(R.id.bottom_nav));
|
||||||
|
// 用户可能在系统设置里开了蓝牙再回来,提示框自动收起
|
||||||
|
dismissBtDialogIfShowing();
|
||||||
|
// 登录后家庭 gid 就绪时,注册家庭级/设备级监听(捕获物理开关/原生面板操作/
|
||||||
|
// 设备上下线的 DP 变化并上报后端)。家庭是异步加载的,首帧 onResume 时 gid
|
||||||
|
// 可能仍为 0,因此用轮询重试,直到 gid 就绪(最多 ~15 秒)。idempotent:
|
||||||
|
// 同一家庭只注册一次。
|
||||||
|
tryRegisterDeviceListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 轮询重试注册设备监听,直到涂鸦家庭 gid 就绪 */
|
||||||
|
private int mRegisterRetry = 0;
|
||||||
|
private static final int MAX_REGISTER_RETRY = 30; // 30 * 500ms ≈ 15s
|
||||||
|
|
||||||
|
private void tryRegisterDeviceListener() {
|
||||||
|
try {
|
||||||
|
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
|
||||||
|
if (gid > 0) {
|
||||||
|
com.xhai.iot.service.TuyaSdkService.registerDeviceListener(gid);
|
||||||
|
mRegisterRetry = 0;
|
||||||
|
} else if (mRegisterRetry < MAX_REGISTER_RETRY) {
|
||||||
|
mRegisterRetry++;
|
||||||
|
new Handler(Looper.getMainLooper()).postDelayed(
|
||||||
|
this::tryRegisterDeviceListener, 500);
|
||||||
|
}
|
||||||
|
} catch (Throwable e) {
|
||||||
|
android.util.Log.w("MainActivity", "注册家庭设备监听失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 供首页快捷方式等跳转到指定 Tab */
|
||||||
|
public void switchToTab(int navItemId) {
|
||||||
|
BottomNavigationView nav = findViewById(R.id.bottom_nav);
|
||||||
|
if (nav.getSelectedItemId() != navItemId) {
|
||||||
|
nav.setSelectedItemId(navItemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异步校验涂鸦会话:USER_SESSION_LOSS 时用本地凭据静默续登(不阻塞 UI) */
|
||||||
|
private void ensureTuyaSession() {
|
||||||
|
try {
|
||||||
|
if (!com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(this)) return;
|
||||||
|
com.thingclips.smart.home.sdk.ThingHomeSdk.getHomeManagerInstance()
|
||||||
|
.queryHomeList(new com.thingclips.smart.home.sdk.callback.IThingGetHomeListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(java.util.List<com.thingclips.smart.home.sdk.bean.HomeBean> homes) {
|
||||||
|
// 会话正常
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
if (code != null && code.toUpperCase().contains("SESSION_LOSS")) {
|
||||||
|
android.util.Log.w("MainActivity",
|
||||||
|
"涂鸦会话已过期,自动续登");
|
||||||
|
com.xhai.iot.util.TuyaAutoRelogin.relogin(MainActivity.this,
|
||||||
|
new com.xhai.iot.util.TuyaAutoRelogin.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(com.thingclips.smart.android.user.bean.User user) {
|
||||||
|
SessionManager.getInstance(MainActivity.this)
|
||||||
|
.setTuyaBound(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String msg) {
|
||||||
|
android.util.Log.w("MainActivity",
|
||||||
|
"涂鸦自动续登失败: " + msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换到指定 Fragment
|
||||||
|
* 注意:Activity 重建后 FragmentManager 会恢复旧实例(与字段实例不同),
|
||||||
|
* 必须遍历隐藏除目标外的所有实例,否则旧页面会叠在上层导致"Tab 点不动"。
|
||||||
|
*/
|
||||||
|
private void switchTo(Fragment target, String tag) {
|
||||||
|
FragmentManager fm = getSupportFragmentManager();
|
||||||
|
FragmentTransaction transaction = fm.beginTransaction();
|
||||||
|
boolean targetHandled = false;
|
||||||
|
for (Fragment f : fm.getFragments()) {
|
||||||
|
if (f == target) {
|
||||||
|
transaction.show(f);
|
||||||
|
targetHandled = true;
|
||||||
|
} else {
|
||||||
|
transaction.hide(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!targetHandled) {
|
||||||
|
transaction.add(R.id.fragment_container, target, tag);
|
||||||
|
}
|
||||||
|
currentFragment = target;
|
||||||
|
transaction.commit();
|
||||||
|
}
|
||||||
|
}
|
||||||
416
Android/app/src/main/java/com/xhai/iot/ui/PanelListActivity.java
Normal file
416
Android/app/src/main/java/com/xhai/iot/ui/PanelListActivity.java
Normal file
@ -0,0 +1,416 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.google.zxing.integration.android.IntentIntegrator;
|
||||||
|
import com.google.zxing.integration.android.IntentResult;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.BackendHome;
|
||||||
|
import com.xhai.iot.model.PanelBindable;
|
||||||
|
import com.xhai.iot.model.PanelBindResult;
|
||||||
|
import com.xhai.iot.model.PanelInfo;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的中控屏:列表 + 解绑 + 扫码绑定(会员自助绑定 P4)
|
||||||
|
* 流程:扫码 → 预检 bindable → 选家庭 → bind;支持解绑。
|
||||||
|
*/
|
||||||
|
public class PanelListActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private RecyclerView rvPanels;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private PanelAdapter adapter;
|
||||||
|
private SessionManager session;
|
||||||
|
private ApiService api;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
setContentView(R.layout.activity_panel_list);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
api = ApiClient.getApiService();
|
||||||
|
|
||||||
|
rvPanels = findViewById(R.id.rv_panels);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
TextView btnScan = findViewById(R.id.btn_scan_bind);
|
||||||
|
|
||||||
|
adapter = new PanelAdapter();
|
||||||
|
adapter.setOnUnbindListener(this::confirmUnbind);
|
||||||
|
rvPanels.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvPanels.setAdapter(adapter);
|
||||||
|
|
||||||
|
btnScan.setOnClickListener(v -> startScan());
|
||||||
|
|
||||||
|
TextView btnManual = findViewById(R.id.btn_manual_bind);
|
||||||
|
btnManual.setOnClickListener(v -> showManualInput());
|
||||||
|
|
||||||
|
findViewById(R.id.btn_back).setOnClickListener(v -> finish());
|
||||||
|
|
||||||
|
// 按钮跟随主题色
|
||||||
|
applyAccent();
|
||||||
|
|
||||||
|
loadPanels();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回/重建后重新应用日夜模式与状态栏,避免扫码返回后主题异常 */
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
|
||||||
|
applyAccent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶部操作按钮跟随当前主题色(切换主题色后返回本页即时生效) */
|
||||||
|
private void applyAccent() {
|
||||||
|
int accent = com.xhai.iot.util.ThemeManager.getInstance(this).accent().value;
|
||||||
|
int[] ids = {R.id.btn_scan_bind, R.id.btn_manual_bind};
|
||||||
|
for (int id : ids) {
|
||||||
|
TextView b = findViewById(id);
|
||||||
|
if (b == null) continue;
|
||||||
|
android.graphics.drawable.Drawable bg = b.getBackground();
|
||||||
|
if (bg == null) continue;
|
||||||
|
bg = bg.mutate();
|
||||||
|
bg.setTint(accent);
|
||||||
|
b.setBackground(bg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载我绑定的中控屏列表 */
|
||||||
|
private void loadPanels() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
api.getMyPanels(auth).enqueue(new retrofit2.Callback<Result<List<PanelInfo>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<List<PanelInfo>>> call,
|
||||||
|
retrofit2.Response<Result<List<PanelInfo>>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
List<PanelInfo> list = response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess() && response.body().getData() != null
|
||||||
|
? response.body().getData() : null;
|
||||||
|
if (list == null) {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"加载失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
tvEmpty.setVisibility(list.isEmpty() ? View.VISIBLE : View.GONE);
|
||||||
|
adapter.update(list);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<List<PanelInfo>>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
runOnUiThread(() -> Toast.makeText(PanelListActivity.this,
|
||||||
|
"加载失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动扫码 */
|
||||||
|
private void startScan() {
|
||||||
|
new IntentIntegrator(this)
|
||||||
|
.setCaptureActivity(ZxingScanActivity.class)
|
||||||
|
.setPrompt("将中控屏二维码放入框内")
|
||||||
|
.setOrientationLocked(false)
|
||||||
|
.setBeepEnabled(true)
|
||||||
|
.initiateScan();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 兜底:手动输入 SN 绑定(屏固件暂未渲染二维码时使用) */
|
||||||
|
private void showManualInput() {
|
||||||
|
final android.widget.EditText input = new android.widget.EditText(this);
|
||||||
|
input.setHint("请输入中控屏序列号 SN");
|
||||||
|
input.setSingleLine(true);
|
||||||
|
input.setPadding(40, 24, 40, 24);
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("手动绑定中控屏")
|
||||||
|
.setMessage("若屏上暂无二维码,可手动输入屏的序列号(SN)进行绑定。")
|
||||||
|
.setView(input)
|
||||||
|
.setPositiveButton("下一步", (dialog, which) -> {
|
||||||
|
String sn = (input.getText() == null ? "" : input.getText().toString()).trim();
|
||||||
|
if (sn.isEmpty()) {
|
||||||
|
Toast.makeText(this, "请输入序列号 SN", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkBindable(sn);
|
||||||
|
})
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
IntentResult result = IntentIntegrator.parseActivityResult(requestCode, resultCode, data);
|
||||||
|
if (result != null && result.getContents() != null) {
|
||||||
|
String sn = extractSn(result.getContents());
|
||||||
|
if (sn.isEmpty()) {
|
||||||
|
Toast.makeText(this, "无法识别二维码内容", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
checkBindable(sn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从扫码内容提取 serialNumber(兼容「纯 SN」或「https://...?sn=XXX」) */
|
||||||
|
private String extractSn(String raw) {
|
||||||
|
if (raw == null) return "";
|
||||||
|
String s = raw.trim();
|
||||||
|
try {
|
||||||
|
android.net.Uri uri = android.net.Uri.parse(s);
|
||||||
|
if (uri != null && uri.getQueryParameter("sn") != null) {
|
||||||
|
return uri.getQueryParameter("sn");
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
int idx = s.toLowerCase().indexOf("sn=");
|
||||||
|
if (idx >= 0) {
|
||||||
|
String sub = s.substring(idx + 3).trim();
|
||||||
|
int end = sub.indexOf('&');
|
||||||
|
return (end >= 0 ? sub.substring(0, end) : sub).trim();
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 预检 SN 是否可绑定 */
|
||||||
|
private void checkBindable(String sn) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
api.getPanelBindable(auth, sn).enqueue(new retrofit2.Callback<Result<PanelBindable>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<PanelBindable>> call,
|
||||||
|
retrofit2.Response<Result<PanelBindable>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (!response.isSuccessful() || response.body() == null
|
||||||
|
|| !response.body().isSuccess()) {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"预检失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PanelBindable b = response.body().getData();
|
||||||
|
if (b == null || !b.isExists()) {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"该屏未激活或不存在", Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!b.isEnabled()) {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"该屏已禁用,无法绑定", Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (b.isBound()) {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"该屏已被其它账号绑定", Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showHomePicker(sn);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<PanelBindable>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"预检失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选择要绑定的家庭后调用 bind */
|
||||||
|
private void showHomePicker(String sn) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
api.getMyHomes(auth, session.getMemberId())
|
||||||
|
.enqueue(new retrofit2.Callback<Result<List<BackendHome>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<List<BackendHome>>> call,
|
||||||
|
retrofit2.Response<Result<List<BackendHome>>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
List<BackendHome> homes = response.isSuccessful()
|
||||||
|
&& response.body() != null && response.body().isSuccess()
|
||||||
|
&& response.body().getData() != null
|
||||||
|
? response.body().getData() : null;
|
||||||
|
if (homes == null || homes.isEmpty()) {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"请先在「我的」中创建家庭", Toast.LENGTH_LONG).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CharSequence[] items = new CharSequence[homes.size()];
|
||||||
|
for (int i = 0; i < homes.size(); i++) {
|
||||||
|
items[i] = homes.get(i).getName() != null
|
||||||
|
? homes.get(i).getName() : "未命名家庭";
|
||||||
|
}
|
||||||
|
new AlertDialog.Builder(PanelListActivity.this)
|
||||||
|
.setTitle("绑定到哪个家庭")
|
||||||
|
.setItems(items, (dialog, which) ->
|
||||||
|
bindPanel(sn, homes.get(which).getId()))
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<List<BackendHome>>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"获取家庭失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 绑定中控屏 */
|
||||||
|
private void bindPanel(String sn, long homeId) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("serialNumber", sn);
|
||||||
|
body.put("homeId", homeId);
|
||||||
|
api.bindPanel(auth, body).enqueue(new retrofit2.Callback<Result<PanelBindResult>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<PanelBindResult>> call,
|
||||||
|
retrofit2.Response<Result<PanelBindResult>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"绑定成功,请让中控屏重新联网", Toast.LENGTH_LONG).show();
|
||||||
|
loadPanels();
|
||||||
|
} else {
|
||||||
|
String msg = response.body() != null ? response.body().getMessage() : "网络错误";
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"绑定失败: " + msg, Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<PanelBindResult>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"绑定失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解绑确认 */
|
||||||
|
private void confirmUnbind(PanelInfo panel) {
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("解绑中控屏")
|
||||||
|
.setMessage("确定解绑「" + panel.getSerialNumber() + "」吗?解绑后该屏需重新绑定才能使用。")
|
||||||
|
.setPositiveButton("解绑", (dialog, which) -> doUnbind(panel))
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解绑中控屏 */
|
||||||
|
private void doUnbind(PanelInfo panel) {
|
||||||
|
if (panel.getPanelId() == null) return;
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
api.unbindPanel(auth, panel.getPanelId()).enqueue(new retrofit2.Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<Void>> call,
|
||||||
|
retrofit2.Response<Result<Void>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(PanelListActivity.this, "已解绑", Toast.LENGTH_SHORT).show();
|
||||||
|
loadPanels();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"解绑失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(PanelListActivity.this,
|
||||||
|
"解绑失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 列表适配器 ====================
|
||||||
|
|
||||||
|
private static class PanelAdapter extends RecyclerView.Adapter<PanelAdapter.VH> {
|
||||||
|
private List<PanelInfo> items = new ArrayList<>();
|
||||||
|
private OnUnbindListener unbindListener;
|
||||||
|
|
||||||
|
void update(List<PanelInfo> list) {
|
||||||
|
items = list != null ? list : new ArrayList<>();
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setOnUnbindListener(OnUnbindListener l) { unbindListener = l; }
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View v = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_panel, parent, false);
|
||||||
|
return new VH(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull VH h, int pos) {
|
||||||
|
PanelInfo p = items.get(pos);
|
||||||
|
h.tvSn.setText(p.getSerialNumber());
|
||||||
|
StringBuilder sub = new StringBuilder();
|
||||||
|
sub.append("角色: ").append(p.getRole() != null ? p.getRole() : "-");
|
||||||
|
sub.append(" · ").append(p.isOnline() ? "在线" : "离线");
|
||||||
|
if (p.getModel() != null && !p.getModel().isEmpty()) {
|
||||||
|
sub.append(" · ").append(p.getModel());
|
||||||
|
}
|
||||||
|
h.tvSub.setText(sub.toString());
|
||||||
|
h.btnUnbind.setOnClickListener(v -> {
|
||||||
|
if (unbindListener != null) unbindListener.onUnbind(p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() { return items.size(); }
|
||||||
|
|
||||||
|
static class VH extends RecyclerView.ViewHolder {
|
||||||
|
TextView tvSn, tvSub, btnUnbind;
|
||||||
|
VH(View v) {
|
||||||
|
super(v);
|
||||||
|
tvSn = v.findViewById(R.id.tv_sn);
|
||||||
|
tvSub = v.findViewById(R.id.tv_sub);
|
||||||
|
btnUnbind = v.findViewById(R.id.btn_unbind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnUnbindListener {
|
||||||
|
void onUnbind(PanelInfo panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
254
Android/app/src/main/java/com/xhai/iot/ui/ProfileFragment.java
Normal file
254
Android/app/src/main/java/com/xhai/iot/ui/ProfileFragment.java
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.model.SceneItem;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 我的页(设计稿 ProfilePage)
|
||||||
|
* 头像统计卡 + 分组菜单(外观/家庭与房间/账号绑定/设置/帮助) + 退出登录
|
||||||
|
*/
|
||||||
|
public class ProfileFragment extends Fragment {
|
||||||
|
|
||||||
|
private SessionManager session;
|
||||||
|
private ThemeManager theme;
|
||||||
|
private View root;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_profile, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
root = view;
|
||||||
|
session = SessionManager.getInstance(requireContext());
|
||||||
|
theme = ThemeManager.getInstance(requireContext());
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
if (root != null) {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tab 用 hide/show 切换时不走 onResume,需在此刷新 */
|
||||||
|
@Override
|
||||||
|
public void onHiddenChanged(boolean hidden) {
|
||||||
|
super.onHiddenChanged(hidden);
|
||||||
|
if (!hidden && root != null) {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 外观设置弹窗修改主题后调用:重渲染本页 + 刷新底部导航色 */
|
||||||
|
public void refreshTheme() {
|
||||||
|
if (root != null) {
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
if (getActivity() instanceof MainActivity) {
|
||||||
|
((MainActivity) getActivity()).refreshNavTint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染整页(主题/绑定状态可能已变化) */
|
||||||
|
private void render() {
|
||||||
|
int accent = theme.accent().value;
|
||||||
|
|
||||||
|
// 用户信息
|
||||||
|
String nickname = session.getNickname();
|
||||||
|
String username = session.getUsername();
|
||||||
|
TextView tvNickname = root.findViewById(R.id.tv_nickname);
|
||||||
|
TextView tvUsername = root.findViewById(R.id.tv_username);
|
||||||
|
TextView tvAvatar = root.findViewById(R.id.tv_avatar);
|
||||||
|
tvNickname.setText(nickname != null && !nickname.isEmpty() ? nickname : "用户");
|
||||||
|
tvUsername.setText(username != null ? username : "");
|
||||||
|
tvAvatar.setText(nickname != null && !nickname.isEmpty()
|
||||||
|
? nickname.substring(0, 1) : "用");
|
||||||
|
GradientDrawable avatarBg = new GradientDrawable();
|
||||||
|
avatarBg.setCornerRadius(dp(16));
|
||||||
|
avatarBg.setColor(accent);
|
||||||
|
tvAvatar.setBackground(avatarBg);
|
||||||
|
|
||||||
|
// 会员徽章染主题色
|
||||||
|
TextView badge = root.findViewById(R.id.tv_badge_admin);
|
||||||
|
badge.getBackground().mutate().setTint(accent);
|
||||||
|
|
||||||
|
// 分组菜单
|
||||||
|
LinearLayout gAppearance = root.findViewById(R.id.ll_group_appearance);
|
||||||
|
LinearLayout gManage = root.findViewById(R.id.ll_group_manage);
|
||||||
|
LinearLayout gOther = root.findViewById(R.id.ll_group_other);
|
||||||
|
gAppearance.removeAllViews();
|
||||||
|
gManage.removeAllViews();
|
||||||
|
gOther.removeAllViews();
|
||||||
|
|
||||||
|
// 个性化:外观与主题色(右侧显示当前主题色圆点)
|
||||||
|
addRow(gAppearance, "🎨", "外观与主题色",
|
||||||
|
modeLabel(), accent, true, v -> {
|
||||||
|
new AppearanceSheetFragment().show(
|
||||||
|
getChildFragmentManager(), "appearance");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理:家庭管理 / 房间管理 / 账号绑定 / 我的设备
|
||||||
|
addRow(gManage, "🏠", "家庭管理", "切换与创建家庭", 0, false,
|
||||||
|
v -> startActivity(new Intent(requireContext(), HomeManagerActivity.class)));
|
||||||
|
addRow(gManage, "🗂️", "房间管理", "房间分组设置", 0, false,
|
||||||
|
v -> startActivity(new Intent(requireContext(), RoomManagerActivity.class)));
|
||||||
|
addRow(gManage, "🔗", "账号绑定", "涂鸦账号",
|
||||||
|
0, false,
|
||||||
|
v -> startActivity(new Intent(requireContext(), TuyaBindActivity.class)));
|
||||||
|
addRow(gManage, "📱", "我的设备", "设备管理", 0, false,
|
||||||
|
v -> startActivity(new Intent(requireContext(), DeviceActivity.class)));
|
||||||
|
// 中控屏(会员扫码自助绑定):10 寸屏作为本地执行网关,与 Matter 配网是两条并行接入路线
|
||||||
|
addRow(gManage, "🖥️", "我的中控屏", "中控屏绑定与管理", 0, false,
|
||||||
|
v -> startActivity(new Intent(requireContext(), PanelListActivity.class)));
|
||||||
|
|
||||||
|
// 其他:设置 / 帮助与支持
|
||||||
|
addRow(gOther, "⚙️", "设置", "通知、隐私、安全", 0, false,
|
||||||
|
v -> Toast.makeText(requireContext(), "设置(待实现)",
|
||||||
|
Toast.LENGTH_SHORT).show());
|
||||||
|
addRow(gOther, "📖", "帮助与支持", "常见问题、联系我们", 0, false,
|
||||||
|
v -> showHelp());
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
root.findViewById(R.id.btn_logout).setOnClickListener(v -> doLogout());
|
||||||
|
|
||||||
|
// 统计:设备数 / 场景数
|
||||||
|
loadStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拉取设备/场景数量 */
|
||||||
|
private void loadStats() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
TextView tvDevices = root.findViewById(R.id.tv_stat_devices);
|
||||||
|
TextView tvScenes = root.findViewById(R.id.tv_stat_scenes);
|
||||||
|
TextView tvRules = root.findViewById(R.id.tv_stat_rules);
|
||||||
|
|
||||||
|
ApiClient.getApiService().getDevices(auth, 1, 1, null, null, null)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<PageData<com.xhai.iot.model.BackendDevice>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<PageData<com.xhai.iot.model.BackendDevice>>> call,
|
||||||
|
retrofit2.Response<Result<PageData<com.xhai.iot.model.BackendDevice>>> response) {
|
||||||
|
if (!isAdded() || response.body() == null
|
||||||
|
|| !response.body().isSuccess()) return;
|
||||||
|
PageData<?> data = response.body().getData();
|
||||||
|
tvDevices.setText(data != null
|
||||||
|
? String.valueOf(data.getTotal()) : "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<PageData<com.xhai.iot.model.BackendDevice>>> call,
|
||||||
|
Throwable t) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ApiClient.getSceneApiService().getScenes(auth, 1, 100)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<PageData<SceneItem>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<PageData<SceneItem>>> call,
|
||||||
|
retrofit2.Response<Result<PageData<SceneItem>>> response) {
|
||||||
|
if (!isAdded() || response.body() == null
|
||||||
|
|| !response.body().isSuccess()) return;
|
||||||
|
PageData<SceneItem> data = response.body().getData();
|
||||||
|
int count = data != null && data.getRecords() != null
|
||||||
|
? data.getRecords().size() : 0;
|
||||||
|
tvScenes.setText(String.valueOf(count));
|
||||||
|
tvRules.setText(String.valueOf(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<PageData<SceneItem>>> call,
|
||||||
|
Throwable t) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加分组菜单行 */
|
||||||
|
private void addRow(LinearLayout group, String icon, String label, String desc,
|
||||||
|
int dotColor, boolean showDot, View.OnClickListener listener) {
|
||||||
|
View row = LayoutInflater.from(requireContext())
|
||||||
|
.inflate(R.layout.item_profile_row, group, false);
|
||||||
|
TextView tvIcon = row.findViewById(R.id.tv_row_icon);
|
||||||
|
TextView tvLabel = row.findViewById(R.id.tv_row_label);
|
||||||
|
TextView tvDesc = row.findViewById(R.id.tv_row_desc);
|
||||||
|
View dot = row.findViewById(R.id.v_dot);
|
||||||
|
tvIcon.setText(icon);
|
||||||
|
tvLabel.setText(label);
|
||||||
|
tvDesc.setText(desc);
|
||||||
|
if (showDot && dotColor != 0) {
|
||||||
|
GradientDrawable d = new GradientDrawable();
|
||||||
|
d.setShape(GradientDrawable.OVAL);
|
||||||
|
d.setColor(dotColor);
|
||||||
|
dot.setBackground(d);
|
||||||
|
dot.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
dot.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
row.setOnClickListener(listener);
|
||||||
|
group.addView(row);
|
||||||
|
// 分隔线
|
||||||
|
if (group.getChildCount() > 1) {
|
||||||
|
View line = new View(requireContext());
|
||||||
|
line.setLayoutParams(new LinearLayout.LayoutParams(
|
||||||
|
LinearLayout.LayoutParams.MATCH_PARENT, dp(1)));
|
||||||
|
line.setBackgroundColor(requireContext().getColor(R.color.c_border));
|
||||||
|
group.addView(line, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String modeLabel() {
|
||||||
|
String m = theme.mode();
|
||||||
|
if (ThemeManager.MODE_DARK.equals(m)) return "🌙 深色";
|
||||||
|
if (ThemeManager.MODE_LIGHT.equals(m)) return "☀️ 浅色";
|
||||||
|
return "⚙️ 跟随系统";
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showHelp() {
|
||||||
|
new androidx.appcompat.app.AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle("帮助与支持")
|
||||||
|
.setItems(new String[]{"常见问题", "使用教程", "联系我们", "关于 IoT SaaS"},
|
||||||
|
(d, w) -> Toast.makeText(requireContext(), "敬请期待",
|
||||||
|
Toast.LENGTH_SHORT).show())
|
||||||
|
.setNegativeButton("关闭", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doLogout() {
|
||||||
|
session.logout();
|
||||||
|
Toast.makeText(requireContext(), "已退出登录", Toast.LENGTH_SHORT).show();
|
||||||
|
Intent intent = new Intent(requireContext(), LoginActivity.class);
|
||||||
|
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
|
||||||
|
startActivity(intent);
|
||||||
|
requireActivity().finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
private int dp(int v) {
|
||||||
|
return Math.round(getResources().getDisplayMetrics().density * v);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
Android/app/src/main/java/com/xhai/iot/ui/RegisterActivity.java
Normal file
116
Android/app/src/main/java/com/xhai/iot/ui/RegisterActivity.java
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会员注册页
|
||||||
|
*/
|
||||||
|
public class RegisterActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private EditText etUsername;
|
||||||
|
private EditText etPassword;
|
||||||
|
private EditText etPassword2;
|
||||||
|
private EditText etNickname;
|
||||||
|
private EditText etPhone;
|
||||||
|
private Button btnRegister;
|
||||||
|
private ProgressBar progressBar;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_register);
|
||||||
|
initViews();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initViews() {
|
||||||
|
etUsername = findViewById(R.id.et_username);
|
||||||
|
etPassword = findViewById(R.id.et_password);
|
||||||
|
etPassword2 = findViewById(R.id.et_password2);
|
||||||
|
etNickname = findViewById(R.id.et_nickname);
|
||||||
|
etPhone = findViewById(R.id.et_phone);
|
||||||
|
btnRegister = findViewById(R.id.btn_register);
|
||||||
|
progressBar = findViewById(R.id.progress_register);
|
||||||
|
|
||||||
|
btnRegister.setOnClickListener(v -> doRegister());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doRegister() {
|
||||||
|
String username = etUsername.getText().toString().trim();
|
||||||
|
String password = etPassword.getText().toString().trim();
|
||||||
|
String password2 = etPassword2.getText().toString().trim();
|
||||||
|
String nickname = etNickname.getText().toString().trim();
|
||||||
|
String phone = etPhone.getText().toString().trim();
|
||||||
|
|
||||||
|
if (TextUtils.isEmpty(username)) {
|
||||||
|
Toast.makeText(this, "请输入用户名", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (TextUtils.isEmpty(password)) {
|
||||||
|
Toast.makeText(this, "请输入密码", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!password.equals(password2)) {
|
||||||
|
Toast.makeText(this, "两次密码不一致", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progressBar.setVisibility(View.VISIBLE);
|
||||||
|
btnRegister.setEnabled(false);
|
||||||
|
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("username", username);
|
||||||
|
body.put("password", password);
|
||||||
|
if (!TextUtils.isEmpty(nickname)) body.put("nickname", nickname);
|
||||||
|
if (!TextUtils.isEmpty(phone)) body.put("phone", phone);
|
||||||
|
|
||||||
|
ApiClient.getApiService().register(body).enqueue(new Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Void>> call, Response<Result<Void>> response) {
|
||||||
|
progressBar.setVisibility(View.GONE);
|
||||||
|
btnRegister.setEnabled(true);
|
||||||
|
if (response.isSuccessful() && response.body() != null) {
|
||||||
|
Result<Void> result = response.body();
|
||||||
|
if (result.isSuccess()) {
|
||||||
|
Toast.makeText(RegisterActivity.this,
|
||||||
|
"注册成功,请登录", Toast.LENGTH_SHORT).show();
|
||||||
|
finish();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(RegisterActivity.this,
|
||||||
|
result.getMessage() != null ? result.getMessage() : "注册失败",
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Toast.makeText(RegisterActivity.this, "网络错误,请重试", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Void>> call, Throwable t) {
|
||||||
|
progressBar.setVisibility(View.GONE);
|
||||||
|
btnRegister.setEnabled(true);
|
||||||
|
Toast.makeText(RegisterActivity.this,
|
||||||
|
"连接服务器失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,264 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.BackendHome;
|
||||||
|
import com.xhai.iot.model.BackendRoom;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.ui.adapter.RoomListAdapter;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 房间管理页(数据源:本平台后端 /api/v1/homes/{homeId}/rooms,不对接涂鸦)
|
||||||
|
* 家庭房间列表 + 添加房间 + 删除房间
|
||||||
|
*/
|
||||||
|
public class RoomManagerActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private long homeId;
|
||||||
|
private String homeName;
|
||||||
|
private RecyclerView rvRooms;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private RoomListAdapter adapter;
|
||||||
|
private SessionManager session;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
setContentView(R.layout.activity_room_manager);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
|
||||||
|
homeId = getIntent().getLongExtra("homeId", 0);
|
||||||
|
homeName = getIntent().getStringExtra("homeName");
|
||||||
|
rvRooms = findViewById(R.id.rv_rooms);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
TextView tvAddRoom = findViewById(R.id.tv_add_room);
|
||||||
|
|
||||||
|
adapter = new RoomListAdapter();
|
||||||
|
adapter.setOnDeleteListener((room, position) -> confirmDelete(room));
|
||||||
|
rvRooms.setLayoutManager(new LinearLayoutManager(this));
|
||||||
|
rvRooms.setAdapter(adapter);
|
||||||
|
|
||||||
|
tvAddRoom.setOnClickListener(v -> showAddRoomDialog());
|
||||||
|
|
||||||
|
// 未指定家庭时,取后端第一个家庭
|
||||||
|
if (homeId <= 0) {
|
||||||
|
resolveDefaultHome();
|
||||||
|
} else {
|
||||||
|
loadRooms();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从后端解析默认家庭(第一个),然后加载房间 */
|
||||||
|
private void resolveDefaultHome() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getApiService().getMyHomes(auth, session.getMemberId())
|
||||||
|
.enqueue(new retrofit2.Callback<Result<List<BackendHome>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<List<BackendHome>>> call,
|
||||||
|
retrofit2.Response<Result<List<BackendHome>>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
List<BackendHome> homes = response.isSuccessful()
|
||||||
|
&& response.body() != null && response.body().isSuccess()
|
||||||
|
? response.body().getData() : null;
|
||||||
|
if (homes == null || homes.isEmpty()) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
((TextView) findViewById(R.id.tv_add_room)).setEnabled(false);
|
||||||
|
});
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"请先创建家庭", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
homeId = homes.get(0).getId();
|
||||||
|
homeName = homes.get(0).getName();
|
||||||
|
runOnUiThread(this::loadRoomsOnUi);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadRoomsOnUi() {
|
||||||
|
loadRooms();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<List<BackendHome>>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
runOnUiThread(() -> Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"加载家庭失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从后端加载房间列表 */
|
||||||
|
private void loadRooms() {
|
||||||
|
if (homeId <= 0) {
|
||||||
|
resolveDefaultHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getApiService().getRooms(auth, homeId, session.getMemberId())
|
||||||
|
.enqueue(new retrofit2.Callback<Result<List<BackendRoom>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<List<BackendRoom>>> call,
|
||||||
|
retrofit2.Response<Result<List<BackendRoom>>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
List<BackendRoom> rooms = response.isSuccessful()
|
||||||
|
&& response.body() != null && response.body().isSuccess()
|
||||||
|
? response.body().getData() : null;
|
||||||
|
if (rooms == null) {
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"加载房间失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
tvEmpty.setVisibility(rooms.isEmpty()
|
||||||
|
? View.VISIBLE : View.GONE);
|
||||||
|
adapter.update(toSdkRooms(rooms));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<List<BackendRoom>>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
runOnUiThread(() -> Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"加载房间失败: " + t.getMessage(), Toast.LENGTH_SHORT).show());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 后端房间 → 列表适配器模型(复用现有 UI) */
|
||||||
|
private List<TuyaSdkService.SdkRoom> toSdkRooms(List<BackendRoom> rooms) {
|
||||||
|
List<TuyaSdkService.SdkRoom> result = new java.util.ArrayList<>();
|
||||||
|
if (rooms != null) {
|
||||||
|
for (BackendRoom r : rooms) {
|
||||||
|
if (r.getId() == null) continue;
|
||||||
|
result.add(new TuyaSdkService.SdkRoom(r.getId(),
|
||||||
|
r.getName() != null ? r.getName() : "未命名房间"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加房间对话框 */
|
||||||
|
private void showAddRoomDialog() {
|
||||||
|
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||||
|
builder.setTitle("添加房间");
|
||||||
|
final EditText etName = new EditText(this);
|
||||||
|
etName.setHint("请输入房间名称");
|
||||||
|
builder.setView(etName);
|
||||||
|
builder.setPositiveButton("确定", (dialog, which) -> {
|
||||||
|
String name = etName.getText().toString().trim();
|
||||||
|
if (TextUtils.isEmpty(name)) {
|
||||||
|
Toast.makeText(this, "请输入房间名称", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addRoom(name);
|
||||||
|
});
|
||||||
|
builder.setNegativeButton("取消", null);
|
||||||
|
builder.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加房间(后端) */
|
||||||
|
private void addRoom(String name) {
|
||||||
|
if (homeId <= 0) {
|
||||||
|
Toast.makeText(this, "家庭未就绪,请稍后重试", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("name", name);
|
||||||
|
ApiClient.getApiService().addRoom(auth, homeId, session.getMemberId(), body)
|
||||||
|
.enqueue(new retrofit2.Callback<Result<BackendRoom>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<BackendRoom>> call,
|
||||||
|
retrofit2.Response<Result<BackendRoom>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"房间添加成功", Toast.LENGTH_SHORT).show();
|
||||||
|
loadRooms();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"添加失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<BackendRoom>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"添加失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认删除房间 */
|
||||||
|
private void confirmDelete(TuyaSdkService.SdkRoom room) {
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("删除房间")
|
||||||
|
.setMessage("确定删除房间「" + room.name + "」吗?")
|
||||||
|
.setPositiveButton("删除", (dialog, which) -> removeRoom(room))
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除房间(后端,逻辑删除) */
|
||||||
|
private void removeRoom(TuyaSdkService.SdkRoom room) {
|
||||||
|
if (homeId <= 0) return;
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getApiService().deleteRoom(auth, homeId, room.roomId, session.getMemberId())
|
||||||
|
.enqueue(new retrofit2.Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(retrofit2.Call<Result<Void>> call,
|
||||||
|
retrofit2.Response<Result<Void>> response) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"房间已删除", Toast.LENGTH_SHORT).show();
|
||||||
|
loadRooms();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"删除失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "网络错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(retrofit2.Call<Result<Void>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
Toast.makeText(RoomManagerActivity.this,
|
||||||
|
"删除失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
126
Android/app/src/main/java/com/xhai/iot/ui/SceneActivity.java
Normal file
126
Android/app/src/main/java/com/xhai/iot/ui/SceneActivity.java
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.reflect.TypeToken;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
|
||||||
|
import java.lang.reflect.Type;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建场景页
|
||||||
|
* 展示场景列表 + 创建场景(先本地存储,后续可对接涂鸦场景 SDK)
|
||||||
|
*/
|
||||||
|
public class SceneActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final String PREFS = "xhai_scene";
|
||||||
|
private static final String KEY_SCENES = "scenes";
|
||||||
|
|
||||||
|
private LinearLayout llSceneContainer;
|
||||||
|
private TextView tvEmpty;
|
||||||
|
private final List<SceneItem> scenes = new ArrayList<>();
|
||||||
|
private final Gson gson = new Gson();
|
||||||
|
|
||||||
|
/** 场景项 */
|
||||||
|
public static class SceneItem {
|
||||||
|
public String name;
|
||||||
|
public String icon;
|
||||||
|
public String desc;
|
||||||
|
|
||||||
|
public SceneItem(String name, String icon, String desc) {
|
||||||
|
this.name = name;
|
||||||
|
this.icon = icon;
|
||||||
|
this.desc = desc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_scene);
|
||||||
|
|
||||||
|
llSceneContainer = findViewById(R.id.ll_scene_container);
|
||||||
|
tvEmpty = findViewById(R.id.tv_empty);
|
||||||
|
TextView tvCreate = findViewById(R.id.tv_create);
|
||||||
|
|
||||||
|
tvCreate.setOnClickListener(v -> showCreateDialog());
|
||||||
|
|
||||||
|
loadScenes();
|
||||||
|
renderScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadScenes() {
|
||||||
|
SharedPreferences prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
String json = prefs.getString(KEY_SCENES, "[]");
|
||||||
|
try {
|
||||||
|
Type type = new TypeToken<List<SceneItem>>() {}.getType();
|
||||||
|
List<SceneItem> list = gson.fromJson(json, type);
|
||||||
|
scenes.clear();
|
||||||
|
if (list != null) scenes.addAll(list);
|
||||||
|
} catch (Exception e) {
|
||||||
|
scenes.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void saveScenes() {
|
||||||
|
SharedPreferences prefs = getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
prefs.edit().putString(KEY_SCENES, gson.toJson(scenes)).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void renderScenes() {
|
||||||
|
llSceneContainer.removeAllViews();
|
||||||
|
if (scenes.isEmpty()) {
|
||||||
|
tvEmpty.setVisibility(View.VISIBLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tvEmpty.setVisibility(View.GONE);
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(this);
|
||||||
|
for (int i = 0; i < scenes.size(); i++) {
|
||||||
|
SceneItem scene = scenes.get(i);
|
||||||
|
View item = inflater.inflate(R.layout.item_scene, llSceneContainer, false);
|
||||||
|
TextView tvIcon = item.findViewById(R.id.tv_scene_icon);
|
||||||
|
TextView tvName = item.findViewById(R.id.tv_scene_name);
|
||||||
|
TextView tvDesc = item.findViewById(R.id.tv_scene_desc);
|
||||||
|
tvIcon.setText(scene.icon);
|
||||||
|
tvName.setText(scene.name);
|
||||||
|
tvDesc.setText(scene.desc);
|
||||||
|
llSceneContainer.addView(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showCreateDialog() {
|
||||||
|
AlertDialog.Builder builder = new AlertDialog.Builder(this);
|
||||||
|
builder.setTitle("创建场景");
|
||||||
|
final EditText etName = new EditText(this);
|
||||||
|
etName.setHint("请输入场景名称");
|
||||||
|
builder.setView(etName);
|
||||||
|
builder.setPositiveButton("创建", (dialog, which) -> {
|
||||||
|
String name = etName.getText().toString().trim();
|
||||||
|
if (TextUtils.isEmpty(name)) {
|
||||||
|
Toast.makeText(this, "请输入场景名称", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scenes.add(new SceneItem(name, "⏰", "手动执行场景"));
|
||||||
|
saveScenes();
|
||||||
|
renderScenes();
|
||||||
|
Toast.makeText(this, "场景创建成功", Toast.LENGTH_SHORT).show();
|
||||||
|
});
|
||||||
|
builder.setNegativeButton("取消", null);
|
||||||
|
builder.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,661 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.ArrayAdapter;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.Spinner;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.thingclips.smart.android.device.bean.SchemaBean;
|
||||||
|
import com.thingclips.smart.home.sdk.ThingHomeSdk;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.model.ControlSchema;
|
||||||
|
import com.xhai.iot.model.SceneItem;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 场景详情页:展示场景信息、规则列表、添加/删除规则、一执行场景
|
||||||
|
*/
|
||||||
|
public class SceneDetailActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private TextView tvBack;
|
||||||
|
private TextView tvSceneTitle;
|
||||||
|
private TextView tvSceneIcon;
|
||||||
|
private TextView tvSceneName;
|
||||||
|
private TextView tvSceneDesc;
|
||||||
|
private Button btnExecute;
|
||||||
|
private TextView tvAddRule;
|
||||||
|
private LinearLayout llRuleList;
|
||||||
|
private TextView tvRuleEmpty;
|
||||||
|
|
||||||
|
private SessionManager session;
|
||||||
|
private Long sceneId;
|
||||||
|
private String sceneName;
|
||||||
|
private final List<Map<String, Object>> rules = new ArrayList<>();
|
||||||
|
private final List<TuyaSdkService.SdkDevice> deviceList = new ArrayList<>();
|
||||||
|
|
||||||
|
// 动作设计相关:保存当前动作设备可写指令集(property code -> ControlItem,平台无关)
|
||||||
|
private Spinner spActionDevice;
|
||||||
|
private Spinner spActionDp;
|
||||||
|
private android.widget.FrameLayout flActionValue;
|
||||||
|
private java.util.Map<String, ControlSchema.ControlItem> currentSchemaMap = new java.util.HashMap<>();
|
||||||
|
private final List<String> actionDpKeys = new ArrayList<>();
|
||||||
|
private int actionDpTypeIndex = 0; // 0=Bool,1=Enum,2=Value,3=String
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setContentView(R.layout.activity_scene_detail);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
sceneId = getIntent().getLongExtra("sceneId", 0);
|
||||||
|
sceneName = getIntent().getStringExtra("sceneName");
|
||||||
|
if (sceneId == 0) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tvBack = findViewById(R.id.tv_back);
|
||||||
|
tvSceneTitle = findViewById(R.id.tv_scene_title);
|
||||||
|
tvSceneIcon = findViewById(R.id.tv_scene_icon);
|
||||||
|
tvSceneName = findViewById(R.id.tv_scene_name);
|
||||||
|
tvSceneDesc = findViewById(R.id.tv_scene_desc);
|
||||||
|
btnExecute = findViewById(R.id.btn_execute_scene);
|
||||||
|
tvAddRule = findViewById(R.id.tv_add_rule);
|
||||||
|
llRuleList = findViewById(R.id.ll_rule_list);
|
||||||
|
tvRuleEmpty = findViewById(R.id.tv_rule_empty);
|
||||||
|
|
||||||
|
tvSceneTitle.setText(sceneName != null ? sceneName : "场景详情");
|
||||||
|
tvSceneName.setText(sceneName != null ? sceneName : "");
|
||||||
|
tvSceneDesc.setText("点击右上角\"添加规则\"配置联动条件");
|
||||||
|
|
||||||
|
tvBack.setOnClickListener(v -> finish());
|
||||||
|
btnExecute.setOnClickListener(v -> executeScene());
|
||||||
|
tvAddRule.setOnClickListener(v -> loadDevicesThenShowAddDialog());
|
||||||
|
|
||||||
|
loadRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
loadRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载规则列表 */
|
||||||
|
private void loadRules() {
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null || sceneId == null) return;
|
||||||
|
ApiClient.getSceneApiService().getRules(auth, sceneId, 1, 100)
|
||||||
|
.enqueue(new Callback<Result<PageData<Map<String, Object>>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<PageData<Map<String, Object>>>> call,
|
||||||
|
Response<Result<PageData<Map<String, Object>>>> response) {
|
||||||
|
Result<PageData<Map<String, Object>>> body = response.body();
|
||||||
|
rules.clear();
|
||||||
|
if (body != null && body.isSuccess() && body.getData() != null
|
||||||
|
&& body.getData().getRecords() != null) {
|
||||||
|
rules.addAll(body.getData().getRecords());
|
||||||
|
}
|
||||||
|
renderRules();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<PageData<Map<String, Object>>>> call, Throwable t) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"加载规则失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染规则列表 */
|
||||||
|
private void renderRules() {
|
||||||
|
llRuleList.removeAllViews();
|
||||||
|
if (rules.isEmpty()) {
|
||||||
|
tvRuleEmpty.setVisibility(View.VISIBLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tvRuleEmpty.setVisibility(View.GONE);
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(this);
|
||||||
|
for (Map<String, Object> rule : rules) {
|
||||||
|
View item = inflater.inflate(R.layout.item_rule_card, llRuleList, false);
|
||||||
|
TextView tvName = item.findViewById(R.id.tv_rule_name);
|
||||||
|
TextView tvStatus = item.findViewById(R.id.tv_rule_status);
|
||||||
|
TextView tvDelete = item.findViewById(R.id.tv_rule_delete);
|
||||||
|
TextView tvTrigger = item.findViewById(R.id.tv_rule_trigger);
|
||||||
|
TextView tvCondition = item.findViewById(R.id.tv_rule_condition);
|
||||||
|
TextView tvAction = item.findViewById(R.id.tv_rule_action);
|
||||||
|
|
||||||
|
String ruleName = stringOf(rule.get("ruleName"));
|
||||||
|
tvName.setText(ruleName != null && !ruleName.isEmpty() ? ruleName : "未命名规则");
|
||||||
|
Boolean enabled = rule.get("enabled") instanceof Boolean
|
||||||
|
? (Boolean) rule.get("enabled") : true;
|
||||||
|
tvStatus.setText(Boolean.TRUE.equals(enabled) ? "已启用" : "已停用");
|
||||||
|
tvStatus.setTextColor(Boolean.TRUE.equals(enabled) ? 0xFF67C23A : 0xFF909399);
|
||||||
|
|
||||||
|
tvTrigger.setText("触发设备: " + stringOf(rule.get("triggerDeviceId")));
|
||||||
|
tvCondition.setText("条件: " + stringOf(rule.get("conditionExpression")));
|
||||||
|
tvAction.setText("动作: " + (rule.get("actions") != null ? rule.get("actions").toString() : "无"));
|
||||||
|
|
||||||
|
// 删除规则
|
||||||
|
final Long ruleId = rule.get("id") instanceof Number
|
||||||
|
? ((Number) rule.get("id")).longValue() : null;
|
||||||
|
tvDelete.setOnClickListener(v -> {
|
||||||
|
if (ruleId != null) deleteRule(ruleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
llRuleList.addView(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String stringOf(Object obj) {
|
||||||
|
return obj == null ? "" : obj.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除规则 */
|
||||||
|
private void deleteRule(Long ruleId) {
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null) return;
|
||||||
|
ApiClient.getSceneApiService().deleteRule(auth, ruleId)
|
||||||
|
.enqueue(new Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Void>> call, Response<Result<Void>> response) {
|
||||||
|
Result<Void> body = response.body();
|
||||||
|
if (body != null && body.isSuccess()) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this, "已删除", Toast.LENGTH_SHORT).show();
|
||||||
|
loadRules();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"删除失败: " + (body != null ? body.getMessage() : "未知"),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Void>> call, Throwable t) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"删除失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行场景 */
|
||||||
|
private void executeScene() {
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null || sceneId == null) return;
|
||||||
|
Toast.makeText(this, "正在执行场景...", Toast.LENGTH_SHORT).show();
|
||||||
|
ApiClient.getSceneApiService().executeScene(auth, sceneId)
|
||||||
|
.enqueue(new Callback<Result<List<Map<String, Object>>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<List<Map<String, Object>>>> call,
|
||||||
|
Response<Result<List<Map<String, Object>>>> response) {
|
||||||
|
Result<List<Map<String, Object>>> body = response.body();
|
||||||
|
if (body != null && body.isSuccess()) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this, "执行成功", Toast.LENGTH_SHORT).show();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"执行失败: " + (body != null ? body.getMessage() : "未知"),
|
||||||
|
Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<List<Map<String, Object>>>> call, Throwable t) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"执行失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载设备列表后弹出添加规则对话框 */
|
||||||
|
private void loadDevicesThenShowAddDialog() {
|
||||||
|
Toast.makeText(this, "加载设备...", Toast.LENGTH_SHORT).show();
|
||||||
|
TuyaSdkService.getHomes(new TuyaSdkService.HomeListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<TuyaSdkService.SdkHome> homes) {
|
||||||
|
if (homes == null || homes.isEmpty()) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this, "暂无家庭,请先创建家庭", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final long homeId = homes.get(0).homeId;
|
||||||
|
TuyaSdkService.getDevices(homeId, new TuyaSdkService.DeviceListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(List<TuyaSdkService.SdkDevice> devices) {
|
||||||
|
deviceList.clear();
|
||||||
|
if (devices != null) deviceList.addAll(devices);
|
||||||
|
if (deviceList.isEmpty()) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this, "暂无设备,请先绑定设备", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showAddRuleDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"加载设备失败: " + error, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String error) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"加载家庭失败: " + error, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 弹出添加规则对话框 */
|
||||||
|
private void showAddRuleDialog() {
|
||||||
|
View view = LayoutInflater.from(this).inflate(R.layout.dialog_add_rule, null);
|
||||||
|
Spinner spTrigger = view.findViewById(R.id.sp_trigger_device);
|
||||||
|
spActionDevice = view.findViewById(R.id.sp_action_device);
|
||||||
|
spActionDp = view.findViewById(R.id.sp_action_dp);
|
||||||
|
flActionValue = view.findViewById(R.id.fl_action_value);
|
||||||
|
EditText etCondition = view.findViewById(R.id.et_condition);
|
||||||
|
EditText etRuleName = view.findViewById(R.id.et_rule_name);
|
||||||
|
|
||||||
|
// 设备下拉
|
||||||
|
List<String> deviceNames = new ArrayList<>();
|
||||||
|
for (TuyaSdkService.SdkDevice d : deviceList) {
|
||||||
|
deviceNames.add(d.name != null ? d.name : d.devId);
|
||||||
|
}
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, deviceNames);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
spTrigger.setAdapter(adapter);
|
||||||
|
spActionDevice.setAdapter(adapter);
|
||||||
|
|
||||||
|
// 动作设备变化时,加载该设备的可写指令集(schema)
|
||||||
|
spActionDevice.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
|
||||||
|
@Override
|
||||||
|
public void onItemSelected(android.widget.AdapterView<?> parent, View v, int pos, long id) {
|
||||||
|
if (pos < 0 || pos >= deviceList.size()) return;
|
||||||
|
TuyaSdkService.SdkDevice action = deviceList.get(pos);
|
||||||
|
loadActionSchema(action.devId);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 动作指令(DP)变化时,根据类型动态构建动作值控件
|
||||||
|
spActionDp.setOnItemSelectedListener(new android.widget.AdapterView.OnItemSelectedListener() {
|
||||||
|
boolean first = true;
|
||||||
|
@Override
|
||||||
|
public void onItemSelected(android.widget.AdapterView<?> parent, View v, int pos, long id) {
|
||||||
|
if (first) { first = false; }
|
||||||
|
if (pos < 0 || pos >= actionDpKeys.size()) return;
|
||||||
|
String dpKey = actionDpKeys.get(pos);
|
||||||
|
ControlSchema.ControlItem schema = currentSchemaMap.get(dpKey);
|
||||||
|
buildActionValueControl(schema);
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public void onNothingSelected(android.widget.AdapterView<?> parent) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
new AlertDialog.Builder(this)
|
||||||
|
.setTitle("添加联动规则")
|
||||||
|
.setView(view)
|
||||||
|
.setPositiveButton("保存", (d, w) -> {
|
||||||
|
if (spTrigger.getSelectedItemPosition() < 0
|
||||||
|
|| spActionDevice.getSelectedItemPosition() < 0) {
|
||||||
|
Toast.makeText(this, "请选择设备", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TuyaSdkService.SdkDevice trigger = deviceList.get(spTrigger.getSelectedItemPosition());
|
||||||
|
TuyaSdkService.SdkDevice action = deviceList.get(spActionDevice.getSelectedItemPosition());
|
||||||
|
String ruleName = etRuleName.getText().toString().trim();
|
||||||
|
if (ruleName.isEmpty()) ruleName = "联动-" + trigger.devId.substring(0, Math.min(6, trigger.devId.length()));
|
||||||
|
String condition = etCondition.getText().toString().trim();
|
||||||
|
if (condition.isEmpty()) condition = "switch_1 == true";
|
||||||
|
// 读取动作 DP 和值
|
||||||
|
String actionKey = readSelectedActionKey();
|
||||||
|
Object actionValue = readSelectedActionValue();
|
||||||
|
if (actionKey == null || actionKey.isEmpty()) {
|
||||||
|
Toast.makeText(this, "请选择动作指令", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createRule(ruleName, trigger, condition, action, actionKey, actionValue);
|
||||||
|
})
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载选中设备的可写指令集(统一用后端 control-schema,平台无关)。
|
||||||
|
* 仅当设备无物模型(available=false)或请求失败时,回退到涂鸦本地 schema。
|
||||||
|
*/
|
||||||
|
private void loadActionSchema(String deviceId) {
|
||||||
|
currentSchemaMap.clear();
|
||||||
|
actionDpKeys.clear();
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null) {
|
||||||
|
buildActionDpSpinner(new ArrayList<>());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ApiClient.getApiService().getDeviceControlSchema(auth, deviceId)
|
||||||
|
.enqueue(new Callback<Result<ControlSchema>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<ControlSchema>> call,
|
||||||
|
Response<Result<ControlSchema>> response) {
|
||||||
|
Result<ControlSchema> r = response.body();
|
||||||
|
if (r != null && r.isSuccess() && r.getData() != null
|
||||||
|
&& r.getData().available) {
|
||||||
|
ControlSchema schema = r.getData();
|
||||||
|
java.util.Map<String, ControlSchema.ControlItem> writable = new java.util.HashMap<>();
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
if (schema.items != null) {
|
||||||
|
for (ControlSchema.ControlItem it : schema.items) {
|
||||||
|
if (it != null && it.writable && it.code != null) {
|
||||||
|
writable.put(it.code, it);
|
||||||
|
keys.add(it.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSchemaMap.putAll(writable);
|
||||||
|
buildActionDpSpinner(keys);
|
||||||
|
} else {
|
||||||
|
fallbackLoadTuyaSchema(deviceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<ControlSchema>> call, Throwable t) {
|
||||||
|
android.util.Log.e("SceneDetailActivity",
|
||||||
|
"control-schema 请求失败, 回退涂鸦 schema: " + t.getMessage());
|
||||||
|
fallbackLoadTuyaSchema(deviceId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 物模型不可用时的回退:沿用涂鸦本地 schema(兼容旧设备) */
|
||||||
|
private void fallbackLoadTuyaSchema(String deviceId) {
|
||||||
|
new Thread(() -> {
|
||||||
|
try {
|
||||||
|
java.util.Map<String, SchemaBean> schemaMap =
|
||||||
|
ThingHomeSdk.getDataInstance().getSchema(deviceId);
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
if (schemaMap == null || schemaMap.isEmpty()) {
|
||||||
|
buildActionDpSpinner(new ArrayList<>());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
java.util.Map<String, ControlSchema.ControlItem> legacy = new java.util.HashMap<>();
|
||||||
|
List<String> keys = new ArrayList<>();
|
||||||
|
for (Map.Entry<String, SchemaBean> e : schemaMap.entrySet()) {
|
||||||
|
SchemaBean bean = e.getValue();
|
||||||
|
if (bean == null || !isWritable(bean)) continue;
|
||||||
|
ControlSchema.ControlItem item = toControlItem(e.getKey(), bean);
|
||||||
|
if (item != null) {
|
||||||
|
legacy.put(item.code, item);
|
||||||
|
keys.add(item.code);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentSchemaMap.putAll(legacy);
|
||||||
|
buildActionDpSpinner(keys);
|
||||||
|
});
|
||||||
|
} catch (Exception ex) {
|
||||||
|
runOnUiThread(() -> buildActionDpSpinner(new ArrayList<>()));
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将涂鸦 SchemaBean 转换为 ControlItem(仅用于回退路径,字段尽量从 schemaType 推断) */
|
||||||
|
private ControlSchema.ControlItem toControlItem(String dpId, SchemaBean bean) {
|
||||||
|
ControlSchema.ControlItem item = new ControlSchema.ControlItem();
|
||||||
|
item.code = dpId;
|
||||||
|
item.dpId = dpId;
|
||||||
|
item.name = bean.getName() != null ? String.valueOf(bean.getName()) : dpId;
|
||||||
|
item.writable = isWritable(bean);
|
||||||
|
String type = bean.getSchemaType();
|
||||||
|
item.type = type;
|
||||||
|
item.widget = "switch".equals(type) ? "switch"
|
||||||
|
: "enum".equals(type) ? "segmented"
|
||||||
|
: ("value".equals(type) || "integer".equals(type)) ? "slider" : "text";
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 schema 是否可写(mode 包含 w),回退路径用 */
|
||||||
|
private boolean isWritable(SchemaBean s) {
|
||||||
|
try {
|
||||||
|
String mode = s.getMode();
|
||||||
|
return mode != null && mode.contains("w");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断 ControlItem 是否可写 */
|
||||||
|
private boolean isWritable(ControlSchema.ControlItem item) {
|
||||||
|
return item != null && item.writable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用中文名填充动作指令下拉 */
|
||||||
|
private void buildActionDpSpinner(List<String> dpKeys) {
|
||||||
|
actionDpKeys.clear();
|
||||||
|
actionDpKeys.addAll(dpKeys);
|
||||||
|
List<String> dpNames = new ArrayList<>();
|
||||||
|
for (String k : dpKeys) {
|
||||||
|
ControlSchema.ControlItem s = currentSchemaMap.get(k);
|
||||||
|
String name = s != null && s.name != null && !s.name.isEmpty() ? s.name : k;
|
||||||
|
dpNames.add(name);
|
||||||
|
}
|
||||||
|
if (dpNames.isEmpty()) {
|
||||||
|
dpNames.add("该设备无可写指令");
|
||||||
|
}
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, dpNames);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
spActionDp.setAdapter(adapter);
|
||||||
|
// 若只有一个可写指令,默认构建其值控件
|
||||||
|
if (!dpKeys.isEmpty()) {
|
||||||
|
buildActionValueControl(currentSchemaMap.get(dpKeys.get(0)));
|
||||||
|
} else {
|
||||||
|
flActionValue.removeAllViews();
|
||||||
|
TextView empty = new TextView(this);
|
||||||
|
empty.setText("该设备没有可设置的动作指令");
|
||||||
|
empty.setTextColor(0xFF909399);
|
||||||
|
empty.setTextSize(13);
|
||||||
|
flActionValue.addView(empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据指令类型动态构建动作值控件(基于 ControlItem,平台无关) */
|
||||||
|
private void buildActionValueControl(ControlSchema.ControlItem schema) {
|
||||||
|
flActionValue.removeAllViews();
|
||||||
|
if (schema == null) return;
|
||||||
|
String widget = schema.widget != null ? schema.widget : schema.type;
|
||||||
|
try {
|
||||||
|
if ("switch".equals(widget) || "bool".equals(widget)) {
|
||||||
|
// 布尔:开/关 下拉
|
||||||
|
actionDpTypeIndex = 0;
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, new String[]{"开", "关"});
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
Spinner sp = new Spinner(this);
|
||||||
|
sp.setAdapter(adapter);
|
||||||
|
flActionValue.addView(sp, new android.widget.FrameLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
} else if ("segmented".equals(widget) || "enum".equals(widget)) {
|
||||||
|
// 枚举:使用原始枚举值(ControlSchema 暂未提供中文映射)
|
||||||
|
actionDpTypeIndex = 1;
|
||||||
|
List<String> enumValues = new ArrayList<>();
|
||||||
|
if (schema.enumValues != null) {
|
||||||
|
for (Object v : schema.enumValues) enumValues.add(String.valueOf(v));
|
||||||
|
}
|
||||||
|
if (enumValues.isEmpty()) enumValues.add("无");
|
||||||
|
ArrayAdapter<String> adapter = new ArrayAdapter<>(this,
|
||||||
|
android.R.layout.simple_spinner_item, enumValues);
|
||||||
|
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||||
|
Spinner sp = new Spinner(this);
|
||||||
|
sp.setAdapter(adapter);
|
||||||
|
flActionValue.addView(sp, new android.widget.FrameLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
} else if ("slider".equals(widget) || "number".equals(widget)
|
||||||
|
|| "value".equals(widget) || "integer".equals(widget)) {
|
||||||
|
// 数值:输入框(带单位/范围提示)
|
||||||
|
actionDpTypeIndex = 2;
|
||||||
|
double min = schema.min != null ? schema.min : 0;
|
||||||
|
double max = schema.max != null ? schema.max : 100;
|
||||||
|
String unit = schema.unit != null ? schema.unit : "";
|
||||||
|
EditText et = new EditText(this);
|
||||||
|
et.setHint("请输入数值" + (unit.isEmpty() ? "" : "(" + unit + ")")
|
||||||
|
+ " [" + min + "~" + max + "]");
|
||||||
|
et.setTextSize(14);
|
||||||
|
et.setInputType(android.text.InputType.TYPE_CLASS_NUMBER
|
||||||
|
| android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL);
|
||||||
|
flActionValue.addView(et, new android.widget.FrameLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
} else {
|
||||||
|
// 字符串/颜色/未知:文本输入
|
||||||
|
actionDpTypeIndex = 3;
|
||||||
|
EditText et = new EditText(this);
|
||||||
|
et.setHint("请输入值");
|
||||||
|
et.setTextSize(14);
|
||||||
|
flActionValue.addView(et, new android.widget.FrameLayout.LayoutParams(
|
||||||
|
android.view.ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
|
android.view.ViewGroup.LayoutParams.WRAP_CONTENT));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
android.util.Log.e("SceneDetailActivity", "构建动作值控件失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 枚举值本地化:ControlSchema 暂未提供中文映射,原样返回 */
|
||||||
|
private String localizeEnumValue(ControlSchema.ControlItem item, String rawValue) {
|
||||||
|
return rawValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取当前选中的动作 DP code */
|
||||||
|
private String readSelectedActionKey() {
|
||||||
|
int pos = spActionDp.getSelectedItemPosition();
|
||||||
|
if (pos < 0 || pos >= actionDpKeys.size()) return null;
|
||||||
|
return actionDpKeys.get(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取当前选中的动作值(根据类型转换) */
|
||||||
|
private Object readSelectedActionValue() {
|
||||||
|
View valueView = flActionValue.getChildCount() > 0
|
||||||
|
? flActionValue.getChildAt(0) : null;
|
||||||
|
try {
|
||||||
|
switch (actionDpTypeIndex) {
|
||||||
|
case 0: // Bool
|
||||||
|
if (valueView instanceof Spinner) {
|
||||||
|
String sel = (String) ((Spinner) valueView).getSelectedItem();
|
||||||
|
return "开".equals(sel) || "true".equalsIgnoreCase(sel);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case 1: { // Enum:选中的是中文 label,需要映射回原始 value
|
||||||
|
if (valueView instanceof Spinner) {
|
||||||
|
String sel = (String) ((Spinner) valueView).getSelectedItem();
|
||||||
|
int pos = spActionDp.getSelectedItemPosition();
|
||||||
|
if (pos >= 0 && pos < actionDpKeys.size()) {
|
||||||
|
ControlSchema.ControlItem s = currentSchemaMap.get(actionDpKeys.get(pos));
|
||||||
|
return mapEnumLabelToRaw(s, sel);
|
||||||
|
}
|
||||||
|
return sel;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
case 2: { // Value
|
||||||
|
if (valueView instanceof EditText) {
|
||||||
|
String txt = ((EditText) valueView).getText().toString().trim();
|
||||||
|
try {
|
||||||
|
double v = Double.parseDouble(txt);
|
||||||
|
// 整数则存 Long,否则 Double
|
||||||
|
return v == Math.floor(v) ? (long) v : v;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return txt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
default: // String 或未知
|
||||||
|
if (valueView instanceof EditText) {
|
||||||
|
return ((EditText) valueView).getText().toString().trim();
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 枚举中文 label 映射回原始值:ControlSchema 暂未提供映射,原样返回 */
|
||||||
|
private Object mapEnumLabelToRaw(ControlSchema.ControlItem item, String label) {
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建规则 */
|
||||||
|
private void createRule(String ruleName, TuyaSdkService.SdkDevice trigger, String condition,
|
||||||
|
TuyaSdkService.SdkDevice action, String actionKey, Object actionValue) {
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null) return;
|
||||||
|
// 构造 ruleCode(唯一)
|
||||||
|
String ruleCode = "rule_" + System.currentTimeMillis();
|
||||||
|
// 构造动作 JSON
|
||||||
|
List<Map<String, Object>> actions = new ArrayList<>();
|
||||||
|
Map<String, Object> actionObj = new HashMap<>();
|
||||||
|
actionObj.put("deviceId", action.devId);
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put(actionKey, actionValue);
|
||||||
|
actionObj.put("params", params);
|
||||||
|
actions.add(actionObj);
|
||||||
|
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("ruleName", ruleName);
|
||||||
|
body.put("ruleCode", ruleCode);
|
||||||
|
body.put("sceneId", sceneId);
|
||||||
|
body.put("triggerDeviceId", trigger.devId);
|
||||||
|
body.put("conditionExpression", condition);
|
||||||
|
body.put("actions", actions);
|
||||||
|
body.put("enabled", true);
|
||||||
|
|
||||||
|
ApiClient.getSceneApiService().createRule(auth, body)
|
||||||
|
.enqueue(new Callback<Result<Long>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Long>> call, Response<Result<Long>> response) {
|
||||||
|
Result<Long> body = response.body();
|
||||||
|
if (body != null && body.isSuccess()) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this, "规则已添加", Toast.LENGTH_SHORT).show();
|
||||||
|
loadRules();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"添加失败: " + (body != null ? body.getMessage() : "未知"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Long>> call, Throwable t) {
|
||||||
|
Toast.makeText(SceneDetailActivity.this,
|
||||||
|
"添加失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
289
Android/app/src/main/java/com/xhai/iot/ui/SmartFragment.java
Normal file
289
Android/app/src/main/java/com/xhai/iot/ui/SmartFragment.java
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.appcompat.app.AlertDialog;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.model.PageData;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.model.SceneItem;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能页面(手动执行 / 自动化)
|
||||||
|
* 加载后端场景列表,支持场景创建、一键执行
|
||||||
|
*/
|
||||||
|
public class SmartFragment extends Fragment {
|
||||||
|
|
||||||
|
private TextView tabManual;
|
||||||
|
private TextView tabAutomation;
|
||||||
|
private LinearLayout llSceneList;
|
||||||
|
private LinearLayout llEmpty;
|
||||||
|
private Button btnCreate;
|
||||||
|
|
||||||
|
private SessionManager session;
|
||||||
|
private final List<SceneItem> scenes = new ArrayList<>();
|
||||||
|
private boolean isManualTab = true;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
@Nullable ViewGroup container,
|
||||||
|
@Nullable Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_smart, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
session = SessionManager.getInstance(requireContext());
|
||||||
|
|
||||||
|
tabManual = view.findViewById(R.id.tab_manual);
|
||||||
|
tabAutomation = view.findViewById(R.id.tab_automation);
|
||||||
|
llSceneList = view.findViewById(R.id.ll_scene_list);
|
||||||
|
llEmpty = view.findViewById(R.id.ll_empty);
|
||||||
|
btnCreate = view.findViewById(R.id.btn_create_scene);
|
||||||
|
|
||||||
|
tabManual.setOnClickListener(v -> selectTab(true));
|
||||||
|
tabAutomation.setOnClickListener(v -> selectTab(false));
|
||||||
|
btnCreate.setOnClickListener(v -> showCreateDialog());
|
||||||
|
|
||||||
|
selectTab(true);
|
||||||
|
loadScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onResume() {
|
||||||
|
super.onResume();
|
||||||
|
loadScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectTab(boolean manual) {
|
||||||
|
isManualTab = manual;
|
||||||
|
if (manual) {
|
||||||
|
tabManual.setTextColor(0xFF303133);
|
||||||
|
tabManual.setTextSize(20);
|
||||||
|
tabManual.setTypeface(null, android.graphics.Typeface.BOLD);
|
||||||
|
tabAutomation.setTextColor(0xFF909399);
|
||||||
|
tabAutomation.setTextSize(20);
|
||||||
|
tabAutomation.setTypeface(null, android.graphics.Typeface.NORMAL);
|
||||||
|
} else {
|
||||||
|
tabAutomation.setTextColor(0xFF303133);
|
||||||
|
tabAutomation.setTextSize(20);
|
||||||
|
tabAutomation.setTypeface(null, android.graphics.Typeface.BOLD);
|
||||||
|
tabManual.setTextColor(0xFF909399);
|
||||||
|
tabManual.setTextSize(20);
|
||||||
|
tabManual.setTypeface(null, android.graphics.Typeface.NORMAL);
|
||||||
|
}
|
||||||
|
loadScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加载场景列表 */
|
||||||
|
private void loadScenes() {
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null) {
|
||||||
|
showEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ApiClient.getSceneApiService().getScenes(auth, 1, 100)
|
||||||
|
.enqueue(new Callback<Result<PageData<SceneItem>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<PageData<SceneItem>>> call,
|
||||||
|
Response<Result<PageData<SceneItem>>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
Result<PageData<SceneItem>> body = response.body();
|
||||||
|
scenes.clear();
|
||||||
|
if (body != null && body.isSuccess() && body.getData() != null
|
||||||
|
&& body.getData().getRecords() != null) {
|
||||||
|
scenes.addAll(body.getData().getRecords());
|
||||||
|
}
|
||||||
|
renderScenes();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<PageData<SceneItem>>> call, Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
showEmpty();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 渲染场景列表 */
|
||||||
|
private void renderScenes() {
|
||||||
|
llSceneList.removeAllViews();
|
||||||
|
if (scenes.isEmpty()) {
|
||||||
|
showEmpty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
llEmpty.setVisibility(View.GONE);
|
||||||
|
LayoutInflater inflater = LayoutInflater.from(requireContext());
|
||||||
|
for (SceneItem scene : scenes) {
|
||||||
|
// 按类型过滤:手动执行Tab显示 one_click,自动化Tab显示 automation
|
||||||
|
String type = scene.getSceneType() != null ? scene.getSceneType() : "automation";
|
||||||
|
if (isManualTab && !"one_click".equals(type)) continue;
|
||||||
|
if (!isManualTab && !"automation".equals(type)) continue;
|
||||||
|
|
||||||
|
View item = inflater.inflate(R.layout.item_scene_card, llSceneList, false);
|
||||||
|
TextView tvIcon = item.findViewById(R.id.tv_scene_icon);
|
||||||
|
TextView tvName = item.findViewById(R.id.tv_scene_name);
|
||||||
|
TextView tvDesc = item.findViewById(R.id.tv_scene_desc);
|
||||||
|
TextView tvRun = item.findViewById(R.id.tv_scene_run);
|
||||||
|
|
||||||
|
tvIcon.setText(scene.getIcon() != null && !scene.getIcon().isEmpty()
|
||||||
|
? scene.getIcon() : "⏰");
|
||||||
|
tvName.setText(scene.getSceneName() != null ? scene.getSceneName() : "未命名场景");
|
||||||
|
tvDesc.setText(scene.getDescription() != null && !scene.getDescription().isEmpty()
|
||||||
|
? scene.getDescription() : "点击执行场景");
|
||||||
|
if (!Boolean.TRUE.equals(scene.getEnabled())) {
|
||||||
|
tvDesc.setText("已停用");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击卡片进入场景详情
|
||||||
|
item.setOnClickListener(v -> openSceneDetail(scene));
|
||||||
|
tvRun.setOnClickListener(v -> executeScene(scene));
|
||||||
|
llSceneList.addView(item);
|
||||||
|
}
|
||||||
|
// 如果过滤后为空
|
||||||
|
if (llSceneList.getChildCount() == 0) {
|
||||||
|
showEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showEmpty() {
|
||||||
|
llEmpty.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开场景详情 */
|
||||||
|
private void openSceneDetail(SceneItem scene) {
|
||||||
|
if (scene.getId() == null) return;
|
||||||
|
android.content.Intent intent = new android.content.Intent(requireContext(),
|
||||||
|
SceneDetailActivity.class);
|
||||||
|
intent.putExtra("sceneId", scene.getId());
|
||||||
|
intent.putExtra("sceneName", scene.getSceneName());
|
||||||
|
startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一键执行场景 */
|
||||||
|
private void executeScene(SceneItem scene) {
|
||||||
|
if (scene.getId() == null) return;
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null) return;
|
||||||
|
Toast.makeText(requireContext(), "正在执行场景...", Toast.LENGTH_SHORT).show();
|
||||||
|
ApiClient.getSceneApiService().executeScene(auth, scene.getId())
|
||||||
|
.enqueue(new Callback<Result<List<Map<String, Object>>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<List<Map<String, Object>>>> call,
|
||||||
|
Response<Result<List<Map<String, Object>>>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
Result<List<Map<String, Object>>> body = response.body();
|
||||||
|
if (body != null && body.isSuccess()) {
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
"场景执行成功", Toast.LENGTH_SHORT).show();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
"执行失败: " + (body != null ? body.getMessage() : "未知错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<List<Map<String, Object>>>> call, Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
Toast.makeText(requireContext(), "执行失败: " + t.getMessage(),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建场景对话框 */
|
||||||
|
private void showCreateDialog() {
|
||||||
|
final EditText etName = new EditText(requireContext());
|
||||||
|
etName.setHint("请输入场景名称");
|
||||||
|
|
||||||
|
LinearLayout container = new LinearLayout(requireContext());
|
||||||
|
container.setOrientation(LinearLayout.VERTICAL);
|
||||||
|
container.setPadding(dp(20), dp(8), dp(20), 0);
|
||||||
|
final EditText etDesc = new EditText(requireContext());
|
||||||
|
etDesc.setHint("场景描述(可选)");
|
||||||
|
container.addView(etName);
|
||||||
|
container.addView(etDesc);
|
||||||
|
|
||||||
|
new AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle("创建智能场景")
|
||||||
|
.setView(container)
|
||||||
|
.setPositiveButton("创建", (d, w) -> {
|
||||||
|
String name = etName.getText().toString().trim();
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
Toast.makeText(requireContext(), "请输入场景名称", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createScene(name, etDesc.getText().toString().trim());
|
||||||
|
})
|
||||||
|
.setNegativeButton("取消", null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 调用后端创建场景 */
|
||||||
|
private void createScene(String name, String desc) {
|
||||||
|
String auth = session != null ? session.getBearerToken() : null;
|
||||||
|
if (auth == null) {
|
||||||
|
Toast.makeText(requireContext(), "未登录", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Map<String, Object> body = new HashMap<>();
|
||||||
|
body.put("sceneName", name);
|
||||||
|
body.put("sceneType", isManualTab ? "one_click" : "automation");
|
||||||
|
body.put("description", desc);
|
||||||
|
body.put("enabled", true);
|
||||||
|
ApiClient.getSceneApiService().createScene(auth, body)
|
||||||
|
.enqueue(new Callback<Result<Long>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Long>> call, Response<Result<Long>> response) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
Result<Long> res = response.body();
|
||||||
|
if (res != null && res.isSuccess()) {
|
||||||
|
Toast.makeText(requireContext(), "场景创建成功", Toast.LENGTH_SHORT).show();
|
||||||
|
loadScenes();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(requireContext(),
|
||||||
|
"创建失败: " + (res != null ? res.getMessage() : "未知错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Long>> call, Throwable t) {
|
||||||
|
if (!isAdded()) return;
|
||||||
|
Toast.makeText(requireContext(), "创建失败: " + t.getMessage(),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int dp(int value) {
|
||||||
|
return Math.round(requireContext().getResources()
|
||||||
|
.getDisplayMetrics().density * value);
|
||||||
|
}
|
||||||
|
}
|
||||||
311
Android/app/src/main/java/com/xhai/iot/ui/TuyaBindActivity.java
Normal file
311
Android/app/src/main/java/com/xhai/iot/ui/TuyaBindActivity.java
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.thingclips.smart.home.sdk.ThingHomeSdk;
|
||||||
|
import com.thingclips.smart.android.user.api.ILoginCallback;
|
||||||
|
import com.thingclips.smart.android.user.bean.User;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.data.ApiClient;
|
||||||
|
import com.xhai.iot.data.ApiConfig;
|
||||||
|
import com.xhai.iot.data.ApiService;
|
||||||
|
import com.xhai.iot.model.Result;
|
||||||
|
import com.xhai.iot.util.SessionManager;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import retrofit2.Call;
|
||||||
|
import retrofit2.Callback;
|
||||||
|
import retrofit2.Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦账号绑定页
|
||||||
|
* 通过涂鸦原生 SDK 登录涂鸦账号,绑定后同步到 iotsaas 后端
|
||||||
|
*/
|
||||||
|
public class TuyaBindActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private static final String COUNTRY_CODE = "86"; // 中国区
|
||||||
|
|
||||||
|
private EditText etTuyaUsername;
|
||||||
|
private EditText etTuyaPassword;
|
||||||
|
private Button btnBind;
|
||||||
|
private ProgressBar progressBar;
|
||||||
|
private TextView tvStatus;
|
||||||
|
private SessionManager session;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applyNightMode();
|
||||||
|
setContentView(R.layout.activity_tuya_bind);
|
||||||
|
com.xhai.iot.util.ThemeManager.getInstance(this).applySystemBars(this);
|
||||||
|
|
||||||
|
session = SessionManager.getInstance(this);
|
||||||
|
initViews();
|
||||||
|
checkBindStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void initViews() {
|
||||||
|
etTuyaUsername = findViewById(R.id.et_tuya_username);
|
||||||
|
etTuyaPassword = findViewById(R.id.et_tuya_password);
|
||||||
|
btnBind = findViewById(R.id.btn_bind);
|
||||||
|
progressBar = findViewById(R.id.progress_bind);
|
||||||
|
tvStatus = findViewById(R.id.tv_bind_status);
|
||||||
|
View btnUnbind = findViewById(R.id.btn_unbind);
|
||||||
|
View llBindForm = findViewById(R.id.ll_bind_form);
|
||||||
|
mBindForm = llBindForm;
|
||||||
|
|
||||||
|
// 绑定按钮染主题色
|
||||||
|
android.graphics.drawable.Drawable bg = btnBind.getBackground();
|
||||||
|
if (bg != null) {
|
||||||
|
bg = bg.mutate();
|
||||||
|
bg.setTint(com.xhai.iot.util.ThemeManager.getInstance(this).accent().value);
|
||||||
|
btnBind.setBackground(bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回
|
||||||
|
findViewById(R.id.btn_back).setOnClickListener(v -> finish());
|
||||||
|
|
||||||
|
btnBind.setOnClickListener(v -> doBind());
|
||||||
|
btnUnbind.setOnClickListener(v -> doUnbind());
|
||||||
|
}
|
||||||
|
|
||||||
|
private View mBindForm;
|
||||||
|
|
||||||
|
/** 检查当前是否已绑定(查询后端) */
|
||||||
|
private void checkBindStatus() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
|
||||||
|
ApiClient.getApiService().getTuyaBindStatus(auth)
|
||||||
|
.enqueue(new Callback<Result<Map<String, Object>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Map<String, Object>>> call,
|
||||||
|
Response<Result<Map<String, Object>>> response) {
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
Map<String, Object> data = response.body().getData();
|
||||||
|
if (data != null && Boolean.TRUE.equals(data.get("bound"))) {
|
||||||
|
String account = data.get("account") != null
|
||||||
|
? data.get("account").toString() : null;
|
||||||
|
showBound(true, account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Map<String, Object>>> call, Throwable t) {
|
||||||
|
// 忽略查询失败
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 涂鸦SDK登录并绑定 */
|
||||||
|
private void doBind() {
|
||||||
|
String username = etTuyaUsername.getText().toString().trim();
|
||||||
|
String password = etTuyaPassword.getText().toString().trim();
|
||||||
|
|
||||||
|
if (TextUtils.isEmpty(username)) {
|
||||||
|
Toast.makeText(this, "请输入涂鸦账号(手机号/邮箱)", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (TextUtils.isEmpty(password)) {
|
||||||
|
Toast.makeText(this, "请输入涂鸦密码", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
// 调试模式:涂鸦SDK被禁用时,用模拟UID直接走通绑定流程到后端
|
||||||
|
if (!ApiConfig.TUYA_SDK_ENABLED) {
|
||||||
|
Toast.makeText(this, "调试模式:模拟绑定(不调用涂鸦SDK)", Toast.LENGTH_SHORT).show();
|
||||||
|
// 用输入账号作为模拟涂鸦UID,验证 bind-native 接口
|
||||||
|
syncToBackend("mock_" + username, username);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 涂鸦SDK登录(支持手机号或邮箱)
|
||||||
|
loginTuya(username, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loginTuya(String account, String password) {
|
||||||
|
ThingHomeSdk.getUserInstance().loginWithPhonePassword(
|
||||||
|
COUNTRY_CODE, account, password, new ILoginCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(User user) {
|
||||||
|
runOnUiThread(() -> handleLoginSuccess(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
// 手机号登录失败,尝试邮箱登录
|
||||||
|
ThingHomeSdk.getUserInstance().loginWithEmail(
|
||||||
|
COUNTRY_CODE, account, password, new ILoginCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(User user) {
|
||||||
|
runOnUiThread(() -> handleLoginSuccess(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code2, String error2) {
|
||||||
|
runOnUiThread(() -> {
|
||||||
|
showLoading(false);
|
||||||
|
Toast.makeText(TuyaBindActivity.this,
|
||||||
|
"涂鸦登录失败: " + (error2 != null ? error2 : "账号或密码错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleLoginSuccess(User user) {
|
||||||
|
// 获取涂鸦 UID 和用户名
|
||||||
|
String uid = user.getUid();
|
||||||
|
String account = user.getUsername();
|
||||||
|
|
||||||
|
// 加密保存涂鸦凭据(用于会话过期自动续登,绑定一次永久有效)
|
||||||
|
new com.xhai.iot.util.TuyaCredentialStore(this).save(
|
||||||
|
etTuyaUsername.getText().toString().trim(),
|
||||||
|
etTuyaPassword.getText().toString().trim());
|
||||||
|
|
||||||
|
// 同步到 iotsaas 后端
|
||||||
|
syncToBackend(uid, account);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步涂鸦绑定信息到后端 tuya_user_binding */
|
||||||
|
private void syncToBackend(String tuyaUid, String account) {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) {
|
||||||
|
showLoading(false);
|
||||||
|
Toast.makeText(this, "请先登录本应用", Toast.LENGTH_SHORT).show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> body = new HashMap<>();
|
||||||
|
body.put("tuyaUid", tuyaUid);
|
||||||
|
body.put("account", account);
|
||||||
|
body.put("loginMethod", "phone");
|
||||||
|
body.put("platform", "android");
|
||||||
|
|
||||||
|
ApiClient.getApiService().bindNative(auth, body)
|
||||||
|
.enqueue(new Callback<Result<Map<String, Object>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Map<String, Object>>> call,
|
||||||
|
Response<Result<Map<String, Object>>> response) {
|
||||||
|
showLoading(false);
|
||||||
|
if (response.isSuccessful() && response.body() != null
|
||||||
|
&& response.body().isSuccess()) {
|
||||||
|
session.setTuyaBound(true);
|
||||||
|
showBound(true);
|
||||||
|
Toast.makeText(TuyaBindActivity.this,
|
||||||
|
"涂鸦账号绑定成功!", Toast.LENGTH_SHORT).show();
|
||||||
|
// 1.5秒后自动返回主界面
|
||||||
|
new android.os.Handler(getMainLooper()).postDelayed(() -> {
|
||||||
|
finish();
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
Toast.makeText(TuyaBindActivity.this,
|
||||||
|
"绑定同步失败: " + (response.body() != null
|
||||||
|
? response.body().getMessage() : "未知错误"),
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Map<String, Object>>> call, Throwable t) {
|
||||||
|
showLoading(false);
|
||||||
|
Toast.makeText(TuyaBindActivity.this,
|
||||||
|
"绑定同步失败: " + t.getMessage(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void doUnbind() {
|
||||||
|
String auth = session.getBearerToken();
|
||||||
|
if (auth == null) return;
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
ApiClient.getApiService().unbindTuya(auth)
|
||||||
|
.enqueue(new Callback<Result<Void>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<Result<Void>> call, Response<Result<Void>> response) {
|
||||||
|
showLoading(false);
|
||||||
|
session.setTuyaBound(false);
|
||||||
|
new com.xhai.iot.util.TuyaCredentialStore(TuyaBindActivity.this).clear();
|
||||||
|
// 同步登出涂鸦 SDK(修复:之前未登出导致 SDK 会话残留)
|
||||||
|
try {
|
||||||
|
ThingHomeSdk.getUserInstance().logout(
|
||||||
|
new com.thingclips.smart.android.user.api.ILogoutCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
showBound(false);
|
||||||
|
Toast.makeText(TuyaBindActivity.this,
|
||||||
|
"已解绑涂鸦账号", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<Result<Void>> call, Throwable t) {
|
||||||
|
showLoading(false);
|
||||||
|
Toast.makeText(TuyaBindActivity.this,
|
||||||
|
"解绑失败: " + t.getMessage(), Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showLoading(boolean show) {
|
||||||
|
progressBar.setVisibility(show ? View.VISIBLE : View.GONE);
|
||||||
|
btnBind.setEnabled(!show);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showBound(boolean bound) {
|
||||||
|
showBound(bound, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showBound(boolean bound, String account) {
|
||||||
|
if (bound) {
|
||||||
|
// 状态 pill:绿色"已连接"
|
||||||
|
tvStatus.setText("● 已连接");
|
||||||
|
tvStatus.setTextColor(0xFF16A34A);
|
||||||
|
tvStatus.setBackgroundResource(R.drawable.bg_pill_online);
|
||||||
|
if (account != null && !account.isEmpty()) {
|
||||||
|
etTuyaUsername.setText(account);
|
||||||
|
}
|
||||||
|
// 已连接:隐藏表单与解绑显示(解绑按钮显示)
|
||||||
|
if (mBindForm != null) {
|
||||||
|
mBindForm.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
findViewById(R.id.btn_unbind).setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
// 状态 pill:灰色"未连接"
|
||||||
|
tvStatus.setText("未连接");
|
||||||
|
tvStatus.setTextColor(getColor(R.color.c_muted_fg));
|
||||||
|
tvStatus.setBackgroundResource(R.drawable.bg_chip_muted);
|
||||||
|
etTuyaUsername.setText("");
|
||||||
|
etTuyaPassword.setText("");
|
||||||
|
if (mBindForm != null) {
|
||||||
|
mBindForm.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
findViewById(R.id.btn_unbind).setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package com.xhai.iot.ui;
|
||||||
|
|
||||||
|
import android.graphics.Color;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.widget.FrameLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import com.journeyapps.barcodescanner.CaptureActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自定义扫码页:继承 zxing CaptureActivity,叠加一个「✕ 关闭」按钮,
|
||||||
|
* 保证扫码界面始终有可见的返回/取消入口(默认 CaptureActivity 无显式返回键)。
|
||||||
|
* 主题继承应用 Theme.XhaiIoT(DayNight),跟随系统日夜模式。
|
||||||
|
*/
|
||||||
|
public class ZxingScanActivity extends CaptureActivity {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
TextView close = new TextView(this);
|
||||||
|
close.setText("✕ 关闭");
|
||||||
|
close.setTextColor(Color.WHITE);
|
||||||
|
close.setTextSize(16);
|
||||||
|
close.setPadding(28, 30, 28, 30);
|
||||||
|
FrameLayout.LayoutParams lp = new FrameLayout.LayoutParams(
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||||
|
FrameLayout.LayoutParams.WRAP_CONTENT);
|
||||||
|
lp.gravity = android.view.Gravity.TOP | android.view.Gravity.END;
|
||||||
|
close.setLayoutParams(lp);
|
||||||
|
close.setOnClickListener(v -> finish());
|
||||||
|
addContentView(close, lp);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,215 @@
|
|||||||
|
package com.xhai.iot.ui.adapter;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.CompoundButton;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.Switch;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.bumptech.glide.Glide;
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.CenterCrop;
|
||||||
|
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.model.TuyaDevice;
|
||||||
|
import com.xhai.iot.service.IPlatformService;
|
||||||
|
import com.xhai.iot.service.PlatformServiceManager;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备网格适配器(仿照涂鸦智能 App 首页)
|
||||||
|
* 每张卡:图标 + 设备名 + 状态 + 右上角开关(直接控制)
|
||||||
|
* 卡片整体点击 → 进入详细控制页
|
||||||
|
*/
|
||||||
|
public class DeviceGridAdapter extends RecyclerView.Adapter<DeviceGridAdapter.ViewHolder> {
|
||||||
|
|
||||||
|
private final List<TuyaDevice> devices;
|
||||||
|
private final OnDeviceClick listener;
|
||||||
|
|
||||||
|
public interface OnDeviceClick {
|
||||||
|
void onClick(TuyaDevice device, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceGridAdapter(List<TuyaDevice> devices, OnDeviceClick listener) {
|
||||||
|
this.devices = devices;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(List<TuyaDevice> newDevices) {
|
||||||
|
devices.clear();
|
||||||
|
devices.addAll(newDevices);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_device, parent, false);
|
||||||
|
return new ViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 卡片渐变底色(柔和淡色系,仿涂鸦智能 App) */
|
||||||
|
private static final int[][] GRADIENTS = {
|
||||||
|
{0xFFE8F4FF, 0xFFC9E4FF}, // 淡蓝
|
||||||
|
{0xFFF0ECFF, 0xFFDDD2FF}, // 淡紫
|
||||||
|
{0xFFFFEBF3, 0xFFFFD0E4}, // 淡粉
|
||||||
|
{0xFFFFF3E0, 0xFFFFE0B2}, // 淡橙
|
||||||
|
{0xFFE0F7FA, 0xFFB2EBF2}, // 淡青
|
||||||
|
{0xFFE8F5E9, 0xFFC8E6C9}, // 淡绿
|
||||||
|
};
|
||||||
|
private static final int[] OFFLINE_BG = {0xFFF5F6FA, 0xFFE9ECF2};
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||||
|
TuyaDevice device = devices.get(position);
|
||||||
|
boolean online = device.isOnline();
|
||||||
|
holder.tvName.setText(device.getName());
|
||||||
|
holder.tvStatus.setText(online ? "在线" : "离线");
|
||||||
|
// 在线用主题色(跟随"我的→外观与主题色"),与设备列表保持一致
|
||||||
|
int accent = com.xhai.iot.util.ThemeManager
|
||||||
|
.getInstance(holder.itemView.getContext()).accent().value;
|
||||||
|
holder.tvStatus.setTextColor(online ? accent
|
||||||
|
: holder.itemView.getContext().getColor(R.color.offline));
|
||||||
|
// 渐变背景:按 category/索引分配色板,离线用灰色
|
||||||
|
int[] bg = online ? GRADIENTS[position % GRADIENTS.length] : OFFLINE_BG;
|
||||||
|
android.graphics.drawable.GradientDrawable gradient =
|
||||||
|
new android.graphics.drawable.GradientDrawable(
|
||||||
|
android.graphics.drawable.GradientDrawable.Orientation.TL_BR,
|
||||||
|
bg);
|
||||||
|
gradient.setCornerRadius(dp(18));
|
||||||
|
holder.itemView.setBackground(gradient);
|
||||||
|
// 离线设备图标灰化
|
||||||
|
holder.ivIcon.setAlpha(online ? 1f : 0.45f);
|
||||||
|
// 加载涂鸦设备图标(与涂鸦官方 App 一致),无图标/失败时回退默认图标
|
||||||
|
String iconUrl = device.getIcon();
|
||||||
|
if (iconUrl != null && !iconUrl.isEmpty()) {
|
||||||
|
Glide.with(holder.itemView.getContext())
|
||||||
|
.load(iconUrl)
|
||||||
|
.placeholder(R.drawable.ic_device_default)
|
||||||
|
.error(R.drawable.ic_device_default)
|
||||||
|
.transform(new CenterCrop(), new RoundedCorners(dp(12)))
|
||||||
|
.into(holder.ivIcon);
|
||||||
|
} else {
|
||||||
|
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断设备是否有可写的布尔(开关)DP;无开关功能的设备(如蓝牙网关)不显示开关
|
||||||
|
final String switchDpId = TuyaSdkService.findSwitchDpId(device.getId());
|
||||||
|
// 把数字 id 转换为涂鸦 code(dps map key 是 code),用于读真实开关状态
|
||||||
|
String switchDpCode = null;
|
||||||
|
if (switchDpId != null) {
|
||||||
|
Map<String, Object> probe = new HashMap<>();
|
||||||
|
probe.put(switchDpId, true);
|
||||||
|
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(device.getId(), probe);
|
||||||
|
if (codeMap != null && !codeMap.isEmpty()) {
|
||||||
|
switchDpCode = codeMap.keySet().iterator().next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 从 dps 读取设备真实开关状态(同步显示)
|
||||||
|
Object dpValue = null;
|
||||||
|
if (switchDpCode != null && device.getDps() != null) {
|
||||||
|
dpValue = device.getDps().get(switchDpCode);
|
||||||
|
if (dpValue == null) dpValue = device.getDps().get(switchDpId);
|
||||||
|
}
|
||||||
|
final boolean initialChecked = Boolean.TRUE.equals(dpValue);
|
||||||
|
|
||||||
|
if (switchDpId == null || !online) {
|
||||||
|
// 无开关功能或离线:隐藏/禁用开关,避免网关等设备出现无效开关
|
||||||
|
holder.swDevice.setVisibility(switchDpId == null ? View.GONE : View.VISIBLE);
|
||||||
|
holder.swDevice.setEnabled(online);
|
||||||
|
holder.swDevice.setChecked(false);
|
||||||
|
holder.swDevice.setOnCheckedChangeListener(null);
|
||||||
|
} else {
|
||||||
|
holder.swDevice.setVisibility(View.VISIBLE);
|
||||||
|
holder.swDevice.setEnabled(true);
|
||||||
|
holder.swDevice.setChecked(initialChecked);
|
||||||
|
holder.swDevice.setTag(device.getId());
|
||||||
|
// 用标准事件 setOnCheckedChangeListener 处理开关切换(用户点击触发)
|
||||||
|
holder.swDevice.setOnCheckedChangeListener((buttonView, isChecked) -> {
|
||||||
|
// 通过 isPressed 区分用户点击与代码 setChecked 触发的回调,避免误发指令
|
||||||
|
if (!buttonView.isPressed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
android.util.Log.i("DeviceGridAdapter",
|
||||||
|
"switch clicked: device=" + device.getId()
|
||||||
|
+ " checked=" + isChecked + " dpId=" + switchDpId);
|
||||||
|
Map<String, Object> commands = new HashMap<>();
|
||||||
|
commands.put(switchDpId, isChecked);
|
||||||
|
PlatformServiceManager.getInstance(holder.itemView.getContext())
|
||||||
|
.controlDevice(asPlatformDevice(device), commands,
|
||||||
|
new IPlatformService.ControlCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
android.util.Log.i("DeviceGridAdapter",
|
||||||
|
"control success device=" + device.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
// 控制失败,回滚开关状态并提示原因
|
||||||
|
android.util.Log.e("DeviceGridAdapter",
|
||||||
|
"control fail device=" + device.getId()
|
||||||
|
+ " code=" + code + " err=" + error);
|
||||||
|
holder.swDevice.post(() ->
|
||||||
|
holder.swDevice.setChecked(false));
|
||||||
|
android.widget.Toast.makeText(holder.itemView.getContext(),
|
||||||
|
"控制失败: " + (error != null ? error : code),
|
||||||
|
android.widget.Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 卡片整体点击
|
||||||
|
holder.itemView.setOnClickListener(v -> {
|
||||||
|
if (listener != null) listener.onClick(device, position);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int dp(int value) {
|
||||||
|
return Math.round(android.content.res.Resources.getSystem()
|
||||||
|
.getDisplayMetrics().density * value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private com.xhai.iot.model.PlatformDevice asPlatformDevice(TuyaDevice d) {
|
||||||
|
com.xhai.iot.model.PlatformDevice pd =
|
||||||
|
new com.xhai.iot.model.PlatformDevice();
|
||||||
|
pd.setDeviceId(d.getId());
|
||||||
|
pd.setName(d.getName());
|
||||||
|
pd.setPlatform(com.xhai.iot.model.DevicePlatform.TUYA);
|
||||||
|
pd.setOnline(d.isOnline());
|
||||||
|
pd.setCategory(d.getCategory());
|
||||||
|
pd.setProductId(d.getProductId());
|
||||||
|
pd.setIconUrl(d.getIcon());
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return devices.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
ImageView ivIcon;
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvStatus;
|
||||||
|
Switch swDevice;
|
||||||
|
|
||||||
|
ViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
ivIcon = itemView.findViewById(R.id.iv_device_icon);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_device_name);
|
||||||
|
tvStatus = itemView.findViewById(R.id.tv_device_status);
|
||||||
|
swDevice = itemView.findViewById(R.id.sw_device);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,96 @@
|
|||||||
|
package com.xhai.iot.ui.adapter;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备管理列表适配器
|
||||||
|
*/
|
||||||
|
public class DeviceManageAdapter extends RecyclerView.Adapter<DeviceManageAdapter.ViewHolder> {
|
||||||
|
|
||||||
|
private final List<TuyaSdkService.SdkDevice> devices = new ArrayList<>();
|
||||||
|
private OnEditListener editListener;
|
||||||
|
private OnDeleteListener deleteListener;
|
||||||
|
|
||||||
|
public interface OnEditListener {
|
||||||
|
void onEdit(TuyaSdkService.SdkDevice device);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface OnDeleteListener {
|
||||||
|
void onDelete(TuyaSdkService.SdkDevice device);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnEditListener(OnEditListener listener) {
|
||||||
|
this.editListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnDeleteListener(OnDeleteListener listener) {
|
||||||
|
this.deleteListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(List<TuyaSdkService.SdkDevice> newDevices) {
|
||||||
|
devices.clear();
|
||||||
|
if (newDevices != null) devices.addAll(newDevices);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_device_manage, parent, false);
|
||||||
|
return new ViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||||
|
TuyaSdkService.SdkDevice device = devices.get(position);
|
||||||
|
holder.tvName.setText(device.name);
|
||||||
|
holder.tvStatus.setText(device.online ? "在线" : "离线");
|
||||||
|
// 在线用主题色(跟随"我的→外观与主题色"),与设备列表保持一致
|
||||||
|
int accent = com.xhai.iot.util.ThemeManager
|
||||||
|
.getInstance(holder.itemView.getContext()).accent().value;
|
||||||
|
holder.tvStatus.setTextColor(device.online ? accent
|
||||||
|
: holder.itemView.getContext().getColor(R.color.offline));
|
||||||
|
holder.tvEdit.setOnClickListener(v -> {
|
||||||
|
if (editListener != null) editListener.onEdit(device);
|
||||||
|
});
|
||||||
|
holder.tvDelete.setOnClickListener(v -> {
|
||||||
|
if (deleteListener != null) deleteListener.onDelete(device);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return devices.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
ImageView ivIcon;
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvStatus;
|
||||||
|
TextView tvEdit;
|
||||||
|
TextView tvDelete;
|
||||||
|
|
||||||
|
ViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
ivIcon = itemView.findViewById(R.id.iv_device_icon);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_device_name);
|
||||||
|
tvStatus = itemView.findViewById(R.id.tv_device_status);
|
||||||
|
tvEdit = itemView.findViewById(R.id.tv_device_edit);
|
||||||
|
tvDelete = itemView.findViewById(R.id.tv_device_delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,319 @@
|
|||||||
|
package com.xhai.iot.ui.adapter;
|
||||||
|
|
||||||
|
import android.graphics.drawable.GradientDrawable;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.DiffUtil;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.bumptech.glide.Glide;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.model.TuyaDevice;
|
||||||
|
import com.xhai.iot.service.IPlatformService;
|
||||||
|
import com.xhai.iot.service.PlatformServiceManager;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.ui.widget.PillSwitch;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备页列表行适配器(设计稿 DevicesPage list view)
|
||||||
|
* 行:图标卡(开时淡主色底)+ 名称 + 房间·品牌 + 状态 + 胶囊开关(直接控制)
|
||||||
|
*/
|
||||||
|
public class DeviceRowAdapter extends RecyclerView.Adapter<DeviceRowAdapter.ViewHolder> {
|
||||||
|
|
||||||
|
private final List<TuyaDevice> devices = new ArrayList<>();
|
||||||
|
private final OnDeviceClick listener;
|
||||||
|
private OnDeviceLongClick longClickListener;
|
||||||
|
private int accentColor = 0xFF6B7280;
|
||||||
|
|
||||||
|
/** 局部刷新载荷:仅刷新开关/状态,不重载图标 */
|
||||||
|
private static final Object PAYLOAD_STATE = new Object();
|
||||||
|
|
||||||
|
public interface OnDeviceClick {
|
||||||
|
void onClick(TuyaDevice device, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface OnDeviceLongClick {
|
||||||
|
void onLongClick(TuyaDevice device, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DeviceRowAdapter(OnDeviceClick listener) {
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccentColor(int color) {
|
||||||
|
this.accentColor = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnDeviceLongClickListener(OnDeviceLongClick l) {
|
||||||
|
this.longClickListener = l;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 增量更新(DiffUtil):只有真正变化的行才重绘。
|
||||||
|
* 原来用 notifyDataSetChanged 全量刷新,会导致设备行反复重绑(图标重载、开关重置),
|
||||||
|
* 视觉上表现为"设备排列会闪"
|
||||||
|
*/
|
||||||
|
public void update(List<TuyaDevice> newDevices) {
|
||||||
|
final List<TuyaDevice> old = new ArrayList<>(devices);
|
||||||
|
final List<TuyaDevice> neu = new ArrayList<>(newDevices);
|
||||||
|
DiffUtil.DiffResult result = DiffUtil.calculateDiff(new DiffUtil.Callback() {
|
||||||
|
@Override
|
||||||
|
public int getOldListSize() {
|
||||||
|
return old.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getNewListSize() {
|
||||||
|
return neu.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean areItemsTheSame(int a, int b) {
|
||||||
|
String ia = old.get(a).getId();
|
||||||
|
String ib = neu.get(b).getId();
|
||||||
|
return ia != null && ia.equals(ib);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean areContentsTheSame(int a, int b) {
|
||||||
|
TuyaDevice x = old.get(a);
|
||||||
|
TuyaDevice y = neu.get(b);
|
||||||
|
return x.isOnline() == y.isOnline()
|
||||||
|
&& java.util.Objects.equals(x.getName(), y.getName())
|
||||||
|
&& java.util.Objects.equals(x.getIcon(), y.getIcon())
|
||||||
|
&& java.util.Objects.equals(String.valueOf(x.getDps()),
|
||||||
|
String.valueOf(y.getDps()));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
devices.clear();
|
||||||
|
devices.addAll(neu);
|
||||||
|
result.dispatchUpdatesTo(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 只刷新指定设备所在行(状态变化时局部更新,避免整表重载导致闪烁) */
|
||||||
|
public void refreshDevice(String deviceId) {
|
||||||
|
if (deviceId == null) return;
|
||||||
|
for (int i = 0; i < devices.size(); i++) {
|
||||||
|
String id = devices.get(i).getId();
|
||||||
|
if (id != null && id.equals(deviceId)) {
|
||||||
|
notifyItemChanged(i, PAYLOAD_STATE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public TuyaDevice getAt(int position) {
|
||||||
|
if (position >= 0 && position < devices.size()) return devices.get(position);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_device_row, parent, false);
|
||||||
|
return new ViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull ViewHolder holder, int position,
|
||||||
|
@NonNull List<Object> payloads) {
|
||||||
|
if (payloads != null && !payloads.isEmpty()) {
|
||||||
|
// 局部刷新:只更新开关与状态文字,不重载图标(避免闪)
|
||||||
|
bindSwitchState(holder, devices.get(position));
|
||||||
|
} else {
|
||||||
|
// 注意:父类两参数 onBindViewHolder 是抽象方法,不能 super 调用,需调用本类实现
|
||||||
|
onBindViewHolder(holder, position);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||||
|
TuyaDevice device = devices.get(position);
|
||||||
|
boolean online = device.isOnline();
|
||||||
|
holder.tvName.setText(device.getName());
|
||||||
|
holder.tvSub.setText((online ? "在线" : "离线") + " · 涂鸦");
|
||||||
|
|
||||||
|
// 图标
|
||||||
|
String iconUrl = device.getIcon();
|
||||||
|
if (iconUrl != null && !iconUrl.isEmpty()) {
|
||||||
|
Glide.with(holder.itemView.getContext())
|
||||||
|
.load(iconUrl)
|
||||||
|
.placeholder(R.drawable.ic_device_default)
|
||||||
|
.error(R.drawable.ic_device_default)
|
||||||
|
.into(holder.ivIcon);
|
||||||
|
} else {
|
||||||
|
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
|
||||||
|
}
|
||||||
|
holder.ivIcon.setAlpha(online ? 1f : 0.45f);
|
||||||
|
|
||||||
|
// 开关 DP + 状态(抽成独立方法,便于局部刷新时只更新开关)
|
||||||
|
bindSwitchState(holder, device);
|
||||||
|
|
||||||
|
holder.itemView.setOnClickListener(v -> {
|
||||||
|
if (listener != null) listener.onClick(device, position);
|
||||||
|
});
|
||||||
|
holder.itemView.setOnLongClickListener(v -> {
|
||||||
|
if (longClickListener != null) {
|
||||||
|
longClickListener.onLongClick(device, position);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 绑定胶囊开关与状态文字(抽成独立方法)。
|
||||||
|
* 局部刷新时只调用这里,不重载图标,避免列表闪烁
|
||||||
|
*/
|
||||||
|
private void bindSwitchState(ViewHolder holder, TuyaDevice device) {
|
||||||
|
boolean online = device.isOnline();
|
||||||
|
final List<TuyaSdkService.SwitchChannel> channels =
|
||||||
|
TuyaSdkService.findSwitchChannels(device.getId());
|
||||||
|
// 聚合状态:所有开关位都开才算"全开"
|
||||||
|
int onCount = 0;
|
||||||
|
for (TuyaSdkService.SwitchChannel c : channels) {
|
||||||
|
if (readSwitchState(device, c.dpId)) onCount++;
|
||||||
|
}
|
||||||
|
final int total = channels.size();
|
||||||
|
final boolean allOn = total > 0 && onCount == total;
|
||||||
|
|
||||||
|
// 图标底色:全开=淡主色,否则 muted
|
||||||
|
GradientDrawable bg = new GradientDrawable();
|
||||||
|
bg.setCornerRadius(dp(14));
|
||||||
|
bg.setColor(allOn && online ? alphaAccent() : mutedColor(holder));
|
||||||
|
holder.vIconBg.setBackground(bg);
|
||||||
|
|
||||||
|
holder.pill.setAccentColor(accentColor);
|
||||||
|
if (total == 0 || !online) {
|
||||||
|
holder.pill.setVisibility(total == 0 ? View.GONE : View.VISIBLE);
|
||||||
|
holder.pill.setEnabled(online);
|
||||||
|
holder.pill.setOnUserToggleListener(null);
|
||||||
|
holder.pill.setChecked(false);
|
||||||
|
holder.tvState.setText(online ? "在线" : "离线");
|
||||||
|
// 在线:主题色;离线:灰色
|
||||||
|
holder.tvState.setTextColor(online ? accentColor : mutedFgColor(holder));
|
||||||
|
} else {
|
||||||
|
holder.pill.setVisibility(View.VISIBLE);
|
||||||
|
holder.pill.setEnabled(true);
|
||||||
|
holder.pill.setChecked(allOn);
|
||||||
|
// 多开关位设备显示 "x/y 开";单开关位显示 开/关
|
||||||
|
holder.tvState.setText(total > 1
|
||||||
|
? (onCount + "/" + total + " 开")
|
||||||
|
: (allOn ? "开着" : "已关"));
|
||||||
|
holder.tvState.setTextColor(allOn ? accentColor : mutedFgColor(holder));
|
||||||
|
holder.pill.setOnUserToggleListener(checked -> {
|
||||||
|
// 列表页一次性联动控制该设备的所有开关位
|
||||||
|
// (单个开关位请点进设备详情页控制)
|
||||||
|
Map<String, Object> commands = new HashMap<>();
|
||||||
|
for (TuyaSdkService.SwitchChannel c : channels) {
|
||||||
|
commands.put(c.dpId, checked);
|
||||||
|
}
|
||||||
|
sendSwitchCommand(holder, device, commands, checked);
|
||||||
|
holder.tvState.setText(total > 1
|
||||||
|
? (checked ? total + "/" + total + " 开" : "0/" + total + " 开")
|
||||||
|
: (checked ? "开着" : "已关"));
|
||||||
|
holder.tvState.setTextColor(checked ? accentColor : mutedFgColor(holder));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取某个开关位当前的开关状态 */
|
||||||
|
private boolean readSwitchState(TuyaDevice device, String dpId) {
|
||||||
|
if (dpId == null || device.getDps() == null) return false;
|
||||||
|
Map<String, String> codeMap = TuyaSdkService.getDpCodeMap(device.getId());
|
||||||
|
String code = codeMap != null ? codeMap.get(dpId) : null;
|
||||||
|
Object v = code != null ? device.getDps().get(code) : null;
|
||||||
|
if (v == null) v = device.getDps().get(dpId);
|
||||||
|
return Boolean.TRUE.equals(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下发开关指令;失败时回滚胶囊状态 */
|
||||||
|
private void sendSwitchCommand(ViewHolder holder, TuyaDevice device,
|
||||||
|
Map<String, Object> commands, boolean checked) {
|
||||||
|
PlatformServiceManager.getInstance(holder.itemView.getContext())
|
||||||
|
.controlDevice(asPlatformDevice(device), commands,
|
||||||
|
new IPlatformService.ControlCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
holder.pill.post(() -> holder.pill.setChecked(!checked));
|
||||||
|
android.widget.Toast.makeText(
|
||||||
|
holder.itemView.getContext(),
|
||||||
|
"控制失败: " + (error != null ? error : code),
|
||||||
|
android.widget.Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private int alphaAccent() {
|
||||||
|
int c = accentColor;
|
||||||
|
return android.graphics.Color.argb(Math.round(255 * 0.12f),
|
||||||
|
android.graphics.Color.red(c),
|
||||||
|
android.graphics.Color.green(c),
|
||||||
|
android.graphics.Color.blue(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int mutedColor(ViewHolder h) {
|
||||||
|
return h.itemView.getContext().getColor(R.color.c_muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int mutedFgColor(ViewHolder h) {
|
||||||
|
return h.itemView.getContext().getColor(R.color.c_muted_fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private com.xhai.iot.model.PlatformDevice asPlatformDevice(TuyaDevice d) {
|
||||||
|
com.xhai.iot.model.PlatformDevice pd = new com.xhai.iot.model.PlatformDevice();
|
||||||
|
pd.setDeviceId(d.getId());
|
||||||
|
pd.setName(d.getName());
|
||||||
|
pd.setPlatform(com.xhai.iot.model.DevicePlatform.TUYA);
|
||||||
|
pd.setOnline(d.isOnline());
|
||||||
|
pd.setCategory(d.getCategory());
|
||||||
|
pd.setProductId(d.getProductId());
|
||||||
|
pd.setIconUrl(d.getIcon());
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int dp(int value) {
|
||||||
|
return Math.round(android.content.res.Resources.getSystem()
|
||||||
|
.getDisplayMetrics().density * value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return devices.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
View vIconBg;
|
||||||
|
ImageView ivIcon;
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvSub;
|
||||||
|
TextView tvState;
|
||||||
|
PillSwitch pill;
|
||||||
|
|
||||||
|
ViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
vIconBg = itemView.findViewById(R.id.v_icon_bg);
|
||||||
|
ivIcon = itemView.findViewById(R.id.iv_device_icon);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_device_name);
|
||||||
|
tvSub = itemView.findViewById(R.id.tv_device_sub);
|
||||||
|
tvState = itemView.findViewById(R.id.tv_device_state);
|
||||||
|
pill = itemView.findViewById(R.id.pill_switch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,366 @@
|
|||||||
|
package com.xhai.iot.ui.adapter;
|
||||||
|
|
||||||
|
import android.text.Spannable;
|
||||||
|
import android.text.SpannableString;
|
||||||
|
import android.text.style.ForegroundColorSpan;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.bumptech.glide.Glide;
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.model.TuyaDevice;
|
||||||
|
import com.xhai.iot.service.IPlatformService;
|
||||||
|
import com.xhai.iot.service.PlatformServiceManager;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
import com.xhai.iot.ui.widget.PillSwitch;
|
||||||
|
import com.xhai.iot.util.ThemeManager;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首页 3 列常用设备小卡适配器(设计稿 HomePage 常用设备区)
|
||||||
|
* 卡片:图标 + 胶囊开关 + 名称 + 状态;点开关直接控制,点卡片进面板。
|
||||||
|
*/
|
||||||
|
public class HomeDeviceAdapter extends RecyclerView.Adapter<HomeDeviceAdapter.ViewHolder> {
|
||||||
|
|
||||||
|
private final List<TuyaDevice> devices;
|
||||||
|
private final OnDeviceClick listener;
|
||||||
|
private OnDeviceLongClick longClickListener;
|
||||||
|
private int accentColor = 0xFF6B7280;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 控制指令下发到结果回传的窗口内,锁定胶囊显示为"乐观值"。
|
||||||
|
* 否则点击后列表刷新回来的旧 dps 会把胶囊动画弹回去,再被新值弹回来,视觉上就是闪动。
|
||||||
|
*/
|
||||||
|
private static final long TOGGLE_LOCK_MS = 8000L;
|
||||||
|
private final Map<String, Boolean> pendingToggleValue = new HashMap<>();
|
||||||
|
private final Map<String, Long> pendingToggleAt = new HashMap<>();
|
||||||
|
|
||||||
|
public interface OnDeviceClick {
|
||||||
|
void onClick(TuyaDevice device, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface OnDeviceLongClick {
|
||||||
|
void onLongClick(TuyaDevice device, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HomeDeviceAdapter(List<TuyaDevice> devices, OnDeviceClick listener) {
|
||||||
|
this.devices = devices;
|
||||||
|
this.listener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnDeviceLongClickListener(OnDeviceLongClick l) {
|
||||||
|
this.longClickListener = l;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccentColor(int color) {
|
||||||
|
this.accentColor = color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(List<TuyaDevice> newDevices) {
|
||||||
|
if (newDevices == null) return;
|
||||||
|
// 控制窗口内的设备先把乐观值写回数据模型,避免被后端旧状态覆盖
|
||||||
|
applyPendingToggles(newDevices);
|
||||||
|
if (isSameData(newDevices)) {
|
||||||
|
// 数据与当前完全一致:不重绑,避免整块卡片/胶囊白闪一下
|
||||||
|
android.util.Log.d("HomeDeviceAdapter", "update: 数据无变化,跳过重绑");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
devices.clear();
|
||||||
|
devices.addAll(newDevices);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表内容是否与当前渲染的一致(id / 名称 / 图标 / 在线态 / 类型 / dps) */
|
||||||
|
private boolean isSameData(List<TuyaDevice> newDevices) {
|
||||||
|
if (newDevices.size() != devices.size()) return false;
|
||||||
|
for (int i = 0; i < newDevices.size(); i++) {
|
||||||
|
TuyaDevice a = devices.get(i);
|
||||||
|
TuyaDevice b = newDevices.get(i);
|
||||||
|
if (a == b) continue;
|
||||||
|
if (!java.util.Objects.equals(a.getId(), b.getId())) return false;
|
||||||
|
if (a.isOnline() != b.isOnline()) return false;
|
||||||
|
if (!java.util.Objects.equals(a.getName(), b.getName())) return false;
|
||||||
|
if (!java.util.Objects.equals(a.getIcon(), b.getIcon())) return false;
|
||||||
|
if (!java.util.Objects.equals(a.getCategory(), b.getCategory())) return false;
|
||||||
|
if (!java.util.Objects.equals(a.getDps(), b.getDps())) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 控制窗口内的设备:把开关 DP 直接写成乐观值,让数据模型与胶囊显示保持一致 */
|
||||||
|
private void applyPendingToggles(List<TuyaDevice> list) {
|
||||||
|
if (pendingToggleValue.isEmpty()) return;
|
||||||
|
long now = System.currentTimeMillis();
|
||||||
|
for (TuyaDevice d : list) {
|
||||||
|
Boolean pending = pendingToggleValue.get(d.getId());
|
||||||
|
if (pending == null) continue;
|
||||||
|
Long at = pendingToggleAt.get(d.getId());
|
||||||
|
if (at == null || now - at > TOGGLE_LOCK_MS) {
|
||||||
|
pendingToggleValue.remove(d.getId());
|
||||||
|
pendingToggleAt.remove(d.getId());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String switchDpId = TuyaSdkService.findSwitchDpId(d.getId());
|
||||||
|
if (switchDpId == null) continue;
|
||||||
|
Map<String, Object> dps = d.getDps();
|
||||||
|
if (dps == null) {
|
||||||
|
dps = new HashMap<>();
|
||||||
|
d.setDps(dps);
|
||||||
|
}
|
||||||
|
Map<String, Object> probe = new HashMap<>();
|
||||||
|
probe.put(switchDpId, true);
|
||||||
|
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(d.getId(), probe);
|
||||||
|
if (codeMap != null && !codeMap.isEmpty()) {
|
||||||
|
dps.put(codeMap.keySet().iterator().next(), pending);
|
||||||
|
}
|
||||||
|
dps.put(switchDpId, pending);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_home_device, parent, false);
|
||||||
|
return new ViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||||
|
TuyaDevice device = devices.get(position);
|
||||||
|
boolean online = device.isOnline();
|
||||||
|
holder.tvName.setText(device.getName());
|
||||||
|
|
||||||
|
// 图标:涂鸦设备图标,无则默认
|
||||||
|
String iconUrl = device.getIcon();
|
||||||
|
if (iconUrl != null && !iconUrl.isEmpty()) {
|
||||||
|
Glide.with(holder.itemView.getContext())
|
||||||
|
.load(iconUrl)
|
||||||
|
.placeholder(R.drawable.ic_device_default)
|
||||||
|
.error(R.drawable.ic_device_default)
|
||||||
|
// 去掉默认的 300ms 交叉淡入:列表重绑时图标会重新淡入一次,看起来就是闪一下
|
||||||
|
.dontAnimate()
|
||||||
|
.into(holder.ivIcon);
|
||||||
|
} else {
|
||||||
|
holder.ivIcon.setImageResource(R.drawable.ic_device_default);
|
||||||
|
}
|
||||||
|
holder.ivIcon.setAlpha(online ? 1f : 0.45f);
|
||||||
|
|
||||||
|
// 开关 DP 逻辑(与 DeviceGridAdapter 一致)
|
||||||
|
final String switchDpId = TuyaSdkService.findSwitchDpId(device.getId());
|
||||||
|
String switchDpCode = null;
|
||||||
|
if (switchDpId != null) {
|
||||||
|
Map<String, Object> probe = new HashMap<>();
|
||||||
|
probe.put(switchDpId, true);
|
||||||
|
Map<String, Object> codeMap = TuyaSdkService.convertDpIdToCode(device.getId(), probe);
|
||||||
|
if (codeMap != null && !codeMap.isEmpty()) {
|
||||||
|
switchDpCode = codeMap.keySet().iterator().next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Object dpValue = null;
|
||||||
|
if (switchDpCode != null && device.getDps() != null) {
|
||||||
|
dpValue = device.getDps().get(switchDpCode);
|
||||||
|
if (dpValue == null) dpValue = device.getDps().get(switchDpId);
|
||||||
|
}
|
||||||
|
// 无涂鸦 schema 的设备(平台虚拟/本地设备):从 dps 中兜底取常见开关字段
|
||||||
|
if (dpValue == null && device.getDps() != null) {
|
||||||
|
for (String key : new String[]{"switch_1", "switch", "switch1", "power", "1"}) {
|
||||||
|
Object v = device.getDps().get(key);
|
||||||
|
if (v instanceof Boolean) {
|
||||||
|
dpValue = v;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final boolean isOn = Boolean.TRUE.equals(dpValue);
|
||||||
|
|
||||||
|
// 是否开关类:有涂鸦可写开关 DP,或设备类型(后端/涂鸦 category)属于灯/开关/插座类
|
||||||
|
final boolean switchLike = switchDpId != null
|
||||||
|
|| isSwitchLikeCategory(device.getCategory());
|
||||||
|
|
||||||
|
// 刚点击且控制结果未回传时保持乐观值,避免被列表里的旧 dps 刷回造成胶囊回弹闪动
|
||||||
|
final boolean shownOn = resolveShownState(device.getId(), isOn);
|
||||||
|
|
||||||
|
holder.pill.setAccentColor(accentColor);
|
||||||
|
if (switchDpId == null) {
|
||||||
|
holder.pill.setVisibility(View.GONE);
|
||||||
|
holder.pill.setOnUserToggleListener(null);
|
||||||
|
holder.pill.setChecked(false, false);
|
||||||
|
} else if (!online) {
|
||||||
|
holder.pill.setVisibility(View.VISIBLE);
|
||||||
|
holder.pill.setEnabled(false);
|
||||||
|
holder.pill.setOnUserToggleListener(null);
|
||||||
|
holder.pill.setChecked(false, false);
|
||||||
|
}
|
||||||
|
if (switchDpId == null || !online) {
|
||||||
|
// 开关类在线:已在下方统一渲染;此处仅处理无开关 DP / 离线
|
||||||
|
holder.tvStatus.setText(buildStatusText(holder.tvStatus, online,
|
||||||
|
switchLike, shownOn));
|
||||||
|
} else {
|
||||||
|
holder.pill.setVisibility(View.VISIBLE);
|
||||||
|
holder.pill.setEnabled(true);
|
||||||
|
// 列表绑定/刷新不走动画(否则每次刷新胶囊都会动一下);动画只由用户点击触发
|
||||||
|
holder.pill.setChecked(shownOn, false);
|
||||||
|
// 在线开关类:在线(主题色)+ 已打开/已关闭
|
||||||
|
holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, shownOn));
|
||||||
|
holder.pill.setOnUserToggleListener(checked -> {
|
||||||
|
// 锁定乐观值:下发结果回传前,忽略列表刷新带回来的旧状态
|
||||||
|
lockPendingToggle(device.getId(), checked);
|
||||||
|
Map<String, Object> commands = new HashMap<>();
|
||||||
|
commands.put(switchDpId, checked);
|
||||||
|
PlatformServiceManager.getInstance(holder.itemView.getContext())
|
||||||
|
.controlDevice(asPlatformDevice(device), commands,
|
||||||
|
new IPlatformService.ControlCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess() {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
unlockPendingToggle(device.getId());
|
||||||
|
holder.pill.post(() -> holder.pill.setChecked(!checked));
|
||||||
|
android.widget.Toast.makeText(
|
||||||
|
holder.itemView.getContext(),
|
||||||
|
"控制失败: " + (error != null ? error : code),
|
||||||
|
android.widget.Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
holder.tvStatus.setText(buildStatusText(holder.tvStatus, true, true, checked));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
holder.itemView.setOnClickListener(v -> {
|
||||||
|
if (listener != null) listener.onClick(device, position);
|
||||||
|
});
|
||||||
|
holder.itemView.setOnLongClickListener(v -> {
|
||||||
|
if (longClickListener != null) {
|
||||||
|
longClickListener.onLongClick(device, position);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算胶囊实际应显示的状态。
|
||||||
|
* 若该设备刚被点击、且控制结果尚未回传(此时列表里的 dps 还是旧值),保持乐观值不动,
|
||||||
|
* 否则会出现"胶囊弹回原位、再弹过去"的闪动。
|
||||||
|
*/
|
||||||
|
private boolean resolveShownState(String deviceId, boolean serverOn) {
|
||||||
|
Boolean pending = pendingToggleValue.get(deviceId);
|
||||||
|
if (pending == null) return serverOn;
|
||||||
|
Long at = pendingToggleAt.get(deviceId);
|
||||||
|
boolean expired = at == null || System.currentTimeMillis() - at > TOGGLE_LOCK_MS;
|
||||||
|
if (expired || pending == serverOn) {
|
||||||
|
// 已超时,或服务端状态已追上乐观值:解除锁定,以服务端为准
|
||||||
|
pendingToggleValue.remove(deviceId);
|
||||||
|
pendingToggleAt.remove(deviceId);
|
||||||
|
return serverOn;
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void lockPendingToggle(String deviceId, boolean value) {
|
||||||
|
pendingToggleValue.put(deviceId, value);
|
||||||
|
pendingToggleAt.put(deviceId, System.currentTimeMillis());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unlockPendingToggle(String deviceId) {
|
||||||
|
pendingToggleValue.remove(deviceId);
|
||||||
|
pendingToggleAt.remove(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private com.xhai.iot.model.PlatformDevice asPlatformDevice(TuyaDevice d) {
|
||||||
|
com.xhai.iot.model.PlatformDevice pd = new com.xhai.iot.model.PlatformDevice();
|
||||||
|
pd.setDeviceId(d.getId());
|
||||||
|
pd.setName(d.getName());
|
||||||
|
pd.setPlatform(com.xhai.iot.model.DevicePlatform.TUYA);
|
||||||
|
pd.setOnline(d.isOnline());
|
||||||
|
pd.setCategory(d.getCategory());
|
||||||
|
pd.setProductId(d.getProductId());
|
||||||
|
pd.setIconUrl(d.getIcon());
|
||||||
|
return pd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装状态文案(分段配色):
|
||||||
|
* 离线 → 「离线」(灰)
|
||||||
|
* 在线非开关类(传感器等) → 「在线」(主题色)
|
||||||
|
* 在线开关类(灯/开关/插座)→ 「在线」(主题色)+ 「 · 已打开/已关闭」
|
||||||
|
*/
|
||||||
|
private CharSequence buildStatusText(View anchor, boolean online,
|
||||||
|
boolean switchLike, boolean isOn) {
|
||||||
|
android.content.Context ctx = anchor.getContext();
|
||||||
|
int muted = ctx.getColor(R.color.c_muted_fg);
|
||||||
|
if (!online) {
|
||||||
|
SpannableString sp = new SpannableString("离线");
|
||||||
|
sp.setSpan(new ForegroundColorSpan(muted), 0, sp.length(),
|
||||||
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
if (!switchLike) {
|
||||||
|
SpannableString sp = new SpannableString("在线");
|
||||||
|
sp.setSpan(new ForegroundColorSpan(accentColor), 0, sp.length(),
|
||||||
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
String full = "在线 · " + (isOn ? "已打开" : "已关闭");
|
||||||
|
SpannableString sp = new SpannableString(full);
|
||||||
|
// 「在线」用主题色
|
||||||
|
sp.setSpan(new ForegroundColorSpan(accentColor), 0, 2,
|
||||||
|
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
// 「已打开」用在线绿 / 「已关闭」用灰色
|
||||||
|
sp.setSpan(new ForegroundColorSpan(isOn ? ctx.getColor(R.color.online) : muted),
|
||||||
|
3, full.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
|
||||||
|
return sp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备类型是否为「开关类」(可开/关的设备:灯、开关、插座、排插等)
|
||||||
|
* 兼容本平台 device_type(light/socket/switch)与涂鸦 category(kg/cjkg/tdq/tgq...)
|
||||||
|
*/
|
||||||
|
static boolean isSwitchLikeCategory(String category) {
|
||||||
|
if (category == null) return false;
|
||||||
|
String c = category.trim().toLowerCase();
|
||||||
|
if (c.isEmpty()) return false;
|
||||||
|
// 本平台语义类型
|
||||||
|
if (c.equals("light") || c.equals("socket") || c.equals("switch")
|
||||||
|
|| c.equals("plug") || c.equals("switch_1") || c.equals("power")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 涂鸦 category(含 productId 兜底场景)
|
||||||
|
return c.startsWith("kg") || c.startsWith("cjkg") || c.startsWith("tdq")
|
||||||
|
|| c.startsWith("tgq") || c.startsWith("cz") || c.startsWith("pc")
|
||||||
|
|| c.startsWith("dc") || c.startsWith("dd")
|
||||||
|
|| c.contains("light") || c.contains("switch") || c.contains("socket");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return devices.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
ImageView ivIcon;
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvStatus;
|
||||||
|
PillSwitch pill;
|
||||||
|
|
||||||
|
ViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
ivIcon = itemView.findViewById(R.id.iv_device_icon);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_device_name);
|
||||||
|
tvStatus = itemView.findViewById(R.id.tv_device_status);
|
||||||
|
pill = itemView.findViewById(R.id.pill_switch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,78 @@
|
|||||||
|
package com.xhai.iot.ui.adapter;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 家庭列表适配器
|
||||||
|
*/
|
||||||
|
public class HomeListAdapter extends RecyclerView.Adapter<HomeListAdapter.ViewHolder> {
|
||||||
|
|
||||||
|
private final List<TuyaSdkService.SdkHome> homes = new ArrayList<>();
|
||||||
|
private OnDeleteListener deleteListener;
|
||||||
|
|
||||||
|
public interface OnDeleteListener {
|
||||||
|
void onDelete(TuyaSdkService.SdkHome home, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnDeleteListener(OnDeleteListener listener) {
|
||||||
|
this.deleteListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(List<TuyaSdkService.SdkHome> newHomes) {
|
||||||
|
homes.clear();
|
||||||
|
if (newHomes != null) homes.addAll(newHomes);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TuyaSdkService.SdkHome> getData() {
|
||||||
|
return homes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_home, parent, false);
|
||||||
|
return new ViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||||
|
TuyaSdkService.SdkHome home = homes.get(position);
|
||||||
|
holder.tvName.setText(home.name);
|
||||||
|
holder.tvDevices.setText(home.deviceCount + " 台设备");
|
||||||
|
holder.tvDelete.setOnClickListener(v -> {
|
||||||
|
if (deleteListener != null) deleteListener.onDelete(home, position);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return homes.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvDevices;
|
||||||
|
TextView tvDelete;
|
||||||
|
|
||||||
|
ViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_home_name);
|
||||||
|
tvDevices = itemView.findViewById(R.id.tv_home_devices);
|
||||||
|
tvDelete = itemView.findViewById(R.id.tv_home_delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package com.xhai.iot.ui.adapter;
|
||||||
|
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
import com.xhai.iot.service.TuyaSdkService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 房间列表适配器
|
||||||
|
*/
|
||||||
|
public class RoomListAdapter extends RecyclerView.Adapter<RoomListAdapter.ViewHolder> {
|
||||||
|
|
||||||
|
private final List<TuyaSdkService.SdkRoom> rooms = new ArrayList<>();
|
||||||
|
private OnDeleteListener deleteListener;
|
||||||
|
|
||||||
|
public interface OnDeleteListener {
|
||||||
|
void onDelete(TuyaSdkService.SdkRoom room, int position);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnDeleteListener(OnDeleteListener listener) {
|
||||||
|
this.deleteListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void update(List<TuyaSdkService.SdkRoom> newRooms) {
|
||||||
|
rooms.clear();
|
||||||
|
if (newRooms != null) rooms.addAll(newRooms);
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||||
|
View view = LayoutInflater.from(parent.getContext())
|
||||||
|
.inflate(R.layout.item_room, parent, false);
|
||||||
|
return new ViewHolder(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
|
||||||
|
TuyaSdkService.SdkRoom room = rooms.get(position);
|
||||||
|
holder.tvName.setText(room.name);
|
||||||
|
holder.tvDelete.setOnClickListener(v -> {
|
||||||
|
if (deleteListener != null) deleteListener.onDelete(room, position);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int getItemCount() {
|
||||||
|
return rooms.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
static class ViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
TextView tvName;
|
||||||
|
TextView tvDelete;
|
||||||
|
|
||||||
|
ViewHolder(@NonNull View itemView) {
|
||||||
|
super(itemView);
|
||||||
|
tvName = itemView.findViewById(R.id.tv_room_name);
|
||||||
|
tvDelete = itemView.findViewById(R.id.tv_room_delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
395
Android/app/src/main/java/com/xhai/iot/ui/panel/PanelRouter.java
Normal file
395
Android/app/src/main/java/com/xhai/iot/ui/panel/PanelRouter.java
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
package com.xhai.iot.ui.panel;
|
||||||
|
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.app.Application;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
|
||||||
|
import com.thingclips.smart.api.MicroContext;
|
||||||
|
import com.thingclips.smart.panelcaller.api.AbsPanelCallerService;
|
||||||
|
import com.xhai.iot.ui.DeviceControlActivity;
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备面板路由:按 platform 字段分发到对应平台原生面板;不支持时降级到通用面板。
|
||||||
|
*
|
||||||
|
* 当前阶段:
|
||||||
|
* - 涂鸦 (tuya) → 涂鸦 UI BizBundle 原生面板 (AbsPanelCallerService.goPanelWithCheckAndTip)
|
||||||
|
* - 其他 / 失败 → 通用面板 DeviceControlActivity (DP Schema 动态渲染,兜底)
|
||||||
|
*
|
||||||
|
* 未来接入小米/华为:
|
||||||
|
* - xiaomi → XiaomiPanelRouter.open(...)
|
||||||
|
* - huawei → HuaweiPanelRouter.open(...)
|
||||||
|
*
|
||||||
|
* 兜底机制:goPanelWithCheckAndTip 在某些情况下会"静默失败"(不抛异常、不跳转、
|
||||||
|
* 无日志)。因此调用后启动一个超时监控:若 2.5 秒内没有新 Activity 被打开,
|
||||||
|
* 则自动降级到通用面板,保证用户体验不中断。
|
||||||
|
*/
|
||||||
|
public class PanelRouter {
|
||||||
|
|
||||||
|
private static final String TAG = "PanelRouter";
|
||||||
|
/** 原生面板超时降级时间:goPanel 启动后等待 ThingRCTSmartPanelActivity 启动的最大时长。
|
||||||
|
* 涂鸦原生面板需加载 RN bundle 并初始化 BizBundle,家庭 gid 同步、设备数据准备等可能耗时数秒,
|
||||||
|
* 较短的超时会导致面板未加载完就误降级到通用面板。延长到 5s。 */
|
||||||
|
private static final long PANEL_TIMEOUT = 5000L;
|
||||||
|
|
||||||
|
/** 平台常量 */
|
||||||
|
public static final String PLATFORM_TUYA = "tuya";
|
||||||
|
public static final String PLATFORM_XIAOMI = "xiaomi";
|
||||||
|
public static final String PLATFORM_HUAWEI = "huawei";
|
||||||
|
public static final String PLATFORM_LOCAL = "local";
|
||||||
|
public static final String PLATFORM_GENERIC = "generic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦原生面板开关。
|
||||||
|
* 架构调整:家庭/房间/设备由本平台后端管理,涂鸦 SDK 仅用于控制设备。
|
||||||
|
* 涂鸦原生面板依赖家庭 gid(BizBundle),家庭为空时白屏,故关闭原生面板,
|
||||||
|
* 统一走通用面板(DeviceControlActivity,涂鸦 SDK newDeviceInstance 控制,不依赖家庭)。
|
||||||
|
*/
|
||||||
|
public static final boolean ENABLE_TUYA_NATIVE_PANEL = true;
|
||||||
|
|
||||||
|
/** 静态超时监控器 */
|
||||||
|
private static final TimeoutMonitor sMonitor = new TimeoutMonitor();
|
||||||
|
|
||||||
|
public static void openPanel(AppCompatActivity ctx, String deviceId,
|
||||||
|
String platform, String name) {
|
||||||
|
if (ctx == null || deviceId == null) {
|
||||||
|
Log.w(TAG, "openPanel: 参数缺失");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (platform == null || platform.isEmpty()) platform = PLATFORM_TUYA;
|
||||||
|
Log.i(TAG, "openPanel: deviceId=" + deviceId + ", platform=" + platform);
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case PLATFORM_TUYA:
|
||||||
|
if (ENABLE_TUYA_NATIVE_PANEL) {
|
||||||
|
openTuyaPanel(ctx, deviceId, name);
|
||||||
|
} else {
|
||||||
|
Log.i(TAG, "涂鸦原生面板已禁用(lifecycle冲突),走通用面板: " + deviceId);
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PLATFORM_XIAOMI:
|
||||||
|
case PLATFORM_HUAWEI:
|
||||||
|
Log.w(TAG, "平台 " + platform + " 原生面板尚未接入,降级到通用面板");
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
break;
|
||||||
|
case PLATFORM_LOCAL:
|
||||||
|
case PLATFORM_GENERIC:
|
||||||
|
default:
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void openTuyaPanel(AppCompatActivity ctx, String deviceId, String name) {
|
||||||
|
// 本地家庭与涂鸦家庭互不同步:本地家庭只服务于本平台的业务归属,
|
||||||
|
// 涂鸦设备统一挂在涂鸦侧的家庭里,二者不做映射也不做校验。
|
||||||
|
// 唯一硬性前提:gid 不能为 0,否则 goPanelWithCheckAndTip 会静默失败。
|
||||||
|
long gid = com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid();
|
||||||
|
if (gid > 0) {
|
||||||
|
ensureHomeDataThenOpen(ctx, deviceId, name, gid);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "涂鸦 gid 未同步,先同步涂鸦家庭再打开面板: " + deviceId);
|
||||||
|
syncGidAndOpen(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开面板前补齐涂鸦家庭详情。
|
||||||
|
* <p>
|
||||||
|
* 涂鸦原生面板判断设备在线/离线、以及 Mesh 子设备的本地通道,都依赖 SDK 家庭缓存
|
||||||
|
* (HomeBean.deviceList / meshList / sigMeshList)。本平台首页设备来自自建后端,
|
||||||
|
* SDK 家庭详情可能从未加载(日志中可见 deviceList=null、meshList=null),
|
||||||
|
* 会导致面板拿到不完整的设备状态而显示"设备不在线"。
|
||||||
|
* 这里强制拉一次家庭详情,顺带刷新设备真实在线状态并初始化蓝牙 Mesh 网络。
|
||||||
|
*/
|
||||||
|
private static void ensureHomeDataThenOpen(AppCompatActivity ctx, String deviceId, String name, long gid) {
|
||||||
|
try {
|
||||||
|
com.xhai.iot.service.TuyaSdkService.ensureMeshConnected(gid);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
Log.w(TAG, "初始化 Mesh 网络失败(忽略,继续打开面板): " + e.getMessage());
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
com.thingclips.smart.home.sdk.api.IThingHome home =
|
||||||
|
com.thingclips.smart.home.sdk.ThingHomeSdk.newHomeInstance(gid);
|
||||||
|
home.getHomeDetail(new com.thingclips.smart.home.sdk.callback.IThingHomeResultCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(com.thingclips.smart.home.sdk.bean.HomeBean homeBean) {
|
||||||
|
int devCount = homeBean != null && homeBean.getDeviceList() != null
|
||||||
|
? homeBean.getDeviceList().size() : -1;
|
||||||
|
Log.i(TAG, "家庭详情已加载(gid=" + gid + ", 设备数=" + devCount
|
||||||
|
+ "),准备打开面板: " + deviceId);
|
||||||
|
postToMain(() -> {
|
||||||
|
if (ctx.isFinishing() || ctx.isDestroyed()) return;
|
||||||
|
doOpenTuyaPanel(ctx, deviceId, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
Log.w(TAG, "家庭详情加载失败(" + code + " " + error + "),仍尝试打开面板: " + deviceId);
|
||||||
|
postToMain(() -> {
|
||||||
|
if (ctx.isFinishing() || ctx.isDestroyed()) return;
|
||||||
|
doOpenTuyaPanel(ctx, deviceId, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable e) {
|
||||||
|
Log.w(TAG, "加载家庭详情异常,直接打开面板: " + e.getMessage());
|
||||||
|
doOpenTuyaPanel(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void postToMain(Runnable action) {
|
||||||
|
new Handler(Looper.getMainLooper()).post(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 异步查询涂鸦家庭列表并同步 gid,然后打开原生面板;无家庭/失败则降级通用面板 */
|
||||||
|
private static void syncGidAndOpen(AppCompatActivity ctx, String deviceId, String name) {
|
||||||
|
try {
|
||||||
|
com.thingclips.smart.home.sdk.ThingHomeSdk.getHomeManagerInstance()
|
||||||
|
.queryHomeList(new com.thingclips.smart.home.sdk.callback.IThingGetHomeListCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(java.util.List<com.thingclips.smart.home.sdk.bean.HomeBean> homes) {
|
||||||
|
if (homes == null || homes.isEmpty()) {
|
||||||
|
Log.w(TAG, "涂鸦无家庭(gid=0),原生面板无法打开,降级通用面板: " + deviceId);
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 优先选「包含目标设备」的家庭,保证 gid 与设备归属一致
|
||||||
|
com.thingclips.smart.home.sdk.bean.HomeBean target = null;
|
||||||
|
for (com.thingclips.smart.home.sdk.bean.HomeBean home : homes) {
|
||||||
|
if (home.getDeviceList() == null) continue;
|
||||||
|
for (com.thingclips.smart.sdk.bean.DeviceBean dev : home.getDeviceList()) {
|
||||||
|
if (deviceId.equals(dev.getDevId())) {
|
||||||
|
target = home;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (target != null) break;
|
||||||
|
}
|
||||||
|
boolean matched = target != null;
|
||||||
|
if (target == null) target = homes.get(0);
|
||||||
|
com.xhai.iot.service.BizBundleFamilyServiceImpl
|
||||||
|
.updateCurrentHomeId(target.getHomeId(), target.getName());
|
||||||
|
Log.i(TAG, "已同步涂鸦家庭 gid=" + target.getHomeId()
|
||||||
|
+ " name=" + target.getName()
|
||||||
|
+ " 命中目标设备=" + matched
|
||||||
|
+ "(家庭总数=" + homes.size() + ")");
|
||||||
|
doOpenTuyaPanel(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
Log.w(TAG, "查询涂鸦家庭失败(" + code + ":" + error + "): " + deviceId);
|
||||||
|
// 涂鸦会话过期(本地 token 失效,isLogin 仍为 true):
|
||||||
|
// 用本地加密凭据自动续登,成功后重试打开原生面板
|
||||||
|
if (code != null && code.toUpperCase().contains("SESSION_LOSS")) {
|
||||||
|
reloginThenOpen(ctx, deviceId, name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Throwable e) {
|
||||||
|
Log.w(TAG, "同步 gid 异常,降级通用面板: " + e.getMessage(), e);
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 涂鸦会话过期:自动续登成功后重试打开原生面板,失败降级通用面板 */
|
||||||
|
private static void reloginThenOpen(AppCompatActivity ctx, String deviceId, String name) {
|
||||||
|
if (!com.xhai.iot.util.TuyaAutoRelogin.canAutoRelogin(ctx)) {
|
||||||
|
// 无本地凭据(从未绑定或应用数据被清理):明确告知用户去重新绑定,
|
||||||
|
// 不再静默降级,否则用户只看到"点击没跳转到涂鸦原生面板"。
|
||||||
|
Log.w(TAG, "无本地涂鸦凭据,无法续登,提示重新绑定后降级通用面板: " + deviceId);
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> {
|
||||||
|
if (ctx.isFinishing() || ctx.isDestroyed()) return;
|
||||||
|
Toast.makeText(ctx,
|
||||||
|
"涂鸦账号登录已过期,请在「我的」页面重新绑定涂鸦账号",
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "涂鸦会话过期,自动续登后重试原生面板: " + deviceId);
|
||||||
|
com.xhai.iot.util.TuyaAutoRelogin.relogin(ctx,
|
||||||
|
new com.xhai.iot.util.TuyaAutoRelogin.Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(com.thingclips.smart.android.user.bean.User user) {
|
||||||
|
// 续登成功:回到主线程重试打开面板
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> {
|
||||||
|
if (ctx.isFinishing() || ctx.isDestroyed()) return;
|
||||||
|
Log.i(TAG, "续登成功,重试打开原生面板: " + deviceId);
|
||||||
|
openTuyaPanel(ctx, deviceId, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message) {
|
||||||
|
Log.w(TAG, "续登失败(" + message + "),降级通用面板: " + deviceId);
|
||||||
|
new Handler(Looper.getMainLooper()).post(() -> {
|
||||||
|
if (ctx.isFinishing() || ctx.isDestroyed()) return;
|
||||||
|
Toast.makeText(ctx,
|
||||||
|
"涂鸦账号登录已失效,请在「我的」页面重新绑定涂鸦账号",
|
||||||
|
Toast.LENGTH_LONG).show();
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void doOpenTuyaPanel(AppCompatActivity ctx, String deviceId, String name) {
|
||||||
|
try {
|
||||||
|
AbsPanelCallerService service = MicroContext.getServiceManager()
|
||||||
|
.findServiceByInterface(AbsPanelCallerService.class.getName());
|
||||||
|
if (service == null) {
|
||||||
|
Log.w(TAG, "AbsPanelCallerService 未注册,降级到通用面板");
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 诊断:gid / SDK 登录态 / 设备是否在 SDK 缓存中,均为面板能否打开的关键条件
|
||||||
|
boolean sdkLogin = false;
|
||||||
|
String devInfo = "null";
|
||||||
|
try {
|
||||||
|
sdkLogin = com.thingclips.smart.home.sdk.ThingHomeSdk.getUserInstance().isLogin();
|
||||||
|
com.thingclips.smart.sdk.bean.DeviceBean bean =
|
||||||
|
com.thingclips.smart.home.sdk.ThingHomeSdk.getDataInstance().getDeviceBean(deviceId);
|
||||||
|
if (bean != null) {
|
||||||
|
// 云端在线 / 本地蓝牙在线 分开打印:Mesh 子设备常见"本地在线、云端离线"
|
||||||
|
devInfo = "name=" + bean.getName()
|
||||||
|
+ ",category=" + bean.getCategory()
|
||||||
|
+ ",online=" + bean.getIsOnline()
|
||||||
|
+ ",localOnline=" + bean.getIsLocalOnline()
|
||||||
|
+ ",parentId=" + bean.getParentId()
|
||||||
|
+ ",productId=" + bean.getProductId();
|
||||||
|
}
|
||||||
|
} catch (Throwable ignored) {
|
||||||
|
}
|
||||||
|
Log.i(TAG, "打开原生面板: deviceId=" + deviceId
|
||||||
|
+ ", gid=" + com.xhai.iot.service.BizBundleFamilyServiceImpl.getCurrentGid()
|
||||||
|
+ ", sdkLogin=" + sdkLogin
|
||||||
|
+ ", 设备(" + devInfo + ")");
|
||||||
|
// 启动超时监控:若超时未打开新 Activity,则询问用户是否切换通用面板
|
||||||
|
sMonitor.start(ctx, deviceId, name);
|
||||||
|
service.goPanelWithCheckAndTip(ctx, deviceId);
|
||||||
|
Log.i(TAG, "已请求跳转到涂鸦原生面板: " + deviceId);
|
||||||
|
} catch (Throwable e) {
|
||||||
|
Log.w(TAG, "跳转涂鸦原生面板异常,降级到通用面板: " + e.getMessage(), e);
|
||||||
|
sMonitor.cancel();
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void openGenericPanel(AppCompatActivity ctx, String deviceId, String name) {
|
||||||
|
Intent intent = new Intent(ctx, DeviceControlActivity.class);
|
||||||
|
intent.putExtra("deviceId", deviceId);
|
||||||
|
intent.putExtra("deviceName", name);
|
||||||
|
ctx.startActivity(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 原生面板加载超时对话框:让用户选择重新加载 / 切换通用面板 / 取消 */
|
||||||
|
private static void showPanelTimeoutDialog(AppCompatActivity ctx, String deviceId, String name) {
|
||||||
|
if (ctx == null || ctx.isFinishing() || ctx.isDestroyed()) return;
|
||||||
|
try {
|
||||||
|
new androidx.appcompat.app.AlertDialog.Builder(ctx)
|
||||||
|
.setTitle("面板加载较慢")
|
||||||
|
.setMessage("原生控制面板加载超时,请选择处理方式:")
|
||||||
|
.setPositiveButton("重新加载", (d, w) -> {
|
||||||
|
d.dismiss();
|
||||||
|
Log.i(TAG, "用户选择重新加载原生面板: " + deviceId);
|
||||||
|
// 重新走原生面板流程(会启动新一轮超时监控)
|
||||||
|
doOpenTuyaPanel(ctx, deviceId, name);
|
||||||
|
})
|
||||||
|
.setNegativeButton("切换通用面板", (d, w) -> {
|
||||||
|
d.dismiss();
|
||||||
|
Log.i(TAG, "用户选择切换通用面板: " + deviceId);
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
})
|
||||||
|
.setNeutralButton("取消", (d, w) -> {
|
||||||
|
d.dismiss();
|
||||||
|
Log.i(TAG, "用户取消面板操作: " + deviceId);
|
||||||
|
})
|
||||||
|
.setCancelable(true)
|
||||||
|
.show();
|
||||||
|
} catch (Throwable e) {
|
||||||
|
Log.w(TAG, "弹窗失败,降级通用面板: " + e.getMessage(), e);
|
||||||
|
openGenericPanel(ctx, deviceId, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 面板跳转超时监控:
|
||||||
|
* goPanel 调用后,监听 Activity 生命周期;若指定时间内没有新的 Activity 打开,
|
||||||
|
* 则认为原生面板跳转失败,降级到通用面板。
|
||||||
|
*/
|
||||||
|
private static final class TimeoutMonitor {
|
||||||
|
private final Handler mHandler = new Handler(Looper.getMainLooper());
|
||||||
|
private final Application.ActivityLifecycleCallbacks mCallbacks;
|
||||||
|
private Runnable mFallback;
|
||||||
|
private boolean mArmed;
|
||||||
|
/** 是否已注册生命周期回调(避免每次点击重复注册、回调被多次触发) */
|
||||||
|
private boolean mRegistered;
|
||||||
|
/** 发起跳转的原 Activity,用于排除其自身 resume 造成的误判 */
|
||||||
|
private WeakReference<Activity> mOrigin;
|
||||||
|
|
||||||
|
TimeoutMonitor() {
|
||||||
|
mCallbacks = new Application.ActivityLifecycleCallbacks() {
|
||||||
|
@Override public void onActivityResumed(Activity activity) {
|
||||||
|
if (!mArmed) return;
|
||||||
|
Activity origin = mOrigin != null ? mOrigin.get() : null;
|
||||||
|
// 原 Activity 自身 resume(如弹窗关闭)不代表跳转成功,忽略
|
||||||
|
if (activity == origin) return;
|
||||||
|
cancel();
|
||||||
|
Log.i(TAG, "检测到新 Activity 打开,原生面板跳转成功: " + activity.getClass().getSimpleName());
|
||||||
|
}
|
||||||
|
@Override public void onActivityStarted(Activity activity) {}
|
||||||
|
@Override public void onActivityStopped(Activity activity) {}
|
||||||
|
@Override public void onActivityPaused(Activity activity) {}
|
||||||
|
@Override public void onActivitySaveInstanceState(Activity activity, android.os.Bundle outState) {}
|
||||||
|
@Override public void onActivityDestroyed(Activity activity) {}
|
||||||
|
@Override public void onActivityCreated(Activity activity, android.os.Bundle savedInstanceState) {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
void start(AppCompatActivity ctx, String deviceId, String name) {
|
||||||
|
cancel();
|
||||||
|
mArmed = true;
|
||||||
|
mOrigin = new WeakReference<>(ctx);
|
||||||
|
if (!mRegistered) {
|
||||||
|
ctx.getApplication().registerActivityLifecycleCallbacks(mCallbacks);
|
||||||
|
mRegistered = true;
|
||||||
|
}
|
||||||
|
mFallback = () -> {
|
||||||
|
Activity original = mOrigin != null ? mOrigin.get() : null;
|
||||||
|
// 若原 Activity 已不在前台(被覆盖/销毁),说明面板可能已打开,无需处理
|
||||||
|
if (original != null && (original.isFinishing() || original.isDestroyed())) {
|
||||||
|
Log.i(TAG, "原Activity已结束,面板已打开,无需处理");
|
||||||
|
sMonitor.cancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 停止监控标记,弹框期间新的 resumed 不再触发 cancel(避免误处理)
|
||||||
|
mArmed = false;
|
||||||
|
Log.w(TAG, "原生面板加载超时(" + PANEL_TIMEOUT + "ms),询问用户处理方式: " + deviceId);
|
||||||
|
// 不自动降级,弹框让用户选择:重新加载 / 切换通用面板 / 取消
|
||||||
|
showPanelTimeoutDialog(ctx, deviceId, name);
|
||||||
|
};
|
||||||
|
mHandler.postDelayed(mFallback, PANEL_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancel() {
|
||||||
|
mArmed = false;
|
||||||
|
if (mFallback != null) {
|
||||||
|
mHandler.removeCallbacks(mFallback);
|
||||||
|
mFallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
177
Android/app/src/main/java/com/xhai/iot/ui/widget/PillSwitch.java
Normal file
177
Android/app/src/main/java/com/xhai/iot/ui/widget/PillSwitch.java
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
package com.xhai.iot.ui.widget;
|
||||||
|
|
||||||
|
import android.animation.ValueAnimator;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.graphics.Canvas;
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.util.AttributeSet;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 胶囊开关(对应设计稿 Toggle:36x20 圆角轨道 + 16dp 白色圆点,300ms 动画)
|
||||||
|
* accent 色轨道。仅用户点击时触发回调,代码 setChecked 不触发(与 Switch.isPressed 行为一致)。
|
||||||
|
*/
|
||||||
|
public class PillSwitch extends View {
|
||||||
|
|
||||||
|
private boolean checked = false;
|
||||||
|
private int accentColor = 0xFF6B7280;
|
||||||
|
private int trackOffColor = 0xFFF1F2F6;
|
||||||
|
private Boolean trackOffNight = null; // 缓存:轨道色解析时的深色态
|
||||||
|
|
||||||
|
private final Paint trackPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private final Paint thumbPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
|
||||||
|
private float thumbProgress = 0f; // 0=off 1=on
|
||||||
|
private ValueAnimator animator;
|
||||||
|
private OnUserToggleListener listener;
|
||||||
|
|
||||||
|
public interface OnUserToggleListener {
|
||||||
|
void onToggle(boolean checked);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PillSwitch(Context context) {
|
||||||
|
super(context);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PillSwitch(Context context, AttributeSet attrs) {
|
||||||
|
super(context, attrs);
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void init() {
|
||||||
|
float density = getResources().getDisplayMetrics().density;
|
||||||
|
// 36x20dp
|
||||||
|
android.view.ViewGroup.LayoutParams lp = getLayoutParams();
|
||||||
|
setMinimumWidth(Math.round(36 * density));
|
||||||
|
setMinimumHeight(Math.round(20 * density));
|
||||||
|
setClickable(true);
|
||||||
|
setFocusable(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAccentColor(int color) {
|
||||||
|
this.accentColor = color;
|
||||||
|
invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setChecked(boolean checked) {
|
||||||
|
setChecked(checked, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置选中态。
|
||||||
|
*
|
||||||
|
* @param animate 是否播放位移动画。列表绑定/数据刷新时传 false 直接落到目标位置,
|
||||||
|
* 否则每次刷新都会重放一次 250ms 动画,视觉上表现为胶囊"闪动"。
|
||||||
|
*/
|
||||||
|
public void setChecked(boolean checked, boolean animate) {
|
||||||
|
boolean changed = this.checked != checked;
|
||||||
|
this.checked = checked;
|
||||||
|
float target = checked ? 1f : 0f;
|
||||||
|
boolean running = animator != null && animator.isRunning();
|
||||||
|
android.util.Log.d("PillSwitch", "setChecked(" + checked + ", animate=" + animate
|
||||||
|
+ ") changed=" + changed + " running=" + running + " progress=" + thumbProgress);
|
||||||
|
if (!animate) {
|
||||||
|
// 用户点击触发的动画正在进行、且状态与目标一致:不要打断,
|
||||||
|
// 否则圆点会从动画中途瞬移到终点,视觉上就是"闪一下"。
|
||||||
|
if (!changed && running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 状态没变且圆点已在目标位置:无需重绘
|
||||||
|
if (!changed && Math.abs(thumbProgress - target) < 0.01f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelAnimator();
|
||||||
|
thumbProgress = target;
|
||||||
|
invalidate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 状态没变且圆点已在目标位置:不重放动画(避免无意义的闪动)
|
||||||
|
if (!changed && Math.abs(thumbProgress - target) < 0.01f) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animateTo(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isChecked() {
|
||||||
|
return checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setOnUserToggleListener(OnUserToggleListener l) {
|
||||||
|
this.listener = l;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void animateTo(float target) {
|
||||||
|
if (animator != null) {
|
||||||
|
animator.cancel();
|
||||||
|
}
|
||||||
|
animator = ValueAnimator.ofFloat(thumbProgress, target);
|
||||||
|
animator.setDuration(250);
|
||||||
|
animator.addUpdateListener(a -> {
|
||||||
|
thumbProgress = (float) a.getAnimatedValue();
|
||||||
|
invalidate();
|
||||||
|
});
|
||||||
|
animator.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
|
||||||
|
float density = getResources().getDisplayMetrics().density;
|
||||||
|
int w = Math.round(36 * density);
|
||||||
|
int h = Math.round(20 * density);
|
||||||
|
setMeasuredDimension(
|
||||||
|
resolveSize(w, widthMeasureSpec),
|
||||||
|
resolveSize(h, heightMeasureSpec));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onDraw(Canvas canvas) {
|
||||||
|
super.onDraw(canvas);
|
||||||
|
float w = getWidth();
|
||||||
|
float h = getHeight();
|
||||||
|
float pad = h * 0.1f; // 2dp/20dp
|
||||||
|
|
||||||
|
trackPaint.setColor(checked ? accentColor : resolveTrackOffColor());
|
||||||
|
float radius = h / 2f;
|
||||||
|
canvas.drawRoundRect(0, 0, w, h, radius, radius, trackPaint);
|
||||||
|
|
||||||
|
float thumbR = (h - pad * 2) / 2f;
|
||||||
|
float maxTravel = w - pad * 2 - thumbR * 2;
|
||||||
|
float cx = pad + thumbR + thumbProgress * maxTravel;
|
||||||
|
float cy = h / 2f;
|
||||||
|
thumbPaint.setColor(0xFFFFFFFF);
|
||||||
|
canvas.drawCircle(cx, cy, thumbR, thumbPaint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean performClick() {
|
||||||
|
super.performClick();
|
||||||
|
// 用户点击:播放动画反馈
|
||||||
|
setChecked(!checked, true);
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onToggle(checked);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cancelAnimator() {
|
||||||
|
if (animator != null) {
|
||||||
|
animator.cancel();
|
||||||
|
animator = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭态轨道色(跟随浅色/深色主题资源):
|
||||||
|
* 亮色 #F1F2F6,深色 #52525B —— 深色下用中灰,避免与白色圆点糊在一起分不清。
|
||||||
|
*/
|
||||||
|
private int resolveTrackOffColor() {
|
||||||
|
boolean night = (getContext().getResources().getConfiguration().uiMode
|
||||||
|
& android.content.res.Configuration.UI_MODE_NIGHT_MASK)
|
||||||
|
== android.content.res.Configuration.UI_MODE_NIGHT_YES;
|
||||||
|
if (trackOffNight == null || trackOffNight != night) {
|
||||||
|
trackOffColor = getContext().getColor(com.xhai.iot.R.color.c_switch_track_off);
|
||||||
|
trackOffNight = night;
|
||||||
|
}
|
||||||
|
return trackOffColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
package com.xhai.iot.util;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.graphics.drawable.Icon;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 桌面快捷方式工具。
|
||||||
|
* <p>
|
||||||
|
* 涂鸦 SDK 会通过 INSTALL_SHORTCUT 广播自建快捷方式,故 Manifest 已用 tools:node="remove"
|
||||||
|
* 移除该权限。MumIot 图标改用 Android 8+ 官方 API
|
||||||
|
* {@link android.content.pm.ShortcutManager#requestPinShortcut} 请求固定到主屏幕,
|
||||||
|
* 会弹出系统确认框,无需任何权限。
|
||||||
|
*/
|
||||||
|
public class HomeShortcutHelper {
|
||||||
|
|
||||||
|
private static final String TAG = "HomeShortcutHelper";
|
||||||
|
private static final String SHORTCUT_ID = "mumiot_home";
|
||||||
|
|
||||||
|
/** 请求把 MumIot 固定到主屏幕(Android 8+,弹出系统确认框) */
|
||||||
|
public static void requestPinMumIot(Context context) {
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||||
|
// Android 8 以下无 requestPinShortcut,提示手动添加
|
||||||
|
Log.w(TAG, "Android 8 以下无法自动固定,请手动添加");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
android.content.pm.ShortcutManager sm =
|
||||||
|
context.getSystemService(android.content.pm.ShortcutManager.class);
|
||||||
|
if (sm == null || !sm.isRequestPinShortcutSupported()) {
|
||||||
|
Log.w(TAG, "系统不支持固定快捷方式,请手动添加");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
android.content.pm.ShortcutInfo info =
|
||||||
|
new android.content.pm.ShortcutInfo.Builder(context, SHORTCUT_ID)
|
||||||
|
.setShortLabel("MumIot")
|
||||||
|
.setLongLabel("MumIot")
|
||||||
|
.setIcon(Icon.createWithResource(context, R.mipmap.ic_launcher))
|
||||||
|
// 指向 LoginActivity(launcher 已导出),避免 MainActivity 未导出导致快捷方式闪退;
|
||||||
|
// LoginActivity 内部会自动处理登录态并跳转主界面
|
||||||
|
.setIntent(new Intent(context, com.xhai.iot.ui.LoginActivity.class)
|
||||||
|
.setAction(Intent.ACTION_MAIN)
|
||||||
|
.addCategory(Intent.CATEGORY_LAUNCHER))
|
||||||
|
.build();
|
||||||
|
sm.requestPinShortcut(info, null);
|
||||||
|
Log.i(TAG, "已请求固定 MumIot 到主屏幕");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "请求固定快捷方式失败: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
Android/app/src/main/java/com/xhai/iot/util/SessionManager.java
Normal file
146
Android/app/src/main/java/com/xhai/iot/util/SessionManager.java
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package com.xhai.iot.util;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
|
import com.xhai.iot.data.ApiConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话管理:保存 token / 用户信息
|
||||||
|
*/
|
||||||
|
public class SessionManager {
|
||||||
|
|
||||||
|
private static SessionManager instance;
|
||||||
|
private final SharedPreferences prefs;
|
||||||
|
private final Context appContext;
|
||||||
|
|
||||||
|
private SessionManager(Context context) {
|
||||||
|
appContext = context.getApplicationContext();
|
||||||
|
prefs = appContext
|
||||||
|
.getSharedPreferences(ApiConfig.PREF_NAME, Context.MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static synchronized SessionManager getInstance(Context context) {
|
||||||
|
if (instance == null) {
|
||||||
|
if (context == null) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"SessionManager 未初始化,首次调用必须传 Context");
|
||||||
|
}
|
||||||
|
instance = new SessionManager(context);
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用级 Context(供网络拦截器等无 Context 组件使用) */
|
||||||
|
public Context appContext() {
|
||||||
|
return appContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveLogin(String token, String refreshToken, long memberId,
|
||||||
|
String username, String nickname) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(ApiConfig.KEY_TOKEN, token)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||||
|
.putLong(ApiConfig.KEY_MEMBER_ID, memberId)
|
||||||
|
.putString(ApiConfig.KEY_USERNAME, username)
|
||||||
|
.putString(ApiConfig.KEY_NICKNAME, nickname)
|
||||||
|
.putBoolean(KEY_EXPIRED_HANDLED, false)
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 静默续期后只更新双 token(不动用户信息/家庭选择)
|
||||||
|
*/
|
||||||
|
public void updateTokens(String token, String refreshToken) {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(ApiConfig.KEY_TOKEN, token)
|
||||||
|
.putString(KEY_REFRESH_TOKEN, refreshToken)
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRefreshToken() {
|
||||||
|
return prefs.getString(KEY_REFRESH_TOKEN, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getToken() {
|
||||||
|
return prefs.getString(ApiConfig.KEY_TOKEN, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回 "Bearer xxx" 格式的 Authorization 头 */
|
||||||
|
public String getBearerToken() {
|
||||||
|
String token = getToken();
|
||||||
|
if (token == null) return null;
|
||||||
|
return "Bearer " + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getMemberId() {
|
||||||
|
return prefs.getLong(ApiConfig.KEY_MEMBER_ID, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return prefs.getString(ApiConfig.KEY_USERNAME, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNickname() {
|
||||||
|
return prefs.getString(ApiConfig.KEY_NICKNAME, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isLoggedIn() {
|
||||||
|
return getToken() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTuyaBound(boolean bound) {
|
||||||
|
prefs.edit().putBoolean(ApiConfig.KEY_TUYA_BOUND, bound).apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTuyaBound() {
|
||||||
|
return prefs.getBoolean(ApiConfig.KEY_TUYA_BOUND, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前选中的家庭ID(0=未选择) */
|
||||||
|
public long getCurrentHomeId() {
|
||||||
|
return prefs.getLong("current_home_id", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCurrentHomeId(long homeId, String homeName) {
|
||||||
|
prefs.edit().putLong("current_home_id", homeId)
|
||||||
|
.putString("current_home_name", homeName)
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCurrentHomeName() {
|
||||||
|
return prefs.getString("current_home_name", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void logout() {
|
||||||
|
prefs.edit().clear().apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== token 过期统一处理 ====================
|
||||||
|
|
||||||
|
/** 401 已处理标志(防止并发多个 401 重复弹窗/跳转) */
|
||||||
|
private static final String KEY_EXPIRED_HANDLED = "expired_handled";
|
||||||
|
|
||||||
|
/** 刷新令牌存储键 */
|
||||||
|
private static final String KEY_REFRESH_TOKEN = "refresh_token";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* token 过期统一处理:清除登录态(保留家庭选择与涂鸦绑定标记,
|
||||||
|
* 重新登录后可自动恢复),只执行一次。
|
||||||
|
*
|
||||||
|
* @return true=本次执行了清理(调用方应提示并跳转登录页)
|
||||||
|
*/
|
||||||
|
public boolean handleTokenExpired() {
|
||||||
|
if (prefs.getBoolean(KEY_EXPIRED_HANDLED, false)) {
|
||||||
|
return false; // 并发的其他 401 已处理过
|
||||||
|
}
|
||||||
|
prefs.edit()
|
||||||
|
.putBoolean(KEY_EXPIRED_HANDLED, true)
|
||||||
|
.remove(ApiConfig.KEY_TOKEN)
|
||||||
|
.remove(ApiConfig.KEY_MEMBER_ID)
|
||||||
|
.remove(ApiConfig.KEY_USERNAME)
|
||||||
|
.remove(ApiConfig.KEY_NICKNAME)
|
||||||
|
.apply();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
164
Android/app/src/main/java/com/xhai/iot/util/ThemeManager.java
Normal file
164
Android/app/src/main/java/com/xhai/iot/util/ThemeManager.java
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
package com.xhai.iot.util;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.graphics.Color;
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate;
|
||||||
|
|
||||||
|
import com.xhai.iot.R;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主题管理器(对应设计稿 AppearanceSheet / ThemePicker)
|
||||||
|
* - 主题色:8 种预设 + 自定义色(默认石墨 #6B7280)
|
||||||
|
* - 显示模式:浅色 / 深色 / 跟随系统(默认跟随系统)
|
||||||
|
* 持久化到 SharedPreferences,各页面通过 accent()/accentFg() 动态取色。
|
||||||
|
*/
|
||||||
|
public final class ThemeManager {
|
||||||
|
|
||||||
|
/** 主题色预设 */
|
||||||
|
public static class Accent {
|
||||||
|
public final String name;
|
||||||
|
public final int value;
|
||||||
|
public final int fg;
|
||||||
|
|
||||||
|
public Accent(String name, String value, String fg) {
|
||||||
|
this.name = name;
|
||||||
|
this.value = Color.parseColor(value);
|
||||||
|
this.fg = Color.parseColor(fg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 8 种预设(与设计稿 ACCENT_PRESETS 一致,索引 7 = 默认石墨) */
|
||||||
|
public static final Accent[] PRESETS = {
|
||||||
|
new Accent("琥珀", "#F59E0B", "#18181A"),
|
||||||
|
new Accent("珊瑚", "#EF4444", "#FFFFFF"),
|
||||||
|
new Accent("玫瑰", "#EC4899", "#FFFFFF"),
|
||||||
|
new Accent("紫晶", "#8B5CF6", "#FFFFFF"),
|
||||||
|
new Accent("靛蓝", "#3B82F6", "#FFFFFF"),
|
||||||
|
new Accent("青碧", "#06B6D4", "#FFFFFF"),
|
||||||
|
new Accent("翠绿", "#22C55E", "#FFFFFF"),
|
||||||
|
new Accent("石墨", "#6B7280", "#FFFFFF"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 显示模式 */
|
||||||
|
public static final String MODE_LIGHT = "light";
|
||||||
|
public static final String MODE_DARK = "dark";
|
||||||
|
public static final String MODE_SYSTEM = "system";
|
||||||
|
|
||||||
|
private static final String PREFS = "xhai_theme";
|
||||||
|
private static final String KEY_ACCENT = "accent_value";
|
||||||
|
private static final String KEY_ACCENT_NAME = "accent_name";
|
||||||
|
private static final String KEY_MODE = "theme_mode";
|
||||||
|
|
||||||
|
private static volatile ThemeManager sInstance;
|
||||||
|
private final SharedPreferences prefs;
|
||||||
|
|
||||||
|
private ThemeManager(Context context) {
|
||||||
|
prefs = context.getApplicationContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ThemeManager getInstance(Context context) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
synchronized (ThemeManager.class) {
|
||||||
|
if (sInstance == null) {
|
||||||
|
sInstance = new ThemeManager(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前主题色(默认靛蓝 #3B82F6) */
|
||||||
|
public Accent accent() {
|
||||||
|
String value = prefs.getString(KEY_ACCENT, "#3B82F6");
|
||||||
|
try {
|
||||||
|
String name = prefs.getString(KEY_ACCENT_NAME, "靛蓝");
|
||||||
|
// 命中预设:沿用预设的前景色(如琥珀用深字,保证可读)
|
||||||
|
for (Accent p : PRESETS) {
|
||||||
|
if (p.name.equals(name) && p.value == Color.parseColor(value)) {
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 自定义色:按亮度自动选择前景色(浅底深字 / 深底白字)
|
||||||
|
int v = Color.parseColor(value);
|
||||||
|
double lum = (0.299 * Color.red(v) + 0.587 * Color.green(v)
|
||||||
|
+ 0.114 * Color.blue(v)) / 255.0;
|
||||||
|
return new Accent(name, value, lum > 0.6 ? "#18181A" : "#FFFFFF");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return PRESETS[4];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置主题色(预设或自定义,hex 如 #6B7280) */
|
||||||
|
public void setAccent(String name, String hexValue) {
|
||||||
|
try {
|
||||||
|
Color.parseColor(hexValue);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prefs.edit().putString(KEY_ACCENT_NAME, name)
|
||||||
|
.putString(KEY_ACCENT, hexValue)
|
||||||
|
.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前显示模式(默认跟随系统) */
|
||||||
|
public String mode() {
|
||||||
|
return prefs.getString(KEY_MODE, MODE_SYSTEM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置显示模式并立即生效 */
|
||||||
|
public void setMode(String mode) {
|
||||||
|
prefs.edit().putString(KEY_MODE, mode).apply();
|
||||||
|
applyNightMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用显示模式到全局 */
|
||||||
|
public void applyNightMode() {
|
||||||
|
String m = mode();
|
||||||
|
if (MODE_DARK.equals(m)) {
|
||||||
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
|
AppCompatDelegate.MODE_NIGHT_YES);
|
||||||
|
} else if (MODE_LIGHT.equals(m)) {
|
||||||
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
|
AppCompatDelegate.MODE_NIGHT_NO);
|
||||||
|
} else {
|
||||||
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
|
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主色带透明度的浅色背景(如 rgba(107,114,128,0.12)),用于图标底色 */
|
||||||
|
public int accentTintBg() {
|
||||||
|
int accent = accent().value;
|
||||||
|
int r = Color.red(accent), g = Color.green(accent), b = Color.blue(accent);
|
||||||
|
int a = Math.round(255 * 0.12f);
|
||||||
|
return Color.argb(a, r, g, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 主色选中态文字色(用于底色上) */
|
||||||
|
public int accentFgOnBg() {
|
||||||
|
return accent().fg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态栏/导航栏适配页面背景:
|
||||||
|
* 颜色跟随浅色/深色模式,图标亮度自动反转(深色模式用浅色图标)。
|
||||||
|
* 在 Activity setContentView 后调用。
|
||||||
|
*/
|
||||||
|
public void applySystemBars(androidx.appcompat.app.AppCompatActivity activity) {
|
||||||
|
if (activity == null) return;
|
||||||
|
android.view.Window window = activity.getWindow();
|
||||||
|
int bg = activity.getColor(R.color.c_bg);
|
||||||
|
window.setStatusBarColor(bg);
|
||||||
|
window.setNavigationBarColor(bg);
|
||||||
|
int uiMode = activity.getResources().getConfiguration().uiMode
|
||||||
|
& android.content.res.Configuration.UI_MODE_NIGHT_MASK;
|
||||||
|
boolean night = uiMode == android.content.res.Configuration.UI_MODE_NIGHT_YES;
|
||||||
|
androidx.core.view.WindowCompat.getInsetsController(window, window.getDecorView())
|
||||||
|
.setAppearanceLightStatusBars(!night);
|
||||||
|
androidx.core.view.WindowCompat.getInsetsController(window, window.getDecorView())
|
||||||
|
.setAppearanceLightNavigationBars(!night);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
Android/app/src/main/java/com/xhai/iot/util/TuyaAutoRelogin.java
Normal file
106
Android/app/src/main/java/com/xhai/iot/util/TuyaAutoRelogin.java
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
package com.xhai.iot.util;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import com.thingclips.smart.android.user.api.ILoginCallback;
|
||||||
|
import com.thingclips.smart.android.user.bean.User;
|
||||||
|
import com.thingclips.smart.home.sdk.ThingHomeSdk;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦自动续登:涂鸦会话过期(refreshToken 失效)时,
|
||||||
|
* 用本地加密保存的凭据自动重新登录涂鸦 SDK,实现"绑定一次永久有效"。
|
||||||
|
*/
|
||||||
|
public class TuyaAutoRelogin {
|
||||||
|
|
||||||
|
private static final String TAG = "TuyaAutoRelogin";
|
||||||
|
private static final String COUNTRY_CODE = "86";
|
||||||
|
|
||||||
|
public interface Callback {
|
||||||
|
void onSuccess(User user);
|
||||||
|
|
||||||
|
void onError(String message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否具备自动续登条件(本地有保存的凭据) */
|
||||||
|
public static boolean canAutoRelogin(Context context) {
|
||||||
|
return new TuyaCredentialStore(context).has();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用保存的凭据自动重新登录涂鸦(先手机号,失败再邮箱) */
|
||||||
|
public static void relogin(Context context, Callback callback) {
|
||||||
|
TuyaCredentialStore store = new TuyaCredentialStore(context);
|
||||||
|
String account = store.getAccount();
|
||||||
|
String password = store.getPassword();
|
||||||
|
if (account == null || password == null) {
|
||||||
|
callback.onError("未保存涂鸦凭据,请手动绑定");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Log.i(TAG, "涂鸦会话过期,自动续登: " + account);
|
||||||
|
loginWithPhone(account, password, new Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(User user) {
|
||||||
|
Log.i(TAG, "自动续登成功");
|
||||||
|
callback.onSuccess(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String message) {
|
||||||
|
// 手机号失败,尝试邮箱
|
||||||
|
loginWithEmail(account, password, new Callback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(User user) {
|
||||||
|
Log.i(TAG, "自动续登成功(邮箱)");
|
||||||
|
callback.onSuccess(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String msg2) {
|
||||||
|
Log.e(TAG, "自动续登失败: " + msg2);
|
||||||
|
callback.onError(msg2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void loginWithPhone(String account, String password, Callback callback) {
|
||||||
|
try {
|
||||||
|
ThingHomeSdk.getUserInstance().loginWithPhonePassword(
|
||||||
|
COUNTRY_CODE, account, password, new ILoginCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(User user) {
|
||||||
|
callback.onSuccess(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
callback.onError(error != null ? error : "手机号登录失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
callback.onError(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void loginWithEmail(String account, String password, Callback callback) {
|
||||||
|
try {
|
||||||
|
ThingHomeSdk.getUserInstance().loginWithEmail(
|
||||||
|
COUNTRY_CODE, account, password, new ILoginCallback() {
|
||||||
|
@Override
|
||||||
|
public void onSuccess(User user) {
|
||||||
|
callback.onSuccess(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(String code, String error) {
|
||||||
|
callback.onError(error != null ? error : "邮箱登录失败");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
callback.onError(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
package com.xhai.iot.util;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.util.Base64;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.IvParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 涂鸦账号凭据加密存储。
|
||||||
|
* 首次绑定涂鸦时保存账号/密码(AES 加密),用于涂鸦会话过期时自动重新登录,
|
||||||
|
* 实现绑定一次永久有效的体验。
|
||||||
|
*/
|
||||||
|
public class TuyaCredentialStore {
|
||||||
|
|
||||||
|
private static final String TAG = "TuyaCredentialStore";
|
||||||
|
private static final String PREFS = "tuya_credential";
|
||||||
|
private static final String KEY_ACCOUNT = "account";
|
||||||
|
private static final String KEY_PASSWORD = "password";
|
||||||
|
|
||||||
|
/** 加密密钥种子(应用级,混淆后存放;升级可用 Android Keystore 增强) */
|
||||||
|
private static final byte[] SECRET = "MumIot@2026#Tuya".getBytes(StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
private final SharedPreferences prefs;
|
||||||
|
|
||||||
|
public TuyaCredentialStore(Context context) {
|
||||||
|
prefs = context.getApplicationContext()
|
||||||
|
.getSharedPreferences(PREFS, Context.MODE_PRIVATE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存涂鸦凭据(AES 加密) */
|
||||||
|
public void save(String account, String password) {
|
||||||
|
try {
|
||||||
|
prefs.edit()
|
||||||
|
.putString(KEY_ACCOUNT, encrypt(account))
|
||||||
|
.putString(KEY_PASSWORD, encrypt(password))
|
||||||
|
.apply();
|
||||||
|
Log.i(TAG, "涂鸦凭据已保存");
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "保存涂鸦凭据失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取涂鸦账号(未保存返回 null) */
|
||||||
|
public String getAccount() {
|
||||||
|
return decrypt(prefs.getString(KEY_ACCOUNT, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取涂鸦密码(未保存返回 null) */
|
||||||
|
public String getPassword() {
|
||||||
|
return decrypt(prefs.getString(KEY_PASSWORD, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否已保存凭据 */
|
||||||
|
public boolean has() {
|
||||||
|
return prefs.getString(KEY_ACCOUNT, null) != null
|
||||||
|
&& prefs.getString(KEY_PASSWORD, null) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除凭据(解绑时调用) */
|
||||||
|
public void clear() {
|
||||||
|
prefs.edit().clear().apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** AES/CBC/PKCS5Padding 加密(随机 IV,前置于密文) */
|
||||||
|
private String encrypt(String plain) throws Exception {
|
||||||
|
byte[] iv = new byte[16];
|
||||||
|
new SecureRandom().nextBytes(iv);
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(SECRET, "AES"),
|
||||||
|
new IvParameterSpec(iv));
|
||||||
|
byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
|
||||||
|
byte[] out = new byte[iv.length + enc.length];
|
||||||
|
System.arraycopy(iv, 0, out, 0, iv.length);
|
||||||
|
System.arraycopy(enc, 0, out, iv.length, enc.length);
|
||||||
|
return Base64.encodeToString(out, Base64.NO_WRAP);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解密 */
|
||||||
|
private String decrypt(String stored) {
|
||||||
|
if (stored == null || stored.isEmpty()) return null;
|
||||||
|
try {
|
||||||
|
byte[] all = Base64.decode(stored, Base64.NO_WRAP);
|
||||||
|
byte[] iv = new byte[16];
|
||||||
|
byte[] enc = new byte[all.length - 16];
|
||||||
|
System.arraycopy(all, 0, iv, 0, 16);
|
||||||
|
System.arraycopy(all, 16, enc, 0, enc.length);
|
||||||
|
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(SECRET, "AES"),
|
||||||
|
new IvParameterSpec(iv));
|
||||||
|
return new String(cipher.doFinal(enc), StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "解密涂鸦凭据失败", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
Android/app/src/main/res/color/nav_item_color.xml
Normal file
5
Android/app/src/main/res/color/nav_item_color.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:color="#3B82F6" android:state_checked="true" />
|
||||||
|
<item android:color="#909399" />
|
||||||
|
</selector>
|
||||||
7
Android/app/src/main/res/drawable/bg_add_card.xml
Normal file
7
Android/app/src/main/res/drawable/bg_add_card.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/c_card" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/c_border" />
|
||||||
|
</shape>
|
||||||
5
Android/app/src/main/res/drawable/bg_add_circle.xml
Normal file
5
Android/app/src/main/res/drawable/bg_add_circle.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="@color/c_muted" />
|
||||||
|
</shape>
|
||||||
6
Android/app/src/main/res/drawable/bg_add_muted.xml
Normal file
6
Android/app/src/main/res/drawable/bg_add_muted.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/c_muted" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
7
Android/app/src/main/res/drawable/bg_add_outline.xml
Normal file
7
Android/app/src/main/res/drawable/bg_add_outline.xml
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/c_card" />
|
||||||
|
<corners android:radius="24dp" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/c_border" />
|
||||||
|
</shape>
|
||||||
6
Android/app/src/main/res/drawable/bg_add_primary.xml
Normal file
6
Android/app/src/main/res/drawable/bg_add_primary.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/c_primary" />
|
||||||
|
<corners android:radius="24dp" />
|
||||||
|
</shape>
|
||||||
10
Android/app/src/main/res/drawable/bg_ai_button.xml
Normal file
10
Android/app/src/main/res/drawable/bg_ai_button.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<!-- AI 按钮:主色到紫色 135 度渐变(设计稿 primary 至 a855f7) -->
|
||||||
|
<gradient
|
||||||
|
android:angle="315"
|
||||||
|
android:startColor="#3B82F6"
|
||||||
|
android:endColor="#A855F7" />
|
||||||
|
<corners android:radius="22dp" />
|
||||||
|
</shape>
|
||||||
6
Android/app/src/main/res/drawable/bg_ai_icon.xml
Normal file
6
Android/app/src/main/res/drawable/bg_ai_icon.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#F0F4FF" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
10
Android/app/src/main/res/drawable/bg_ai_logo.xml
Normal file
10
Android/app/src/main/res/drawable/bg_ai_logo.xml
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<gradient
|
||||||
|
android:type="linear"
|
||||||
|
android:angle="45"
|
||||||
|
android:startColor="#5B8FF9"
|
||||||
|
android:centerColor="#9C5BF9"
|
||||||
|
android:endColor="#FF5BA9" />
|
||||||
|
</shape>
|
||||||
6
Android/app/src/main/res/drawable/bg_blue_btn.xml
Normal file
6
Android/app/src/main/res/drawable/bg_blue_btn.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#1989FA" />
|
||||||
|
<corners android:radius="24dp" />
|
||||||
|
</shape>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user