Compare commits

..
11 Commits
Author SHA1 Message Date
KremeCN 1238b82b4a feat(about): add link buttons and logo rounding (#16)
## Summary
- add rounded corners to the About page logo
- add GitHub profile links for KremeCN, Moler, and alZerNest
- add personal website links for KremeCN and Moler
- localize link content descriptions in English and Simplified Chinese

## Verification
- debug build completed successfully
- APK installed on emulator

Reviewed-on: #16
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-28 11:19:43 +08:00
KremeCN 0144ea992c docs: reorganize project documentation (#15)
## Summary
- add a root README with project overview, setup, usage, architecture, security, and contribution guidance
- replace stale planning and requirement notes with current 1.0 project overview
- update architecture and hardware validation docs to match the implemented app
- remove obsolete UI branch notes and the old Java/XML AsyncTask example
- remove the superpowers documentation and move vendor ABSDK references into docs/vendor
- preserve vendor Markdown and XLS content byte-for-byte

## Verification
- Markdown relative links checked
- vendor files verified as 100% Git renames
- git diff --check passed
- no application tests run; documentation-only change

Reviewed-on: #15
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-28 10:57:47 +08:00
KremeCN 32c7d4f8cc feat(app): add About screen and mark 1.0 release (#14)
## Summary
- add an About entry to the account sheet and a full-screen About page
- show app, package, platform, product, and Flagship team information
- localize the new interface in English and Simplified Chinese
- set the release version to 1.0.0 with versionCode 1

## Verification
- debug APK build completed successfully after the About screen crash fix
- git diff --check passed

Reviewed-on: #14
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-28 09:47:57 +08:00
KremeCN ad0dd2cd5b feat(infrared): expand remote library and creation flow (#13)
## Summary

- replace the crowded add-remote dialog with a full-screen three-step flow
- make the first step explicitly choose between adding from the infrared library and creating through learning
- add a full-screen remote editor with read-only library configuration, editable custom layouts, and delete confirmation
- append newly created remotes to the bottom of the management list and right side of the remote switcher
- remove the fan emoji from the device type label
- add a curated CC0 Flipper-IRDB preset subset with real NEC/NECext protocol codes
- add projector, audio/receiver, set-top box, streaming device, and lighting categories
- require a test power command and explicit device-response confirmation before creating library remotes
- include source attribution and a repeatable converter for parsed NEC/NECext signals

## Compatibility scope

- imported formats: parsed NEC and NECext only
- excluded: raw pulse data and other protocols because the ABox irCode encoding contract is not publicly specified for them
- existing devices, remotes, and scenes are not migrated or renamed

## Verification

- ./gradlew.bat :android:app:assembleDebug --no-daemon
- converter reproduced

Reviewed-on: #13
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-28 09:23:06 +08:00
KremeCN 387ecacef5 feat(socket): update smart socket defaults (#12)
## Summary

- change the default socket device name from Zigbee插座控制器 to 智能插座
- update new default scenes, in-memory defaults, and Local Experience simulation data to use the new gateway name
- remove the duplicated Direct control section from the smart socket detail screen, keeping the main power control
- remove the unused direct-control strings

## Data behavior

- existing configured devices are not renamed
- existing persisted scenes are not migrated
- only newly created default content uses 智能插座

## Verification

- ./gradlew.bat :android:app:assembleDebug --no-daemon
- installed on mulator-5554
- confirmed the Local Experience device card shows 智能插座
- confirmed the socket detail screen has no Direct control section or duplicate ON/OFF buttons

Automated tests were not run.

Reviewed-on: #12
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-28 07:07:21 +08:00
KremeCN 4d4dd2893e feat(scene): complete automation runtime (#11)
## Summary

- persist scenes and trigger state in Room with a database migration and one-time default seeding
- execute enabled automatic scenes from real device readings with edge-triggered thresholds, conditions, hysteresis, and time occurrence deduplication
- schedule foreground polling and WorkManager background evaluation, including reconciliation after scene changes
- allow automatic commands to be run manually for testing while bypassing triggers and conditions
- send real system notifications with runtime permission handling and accurate failure reporting
- move Other Options & Appearance to the bottom of the command editor

## Verification

- ./gradlew.bat :android:app:assembleDebug --no-daemon
- installed and exercised on mulator-5554
- verified manual automatic-command execution and Activity records
- verified notification permission flow, posted notification, and successful Activity records

Automated tests were not run per the requested iteration workflow.

Reviewed-on: #11
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-28 06:39:31 +08:00
KremeCN 86b7e5849b feat(ui): complete ABox app experience redesign (#10)
## Summary

- rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience
- redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content
- add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative
- add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes
- persist real activity events for device commands, infrared actions, and scene execution
- stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option
- update launcher icons and bilingual application name

## Verification

- `:android:app:assembleDebug`
- installed the debug APK on `emulator-5554`
- manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions

## Notes

- no automated test suite was run during the visual iteration cycle
- `docs/superpowers/**` and local reference repositories are excluded from this PR

Reviewed-on: #10
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-27 23:25:07 +08:00
1570a95f30 feat(scene): add scene editor, executor and fakes (phase 1) (#5)
## Summary

Adds scene integration phase 1: domain model, fakes, UI, navigation, and tests.

## Changes

- **domain**: Add `Scene`, `SceneAction` (SocketPower/InfraredKey), `SceneRepository`, `SceneExecutor`, and execution result models.
- **testing/fakes**: Add `FakeSceneRepository` (isolated by `ProviderProfileId`, seeded defaults for the local profile) and `FakeSceneExecutor` (sequential execution, continue-on-failure).
- **android/app**: Add scene UI module - list screen, editor screen, execution progress screen - with Hilt-bound ViewModels.
- **navigation**: Wire scene routes into `ABoxManagerApp` NavHost.
- **strings**: Add `scene_*` entries for en and zh-rCN.
- **tests**: Cover `FakeSceneRepository` CRUD/isolation and `FakeSceneExecutor` ordering/continue-on-failure.

## Test plan

- [x] `./gradlew :testing:fakes:test` passes.
- [x] `FakeSceneRepository` isolates scenes by provider profile.
- [x] `FakeSceneExecutor` runs actions in order and continues on failure.

## Notes

Phase 1 ships fakes only; real repository/executor backed by the abox gateway will land in a follow-up.

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Co-authored-by: mxhsnd <2049295946@qq.com>
Reviewed-on: #5
Co-authored-by: tzh <3091898014@qq.com>
2026-08-25 20:20:18 +08:00
AurumandKremeCN 30d6d39597 feat: add socket control and temperature humidity modules (#7)
## Summary
- **Smart Socket Module**:
  - Implemented `SocketUiState` and `SocketViewModel` with support for querying power state, toggle control, duplicate click debouncing, and state confirmation timestamping.
  - Implemented Material 3 responsive `SocketScreen` with power indicator badge, toggle action, explicit on/off buttons, offline banner, and error handling.
- **Temperature & Humidity Module**:
  - Implemented `TemperatureHumidityUiState` and `TemperatureHumidityViewModel` for read-only environmental telemetry.
  - Implemented `TemperatureHumidityScreen` featuring distinct temperature and humidity cards, read-only notice, no-data placeholder, and pull-to-refresh actions.
- **Common & Infrastructure**:
  - Added `UiText` and `DomainErrorMapper` for localizing domain errors (`DomainError`).
  - Added navigation graph extensions (`deviceDetailGraph`) in `DeviceDetailNavigation.kt` and wired into `ABoxManagerApp.kt`.
  - Added `ReleaseModule` for Hilt dependency injection in release builds.
  - Added full English and Simplified Chinese resource translations in `values/strings.xml` and `values-zh-rCN/strings.xml`.
- **Testing**:
  - Added `SocketViewModelTest` and `TemperatureHumidityViewModelTest` covering normal operations, offline states, error mapping, and refresh scenarios.

## Test Plan
- [x] Run `./gradlew test` (all modules passed)
- [x] Run `./gradlew :android:app:testDebugUnitTest` (passed)
- [x] Run `./gradlew assembleDebug` (build successful)

?? Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Reviewed-on: #7
Co-authored-by: Aurum <aurum_197@outlook.com>
2026-08-25 15:41:34 +08:00
MolerandKremeCN 0c783ca2f4 feat(infrared): add infrared control, learning, and virtual remote module (#9)
### 概述 / Summary
完整实现红外控制与学习模块(`:android:feature:infrared`),支持红外设备按键学习、实体发射、Room 本地持久化,以及空调 (AC) / 电视 (TV) 虚拟遥控器面板。

### 核心变更 / Key Changes
1. **领域模型与用例 (`:domain`)**:
   - 实现按键学习流程:`LearnInfraredKey`、`ManageInfraredKey`、`InfraredKeyFactory`;
   - 增加虚拟遥控器模型与用例:`CreateVirtualRemote`、`LearnVirtualRemoteKey`、`SendVirtualRemoteAction`、`AcCodeGenerator` 与预置码库 `PresetCodeLibrary`。
2. **数据持久化与仓储 (`:android:feature:infrared`)**:
   - 引入 Room 数据库 `InfraredDatabase` 及 DAO,提供 `RoomInfraredKeyRepository` 与 `RoomVirtualRemoteRepository`;
   - 支持多配置隔离(`ProviderProfileId`)、按键状态管理(`LEARNING` / `READY` / `FAILED`)。
3. **UI 与 ViewModel (`:android:feature:infrared`)**:
   - `InfraredControlScreen`:红外学习与按键调试列表;
   - `RemotePanelScreen` / `AcRemotePanel` / `TvRemotePanel`:空调/电视专属可视化遥控面板;
   - `InfraredHubScreen`:红外中枢与多遥控器管理入口;
   - 在认证后导航栈中将 `DeviceListScreen` 的红外入口无缝对接到 `InfraredHubRoute`。
4. **测试与文档**:
   - 补齐用例、Fakes、Repository、ViewModel 以及 Compose UI 自动化测试;
   - 补充红外控制设计规范与真机硬件验收清单。

Reviewed-on: #9
Co-authored-by: mxhsnd <2049295946@qq.com>
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-25 15:00:06 +08:00
alZerNestandKremeCN 249721be33 新增设备列表 Compose 页面 (#8)
# WIP: Add device list Compose screen

## Summary

This MR adds only the reusable Kotlin Jetpack Compose device list screen for my assigned scope.

The screen displays the three ABox device categories:

- Smart socket
- Temperature/humidity sensor
- Infrared controller

This MR intentionally does not modify the app entry point, login flow, app shell routing, or any device detail/control screen. Those parts are handled by other feature branches.

## Changes

- Add `DeviceListScreen`.
- Expose click callbacks for each device category:
  - `onSocketClick`
  - `onTemperatureHumidityClick`
  - `onInfraredClick`
- Update the device-list change log document.
- Remove the earlier out-of-scope temporary device control screens from this branch.

## Scope

Included:

- Device list Compose UI component
- Device category entry UI
- Callback API for future route integration

Not included:

- App entry point changes
- Login flow changes
- Device control navigation graph
- Smart socket control screen
- Temperature/humidity status screen
- Infrared controller screen
- Direct ABSDK calls
- Java Activity classes
- XML layout files

## Integration Notes

The screen is designed to be hosted by the app shell or routing owner later:

```kotlin
DeviceListScreen(
    onSocketClick = { /* route to socket module */ },
    onTemperatureHumidityClick = { /* route to temperature/humidity module */ },
    onInfraredClick = { /* route to infrared controller module */ },
)
```

The current branch keeps these callbacks external so other feature owners can connect their modules without this MR taking ownership of routing.

## Verification

- Built and ran `android.app` locally from Android Studio.
- `:android:app:assembleDebug` completed successfully after trimming this branch to the device-list component scope.

## Notes

`settings.gradle.kts` is intentionally not part of this MR. Local Gradle/JDK/toolchain setup is handled on the developer machine and should not modify the team-provided project configuration.

---

# WIP: 新增设备列表 Compose 页面

## 概要

本 MR 只新增我当前分工负责的 Kotlin Jetpack Compose 设备列表页面组件。

设备列表展示三类 ABox 设备入口:

- 智能插座
- 温湿度传感器
- 红外控制器

本 MR 故意不修改 App 入口、登录流程、应用壳路由,也不实现任何设备详情/控制页面。这些部分由其他功能分支负责。

## 修改内容

- 新增 `DeviceListScreen`。
- 为三类设备入口暴露点击回调:
  - `onSocketClick`
  - `onTemperatureHumidityClick`
  - `onInfraredClick`
- 更新设备列表变更说明文档。
- 从本分支中移除早期临时加入、但不属于当前分工范围的设备控制页面。

## 本 MR 包含

- 设备列表 Compose UI 组件
- 设备类别入口 UI
- 后续路由接入所需的 callback API

## 本 MR 不包含

- App 入口修改
- 登录流程修改
- 设备控制 navigation graph
- 智能插座控制页面
- 温湿度状态页面
- 红外控制器页面
- Compose UI 中直接调用 ABSDK
- Java Activity
- XML layout

## 对接说明

该页面后续可以由 App shell 或路由负责人挂载:

```kotlin
DeviceListScreen(
    onSocketClick = { /* 后续跳转到智能插座模块 */ },
    onTemperatureHumidityClick = { /* 后续跳转到温湿度模块 */ },
    onInfraredClick = { /* 后续跳转到红外控制器模块 */ },
)
```

当前分支只提供页面和回调 API,不负责具体跳转目标,以便其他模块负责人对接自己的功能页面。

## 验证

- 已在本地通过 Android Studio 成功 build/run `android.app`。
- 修剪为设备列表组件范围后,`:android:app:assembleDebug` 构建成功。

## 备注

`settings.gradle.kts` 不属于本 MR 范围。本地 Gradle/JDK/toolchain 配置在开发者本机处理,不修改团队提供的项目配置文件。

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Reviewed-on: #8
Co-authored-by: alZerNest <2223976172@qq.com>
2026-08-25 11:39:12 +08:00
267 changed files with 28013 additions and 3188 deletions
+133
View File
@@ -0,0 +1,133 @@
<p align="center">
<img src="android/app/src/main/res/drawable/ic_abox_logo_master.png" width="112" alt="ABox Manager logo">
</p>
<h1 align="center">ABox Manager</h1>
<p align="center">
面向 Android 手机和平板的 ABox 智能家居管理应用
</p>
## 项目介绍
ABox Manager 使用 ABSDK 连接局域网中的 ABox 网关,为智能插座、温湿度传感器和红外控制器提供统一的设备管理体验。应用同时包含不依赖账号、网络或真实硬件的本地体验模式,可用于浏览界面和验证主要操作流程。
当前正式版本为 **1.0.0**,由 **Flagship** 团队开发。
## 主要功能
- ABox 登录、自动登录、加密凭据与会话失效处理;
- 智能插座状态查询、开关控制和命令后状态确认;
- 温度与湿度读取,以及无数据、错误和离线状态展示;
- 预置红外码库下载,以及自定义遥控器按键学习;
- 遥控器布局、按钮、硬件槽位和顺序管理;
- 手动指令与基于时间、温度、湿度和设备状态的自动指令;
- 插座、红外、延迟和系统通知动作;
- 按 profile 隔离并持久化的活动记录;
- 浅色、深色、动态颜色、强调色与界面缩放;
- 英文和简体中文本地化;
- 独立且确定性的本地体验环境。
## 技术栈
- Kotlin 2.4
- Jetpack Compose 与 Material
- Navigation Compose
- Hilt
- Coroutines 与 StateFlow
- Room
- DataStore 与 Android Keystore
- WorkManager
- 多模块 Gradle 工程
核心模块:
```text
domain/ 领域模型、端口和用例
absdk-bridge/ ABSDK 0630 防腐层
runtime/simulation/ 本地体验模拟运行时
testing/fakes/ 测试专用 Fake
android/core/ 设计系统、偏好、设备目录和会话
android/feature/infrared/ 红外遥控器功能
android/feature/automation/ 情景、自动化和活动记录
android/app/ 应用组合根、导航和设备界面
```
完整依赖方向和数据边界见 [架构文档](docs/architecture.md)。
## 开发环境
- JDK 17
- Android SDK 37
- Android Studio,或能够运行 Gradle Wrapper 的 Android 开发环境
- Windows、macOS 或 Linux
项目最低支持 Android 8.0(API 26),目标 SDK 为 36。
克隆仓库后,确保 Android SDK 路径已由 Android Studio 写入本机 `local.properties`,然后构建调试 APK:
```powershell
.\gradlew.bat :android:app:assembleDebug
```
macOS 或 Linux:
```bash
./gradlew :android:app:assembleDebug
```
APK 输出位置:
```text
android/app/build/outputs/apk/debug/app-debug.apk
```
连接模拟器或 Android 设备后可以安装:
```bash
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
```
## 使用方式
### ABox
设备需要与 ABox 网关处于可用的局域网环境,并使用有效账号登录。ABSDK 通过设备名称匹配网关设备;App 中的设备名称必须与网关实际名称一致。
首次初始化 ABox profile 时,App 会按设备类型补充以下默认记录:
- `智能插座`
- `温湿度传感器`
- `红外控制器`
已有同类型设备不会被迁移、重命名或覆盖。
### 本地体验
本地体验无需账号和网络,使用独立模拟数据。它适合浏览产品和验证界面流程,但不能作为真实 ABox、网络或硬件验收结果。
## 文档
- [项目概览](docs/project-overview.md)
- [架构说明](docs/architecture.md)
- [ABSDK 0630 已确认事实与边界](docs/protocol/absdk-0630.md)
- [设备数据能力矩阵](docs/testing/device-data-capability-matrix.md)
- [红外硬件验证](docs/testing/infrared-hardware-validation.md)
- [正式版局域网硬件验收](docs/testing/release-lan-hardware-validation.md)
- [完整文档索引](docs/README.md)
## 安全边界
厂商 ABSDK JAR 使用明文 HTTP,并依赖外部局域网环境完成域名到网关的访问。真实 ABox 功能只能在已授权、受控的局域网中使用。
不得在日志、截图、问题报告或测试记录中提交密码、Token、完整网关地址、完整硬件标识、原始红外码值或未脱敏 SDK 响应。
## 贡献流程
`main` 是受保护分支。所有修改必须从最新 `main` 创建专用分支,通过 Pull Request 合并,不得直接在 `main` 上开发或推送。详细规则见 [AGENTS.md](AGENTS.md)。
## 第三方资料
内置的部分红外预置来源于 Flipper-IRDB,并按照 CC0 1.0 提供。来源、转换范围和归属信息见 [Flipper-IRDB attribution](docs/third-party/flipper-irdb.md)。
仓库中的厂商 SDK、接口资料和第三方内容分别受其自身许可或授权条件约束;本仓库当前未声明覆盖整个项目的统一开源许可证。
@@ -133,6 +133,25 @@ class AbsdkBridge internal constructor(
}
}
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = serialized {
withActiveSession(profileId) {
when (val control = call {
client.setSocketPower(deviceName.value, powerState.toSdkStatus())
}) {
is ClientCall.Failed -> DomainResult.Failure(control.error)
is ClientCall.Completed -> if (control.response.isSuccess()) {
DomainResult.Success(Unit)
} else {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response))
}
}
}
}
override suspend fun getReading(
profileId: ProviderProfileId,
deviceName: DeviceName,
@@ -17,10 +17,12 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.reflect.KClass
class AbsdkDeviceBridgeTest {
private val profile = ProviderProfileId("abox")
private val socket = DeviceName("socket-1")
private val irDevice = DeviceName("ir-room")
@Test
fun socketControlReturnsConfirmedStatus() = runTest {
@@ -83,6 +85,69 @@ class AbsdkDeviceBridgeTest {
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
}
@Test
fun infraredSendForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-send:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredLearnForwardsDeviceAndStableKey() = runTest {
val client = ScriptedAbsdkClient()
val bridge = authenticatedBridge(client)
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, irDevice, "app_ir_power"))
assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls)
}
@Test
fun infraredErrorCodesAreMappedCorrectlyForSendAndLearn() = runTest {
val errorMappings = mapOf<String, KClass<out DomainError>>(
"20201" to DomainError.InvalidRequest::class,
"20202" to DomainError.InvalidRequest::class,
"20203" to DomainError.DeviceNotFound::class,
"20204" to DomainError.OperationFailed::class,
"20205" to DomainError.OperationFailed::class,
"20206" to DomainError.DeviceOffline::class,
)
for ((code, expectedClass) in errorMappings) {
val client = ScriptedAbsdkClient().apply {
sendResponse = ScriptedAbsdkClient.failure(code, "message for $code")
learnResponse = ScriptedAbsdkClient.failure(code, "message for $code")
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue("Expected send error class $expectedClass for code $code", sendResult is DomainResult.Failure)
assertEquals(expectedClass, (sendResult as DomainResult.Failure).error::class)
assertEquals(code, sendResult.error.externalCode)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue("Expected learn error class $expectedClass for code $code", learnResult is DomainResult.Failure)
assertEquals(expectedClass, (learnResult as DomainResult.Failure).error::class)
assertEquals(code, learnResult.error.externalCode)
}
}
@Test
fun infraredNullResponseReturnsUnknownDomainError() = runTest {
val client = ScriptedAbsdkClient().apply {
sendResponse = null
learnResponse = null
}
val bridge = authenticatedBridge(client)
val sendResult = bridge.sendKey(profile, irDevice, "key1")
assertTrue(sendResult is DomainResult.Failure)
assertTrue((sendResult as DomainResult.Failure).error is DomainError.Unknown)
val learnResult = bridge.learnKey(profile, irDevice, "key1")
assertTrue(learnResult is DomainResult.Failure)
assertTrue((learnResult as DomainResult.Failure).error is DomainError.Unknown)
}
@Test
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
@@ -48,4 +48,38 @@ class AbsdkSerializationTest {
assertEquals(1, client.maxConcurrentCalls.get())
}
}
@Test
fun concurrentInfraredAndOtherDeviceCallsDoNotOverlap() = runBlocking {
withTimeout(5_000) {
val profile = ProviderProfileId("abox")
val client = ScriptedAbsdkClient().apply {
socketStatusResponses = ArrayDeque(
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
)
}
val bridge = AbsdkBridge(client, Dispatchers.IO)
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
client.onCall = {
if (client.calls.last().startsWith("ir-learn") || client.calls.last().startsWith("temperature")) {
Thread.sleep(75)
}
}
val irCall = async {
bridge.learnKey(profile, DeviceName("ir-1"), "app_ir_power")
}
val tempCall = async {
bridge.getReading(profile, DeviceName("th-1"))
}
delay(10)
irCall.await()
tempCall.await()
assertEquals(1, client.maxConcurrentCalls.get())
}
}
}
+20 -11
View File
@@ -1,20 +1,26 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
alias(libs.plugins.compose.screenshot)
}
android {
namespace = "com.flagship.abox.manager"
compileSdk = 34
compileSdk = 37
experimentalProperties["android.experimental.enableScreenshotTest"] = true
defaultConfig {
applicationId = "com.flagship.abox.manager"
minSdk = 26
targetSdk = 34
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -33,29 +39,26 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
excludes += "/META-INF/DEPENDENCIES"
excludes += "/META-INF/LICENSE*"
excludes += "/META-INF/NOTICE*"
excludes += "/META-INF/INDEX.LIST"
}
}
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
kapt {
correctErrorTypes = true
}
@@ -63,7 +66,12 @@ kapt {
dependencies {
implementation(project(":domain"))
implementation(project(":android:core:designsystem"))
implementation(project(":android:core:preferences"))
implementation(project(":android:core:devicecatalog"))
implementation(project(":android:core:session"))
implementation(project(":android:feature:infrared"))
implementation(project(":android:feature:automation"))
implementation(libs.androidx.hilt.work)
implementation(project(":absdk-bridge"))
implementation(project(":runtime:simulation"))
debugImplementation(project(":testing:fakes"))
@@ -93,4 +101,5 @@ dependencies {
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.tooling)
debugImplementation(libs.compose.ui.test.manifest)
screenshotTestImplementation(libs.compose.screenshot.test)
}
@@ -0,0 +1,55 @@
package com.flagship.abox.manager.ui.devicecenter
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DeviceCenterScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun configuredDeviceCardRendersWithSaveableGridKey() {
val profileId = ProviderProfileId("profile-1")
val device = DeviceDescriptor(
id = DeviceId("profile-1:socket-1"),
profileId = profileId,
name = DeviceName("socket-1"),
type = DeviceType.SOCKET,
)
val card = DeviceCardUiModel(
key = DeviceCardKey(profileId, device.name),
device = device,
)
composeRule.setContent {
ABoxTheme {
DeviceCenterScreen(
state = DeviceCenterUiState(
profileId = profileId,
cards = listOf(card),
content = DeviceCenterContent.CONTENT,
),
onFilterChanged = {},
onOpenDevice = {},
onConfigureDevice = { _, _ -> },
onQuickAction = {},
)
}
}
composeRule.onNodeWithTag("device_card_profile-1:socket-1").assertIsDisplayed()
}
}
@@ -0,0 +1,54 @@
package com.flagship.abox.manager.ui.devicecontrollist
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DeviceListScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun deviceListRendersAllDeviceCategoryButtonsAndHandlesClicks() {
var socketClicks = 0
var tempClicks = 0
var infraredClicks = 0
val context = composeRule.activity
val socketTitle = context.getString(R.string.device_socket_title)
val tempTitle = context.getString(R.string.device_temperature_humidity_title)
val infraredTitle = context.getString(R.string.device_infrared_title)
val listTitle = context.getString(R.string.device_list_title)
composeRule.setContent {
ABoxTheme {
DeviceListScreen(
onSocketClick = { socketClicks++ },
onTemperatureHumidityClick = { tempClicks++ },
onInfraredClick = { infraredClicks++ },
)
}
}
composeRule.onNodeWithText(listTitle).assertIsDisplayed()
composeRule.onNodeWithText(socketTitle).assertIsDisplayed().performClick()
assertEquals(1, socketClicks)
composeRule.onNodeWithText(tempTitle).assertIsDisplayed().performClick()
assertEquals(1, tempClicks)
composeRule.onNodeWithText(infraredTitle).assertIsDisplayed().performClick()
assertEquals(1, infraredClicks)
}
}
@@ -0,0 +1,215 @@
package com.flagship.abox.manager.ui.navigation
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass
import com.flagship.abox.manager.ui.shell.DeviceSelection
import com.flagship.abox.manager.ui.shell.DetailPresentation
import com.flagship.abox.manager.ui.shell.MainTab
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AuthenticatedNavigationRoutingTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun compactDetailBackPopsDetailBeforeParentDestination() {
composeRule.setContent { ABoxTheme { RoutingFixture() } }
composeRule.onNodeWithTag("routing_select_device").performClick()
composeRule.onNodeWithTag("routing_full_screen_detail").assertExists()
composeRule.runOnUiThread {
composeRule.activity.onBackPressedDispatcher.onBackPressed()
}
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_devices_root").assertExists()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE")
}
@Test
fun reselectingTabPopsActualNavControllerToItsRoot() {
composeRule.setContent { ABoxTheme { RoutingFixture() } }
composeRule.onNodeWithTag("routing_scenes_tab").performClick()
composeRule.onNodeWithTag("routing_scene_child").performClick()
composeRule.onNodeWithTag("routing_scenes_tab").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_scenes_root").assertExists()
composeRule.onNodeWithTag("routing_scene_child_destination").assertDoesNotExist()
}
@Test
fun resizeReconcilesActualRouteAndPaneFromOneSavedSelection() {
composeRule.setContent {
ABoxTheme {
RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED)
}
}
composeRule.onNodeWithTag("routing_select_device").performClick()
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_toggle_size").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_full_screen_detail").assertExists()
composeRule.onNodeWithTag("routing_route").assertTextEquals("test/device/socket")
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.FULL_SCREEN.name)
composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_toggle_size").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
}
@Test
fun savedSelectionSurvivesStateRestorationAndProfileReplacementClearsIt() {
val restorationTester = StateRestorationTester(composeRule)
restorationTester.setContent {
ABoxTheme {
RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED)
}
}
composeRule.onNodeWithTag("routing_select_device").performClick()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
restorationTester.emulateSavedInstanceStateRestore()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
composeRule.onNodeWithTag("routing_replace_profile").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE")
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist()
}
}
@Composable
private fun RoutingFixture(
initialWindowSizeClass: ABoxWindowSizeClass = ABoxWindowSizeClass.COMPACT,
) {
var windowSizeClass by remember { mutableStateOf(initialWindowSizeClass) }
var profileId by remember { mutableStateOf(ProviderProfileId("profile")) }
Column(modifier = Modifier.fillMaxSize()) {
Button(
onClick = {
windowSizeClass = if (windowSizeClass == ABoxWindowSizeClass.COMPACT) {
ABoxWindowSizeClass.EXPANDED
} else {
ABoxWindowSizeClass.COMPACT
}
},
modifier = Modifier.testTag("routing_toggle_size"),
) { Text("Resize") }
Button(
onClick = { profileId = ProviderProfileId("replacement") },
modifier = Modifier.testTag("routing_replace_profile"),
) { Text("Replace profile") }
key(profileId.value) {
val navController = rememberNavController()
AuthenticatedNavigationStateHost(
profileId = profileId,
windowSizeClass = windowSizeClass,
navController = navController,
deviceRoute = { "test/device/${it.deviceName.value}" },
selectionForRoute = { route ->
route
?.takeIf { it.startsWith("test/device/") }
?.removePrefix("test/device/")
?.let { deviceName ->
DeviceSelection(
profileId = profileId,
deviceName = DeviceName(deviceName),
type = DeviceType.SOCKET,
)
}
},
) { snapshot, onTabClick, onSelectDevice, _ ->
Text(snapshot.currentRoute.orEmpty(), modifier = Modifier.testTag("routing_route"))
Text(
snapshot.detailPresentation?.name ?: "NONE",
modifier = Modifier.testTag("routing_presentation"),
)
Button(
onClick = { onSelectDevice(DeviceName("socket"), DeviceType.SOCKET) },
modifier = Modifier.testTag("routing_select_device"),
) { Text("Select") }
Button(
onClick = { onTabClick(MainTab.SCENES) },
modifier = Modifier.testTag("routing_scenes_tab"),
) { Text("Scenes") }
Button(
onClick = { navController.navigate("scene/child") },
modifier = Modifier.testTag("routing_scene_child"),
) { Text("Child") }
if (snapshot.detailPresentation == DetailPresentation.PANE) {
Text("Pane detail", modifier = Modifier.testTag("routing_pane_detail"))
}
NavHost(
navController = navController,
startDestination = MainTab.DEVICES.route,
) {
composable(MainTab.DEVICES.route) {
Text("Devices", modifier = Modifier.testTag("routing_devices_root"))
}
composable(MainTab.SCENES.route) {
Text("Scenes", modifier = Modifier.testTag("routing_scenes_root"))
}
composable("scene/child") {
Text("Child", modifier = Modifier.testTag("routing_scene_child_destination"))
}
composable(
route = "test/device/{deviceName}",
arguments = listOf(navArgument("deviceName") { type = NavType.StringType }),
) {
Text("Full screen detail", modifier = Modifier.testTag("routing_full_screen_detail"))
}
}
}
}
}
}
@@ -0,0 +1,126 @@
package com.flagship.abox.manager.ui.preferences
import androidx.activity.ComponentActivity
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SettingsScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun interfaceStyleOpensMenuBeforeChangingRenderer() {
val actions = mutableListOf<SettingsUiAction>()
composeRule.setContent {
ABoxTheme {
SettingsScreen(
state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true),
onAction = actions::add,
)
}
}
composeRule.onNodeWithTag("settings-style-entry").performClick()
assertTrue(actions.isEmpty())
composeRule.onNodeWithTag("settings-style-menu").assertIsDisplayed()
composeRule.onNodeWithText("Miuix").performClick()
assertEquals(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX), actions.single())
}
@Test
fun materialThemeShowsEveryMaterialOptionInReferenceOrder() {
showTheme(DesignRenderer.MATERIAL3)
assertVerticalOrder(
"settings-material-colors",
"settings-theme-mode",
"settings-color-style",
"settings-color-standard",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-liquid-glass").assertCountEquals(0)
}
@Test
fun miuixThemeShowsEveryMiuixOptionInReferenceOrder() {
showTheme(DesignRenderer.MIUIX)
assertVerticalOrder(
"settings-theme-mode",
"settings-miuix-accent",
"settings-blur",
"settings-floating-navigation",
"settings-liquid-glass",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-color-style").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-color-standard").assertCountEquals(0)
}
@Test
fun themeDetailRemainsOpenWhenRendererChangesFromHomeMenu() {
var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3))
val experienceState = SettingsExperienceState()
composeRule.setContent {
ABoxTheme(preferences = preferences) {
SettingsExperience(
state = SettingsUiState(preferences = preferences, loaded = true),
experienceState = experienceState,
onAction = { action ->
if (action is SettingsUiAction.SetRenderer) preferences = preferences.copy(renderer = action.value)
},
onExit = {},
)
}
}
composeRule.onNodeWithTag("settings-style-entry").performClick()
composeRule.onNodeWithText("Miuix").performClick()
composeRule.onNodeWithTag("settings-theme-entry").performClick()
composeRule.onNodeWithTag("settings-theme-detail").assertIsDisplayed()
composeRule.onNodeWithTag("settings-miuix-accent").assertIsDisplayed()
}
private fun showTheme(renderer: DesignRenderer) {
composeRule.setContent {
val experienceState = remember { SettingsExperienceState("THEME") }
ABoxTheme {
SettingsExperience(
state = SettingsUiState(preferences = UiPreferences(renderer = renderer), loaded = true),
experienceState = experienceState,
onAction = {},
onExit = {},
)
}
}
}
private fun assertVerticalOrder(vararg tags: String) {
val tops = tags.map { tag -> composeRule.onNodeWithTag(tag).getUnclippedBoundsInRoot().top }
assertTrue("Expected controls in reference-image order: ${tags.toList()}", tops.zipWithNext().all { it.first < it.second })
}
}
@@ -0,0 +1,40 @@
package com.flagship.abox.manager.ui.scene
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SceneChoiceFlowTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun choicesWrapOntoAnotherLineAtNarrowEnglishWidth() {
composeRule.setContent {
ABoxTheme {
SceneChoiceFlow(modifier = Modifier.width(240.dp)) {
listOf("Socket", "Remote", "Delay", "Notify").forEach { label ->
FilterChip(selected = false, onClick = {}, label = { Text(label) })
}
}
}
}
val firstTop = composeRule.onNodeWithText("Socket").getUnclippedBoundsInRoot().top
val lastTop = composeRule.onNodeWithText("Notify").getUnclippedBoundsInRoot().top
assertTrue("The final choice should wrap below the first row", lastTop > firstTop)
}
}
@@ -0,0 +1,73 @@
package com.flagship.abox.manager.ui.shell
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AccountProviderDialogTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun dialogExposesAccountActionsAndClose() {
var closed = 0
composeRule.setContent {
ABoxTheme {
AccountProviderDialog(
userName = "user",
providerName = "ABox",
onDismissRequest = { closed++ },
onOpenSettings = {},
onSwitchProvider = {},
onEndSession = {},
onForgetCredentials = {},
)
}
}
composeRule.onNodeWithText("账户与提供方").assertIsDisplayed()
composeRule.onNodeWithTag("account_dialog_close").performClick()
assertEquals(1, closed)
}
@Test
fun destructiveActionsRequireSeparateConfirmation() {
var ended = 0
var forgotten = 0
composeRule.setContent {
ABoxTheme {
AccountProviderDialog(
userName = "user",
providerName = "ABox",
onDismissRequest = {},
onOpenSettings = {},
onSwitchProvider = {},
onEndSession = { ended++ },
onForgetCredentials = { forgotten++ },
)
}
}
composeRule.onNodeWithTag("account_dialog_end_session").performClick()
composeRule.onNodeWithText("退出当前会话").assertIsDisplayed()
composeRule.onNodeWithTag("account_dialog_confirm").performClick()
assertEquals(1, ended)
assertEquals(0, forgotten)
composeRule.onNodeWithTag("account_dialog_forget_credentials").performClick()
composeRule.onNodeWithText("清除保存的凭据").assertIsDisplayed()
composeRule.onNodeWithTag("account_dialog_confirm").performClick()
assertEquals(1, forgotten)
}
}
@@ -0,0 +1,92 @@
package com.flagship.abox.manager.ui.shell
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Text
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainAppShellTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun compactUsesBottomNavigationAndDelegatesTabClick() {
var selectedTab: MainTab? = null
composeRule.setContent {
ABoxTheme {
MainAppShell(
currentTab = MainTab.DEVICES,
windowSizeClass = ABoxWindowSizeClass.COMPACT,
userName = "user",
providerName = "ABox",
onTabClick = { selectedTab = it },
onProviderClick = {},
onAvatarClick = {},
content = { _: PaddingValues -> Text("content") },
)
}
}
composeRule.onNodeWithTag("main_tab_devices").assertIsDisplayed()
composeRule.onNodeWithTag("main_tab_scenes").performClick()
assertEquals(MainTab.SCENES, selectedTab)
}
@Test
fun mediumAndExpandedExposeRailNavigation() {
composeRule.setContent {
ABoxTheme {
MainAppShell(
currentTab = MainTab.DEVICES,
windowSizeClass = ABoxWindowSizeClass.MEDIUM,
userName = "user",
providerName = "ABox",
onTabClick = {},
onProviderClick = {},
onAvatarClick = {},
content = { _: PaddingValues -> Text("content") },
)
}
}
composeRule.onNodeWithText("Scenes").assertIsDisplayed()
}
@Test
fun topAreaDelegatesProviderAndAvatarClicks() {
var providerClicks = 0
var avatarClicks = 0
composeRule.setContent {
ABoxTheme {
MainAppShell(
currentTab = MainTab.DEVICES,
windowSizeClass = ABoxWindowSizeClass.COMPACT,
userName = "user",
providerName = "ABox",
onTabClick = {},
onProviderClick = { providerClicks++ },
onAvatarClick = { avatarClicks++ },
content = { _: PaddingValues -> Text("content") },
)
}
}
composeRule.onNodeWithTag("main_provider_pill").performClick()
composeRule.onNodeWithTag("main_avatar").performClick()
assertEquals(1, providerClicks)
assertEquals(1, avatarClicks)
}
}
@@ -0,0 +1,41 @@
package com.flagship.abox.manager.ui.socket
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SocketDetailScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun awaitingConfirmationShowsMainControlAndWarning() {
composeRule.setContent {
ABoxTheme {
SocketScreen(
state = SocketUiState(
profileId = ProviderProfileId("p1"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
operation = SocketOperationState.AwaitingConfirmation(SocketPowerState.OFF),
),
onEvent = {},
onBackClick = {},
)
}
}
composeRule.onNodeWithTag("socket_power_dial").assertIsDisplayed()
composeRule.onNodeWithTag("socket_awaiting_confirmation").assertIsDisplayed()
}
}
@@ -0,0 +1,43 @@
package com.flagship.abox.manager.ui.temperaturehumidity
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TemperatureHumidityDetailScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun partialReadingRendersOnlyAvailableMetric() {
composeRule.setContent {
ABoxTheme {
TemperatureHumidityScreen(
state = TemperatureHumidityUiState(
profileId = ProviderProfileId("p1"),
deviceName = DeviceName("sensor-1"),
temperature = "24.2",
humidity = null,
),
onEvent = {},
onBackClick = {},
)
}
}
composeRule.onNodeWithTag("sensor_metric_temperature").assertIsDisplayed()
composeRule.onAllNodesWithTag("sensor_metric_humidity").assertCountEquals(0)
composeRule.onNodeWithTag("sensor_monitoring_notice").assertIsDisplayed()
}
}
@@ -1,22 +0,0 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object FoundationModule {
// TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production
// ABox catalog is owned by the device-catalog module. Until that exists, debug builds
// keep the fake ABox catalog here. Do NOT route ABox catalog requests to
// SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding
// until a real release consumer appears.
@Provides
@Singleton
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
}
+16 -1
View File
@@ -1,12 +1,27 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<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.POST_NOTIFICATIONS" />
<application
android:name=".ABoxManagerApplication"
android:enableOnBackInvokedCallback="false"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.ABoxManager">
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
<activity
android:name=".MainActivity"
android:exported="true">
@@ -1,7 +1,22 @@
package com.flagship.abox.manager
import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.flagship.abox.manager.automation.runtime.AutomationBootstrapper
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
@HiltAndroidApp
class ABoxManagerApplication : Application()
class ABoxManagerApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
@Inject lateinit var automationBootstrapper: AutomationBootstrapper
override fun getWorkManagerConfiguration(): Configuration =
Configuration.Builder().setWorkerFactory(workerFactory).build()
override fun onCreate() {
super.onCreate()
automationBootstrapper.start()
}
}
@@ -1,19 +1,38 @@
package com.flagship.abox.manager
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.core.content.ContextCompat
import com.flagship.abox.manager.ui.ABoxManagerApp
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ABoxManagerApp()
}
requestNotificationPermissionIfNeeded()
}
private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
) {
return
}
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -2,12 +2,16 @@ package com.flagship.abox.manager.di
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
import com.flagship.abox.manager.simulation.SimulationSocketGateway
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
import com.flagship.abox.manager.simulation.SimulationDeviceCatalog
import com.flagship.abox.manager.devicecatalog.RoutingDeviceCatalog
import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -22,6 +26,18 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DeviceGatewayModule {
@Provides
@Singleton
fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog()
@Provides
@Singleton
fun provideDeviceCatalog(
profileStore: ProviderProfileStore,
configured: RoomConfiguredDeviceRepository,
simulation: SimulationDeviceCatalog,
): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation)
@Provides
@Singleton
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
@@ -62,6 +62,15 @@ internal class RoutingSocketGateway(
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
}
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).commandPower(profileId, deviceName, powerState)
}
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
@@ -1,11 +1,14 @@
package com.flagship.abox.manager.di
import com.flagship.abox.manager.domain.DefaultSceneExecutor
import com.flagship.abox.manager.domain.InMemorySceneRepository
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.ui.scene.RecordingSceneExecutor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -18,14 +21,21 @@ import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SceneIntegrationModule {
@Provides
@Singleton
fun provideSceneRepository(): SceneRepository = InMemorySceneRepository()
@Provides
@Singleton
fun provideSceneExecutor(
socketGateway: SocketGateway,
infraredGateway: InfraredGateway,
): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway)
sendVirtualRemoteAction: SendVirtualRemoteAction,
notificationGateway: NotificationGateway,
activityWriter: ActivityEventWriter,
): SceneExecutor = RecordingSceneExecutor(
delegate = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = infraredGateway,
sendVirtualRemoteAction = sendVirtualRemoteAction,
notificationGateway = notificationGateway,
),
writer = activityWriter,
)
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.di
import android.content.Context
import com.flagship.abox.manager.preferences.DataStoreUiPreferencesRepository
import com.flagship.abox.manager.preferences.UiPreferencesRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UiPreferencesModule {
@Provides
@Singleton
fun provideUiPreferencesRepository(
@ApplicationContext context: Context,
): UiPreferencesRepository = DataStoreUiPreferencesRepository(context)
}
@@ -7,6 +7,11 @@ import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
@@ -14,31 +19,50 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.designsystem.ABoxBackLayer
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel
import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
import com.flagship.abox.manager.ui.about.AboutScreen
import com.flagship.abox.manager.ui.preferences.SettingsRoute
import com.flagship.abox.manager.ui.preferences.SettingsExperienceState
import com.flagship.abox.manager.ui.preferences.SettingsViewModel
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.scene.SceneNavHost
import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
import com.flagship.abox.manager.ui.shell.MainAppShell
import com.flagship.abox.manager.ui.shell.AccountProviderDialog
import com.flagship.abox.manager.ui.session.SessionUiAction
@Composable
fun ABoxManagerApp(
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
loginViewModel: LoginViewModel = hiltViewModel(),
settingsViewModel: SettingsViewModel = hiltViewModel(),
) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val actionError = actionState.errorMessage?.asString()
val screenKey = sessionState.screenKey()
var showAccountDialog by rememberSaveable(screenKey) { mutableStateOf(false) }
var showSettings by rememberSaveable(screenKey) { mutableStateOf(false) }
var showAbout by rememberSaveable(screenKey) { mutableStateOf(false) }
val settingsExperienceState = rememberSaveable(screenKey, saver = SettingsExperienceState.Saver) {
SettingsExperienceState()
}
LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError()
if (sessionState !is AppSessionUiState.Login) {
loginViewModel.clearSensitiveState()
}
}
ABoxTheme {
ABoxTheme(preferences = settingsState.preferences) {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
when (val state = sessionState) {
AppSessionUiState.Loading -> Box(
@@ -53,26 +77,99 @@ fun ABoxManagerApp(
is AppSessionUiState.Login -> LoginRoute(
profile = state.profile,
bootstrapError = state.bootstrapError,
actionError = actionState.errorMessage,
actionError = actionError,
actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel,
)
is AppSessionUiState.Authenticated -> MainAppShell(
userName = state.username.value,
providerName = state.profile.displayName,
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
username = state.username,
profile = state.profile,
onLogout = sessionActionsViewModel::logout,
actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage,
scenesSlot = {
SceneNavHost()
},
onSessionAction = sessionActionsViewModel::dispatch,
showAccountDialog = showAccountDialog,
showSettings = showSettings,
showAbout = showAbout,
settingsExperienceState = settingsExperienceState,
onShowAccountDialogChange = { showAccountDialog = it },
onShowSettingsChange = { showSettings = it },
onShowAboutChange = { showAbout = it },
)
}
}
}
}
@Composable
private fun AuthenticatedNavHost(
username: Username,
profile: ProviderProfile,
onLogout: () -> Unit,
onSessionAction: (SessionUiAction) -> Unit,
showAccountDialog: Boolean,
showSettings: Boolean,
showAbout: Boolean,
settingsExperienceState: SettingsExperienceState,
onShowAccountDialogChange: (Boolean) -> Unit,
onShowSettingsChange: (Boolean) -> Unit,
onShowAboutChange: (Boolean) -> Unit,
) {
Box(Modifier.fillMaxSize()) {
AuthenticatedNavigation(
username = username,
profile = profile,
onLogout = onLogout,
onProviderClick = { onShowAccountDialogChange(true) },
onAvatarClick = { onShowAccountDialogChange(true) },
)
if (showSettings) {
ABoxBackLayer(
onBack = { onShowSettingsChange(false) },
enabled = settingsExperienceState.isHome,
) {
SettingsRoute(
experienceState = settingsExperienceState,
onExit = { onShowSettingsChange(false) },
)
}
}
if (showAbout) {
ABoxBackLayer(onBack = { onShowAboutChange(false) }) {
AboutScreen(onBack = { onShowAboutChange(false) })
}
}
}
if (showAccountDialog) {
AccountProviderDialog(
userName = username.value,
providerName = profile.displayName,
onDismissRequest = { onShowAccountDialogChange(false) },
onOpenSettings = {
onShowAccountDialogChange(false)
settingsExperienceState.showTheme(returnToHome = false)
onShowSettingsChange(true)
},
onOpenAbout = {
onShowAccountDialogChange(false)
onShowAboutChange(true)
},
onSwitchProvider = {
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.SwitchProvider)
},
onEndSession = {
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.EndSession)
},
onForgetCredentials = {
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.ForgetSavedCredentials)
},
)
}
}
private fun AppSessionUiState.screenKey(): String = when (this) {
AppSessionUiState.Loading -> "loading"
AppSessionUiState.ProviderSelection -> "providers"
@@ -0,0 +1,221 @@
package com.flagship.abox.manager.ui.about
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Language
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AboutScreen(
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val packageInfo = remember(context.packageName) {
runCatching {
context.packageManager.getPackageInfo(context.packageName, 0)
}.getOrNull()
}
val versionName = packageInfo?.versionName ?: stringResource(R.string.about_unknown)
val versionCode = packageInfo?.let {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) it.longVersionCode else {
@Suppress("DEPRECATION")
it.versionCode.toLong()
}
}
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.about_title)) },
navigationIcon = {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = stringResource(R.string.action_back),
onClick = onBack,
)
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Image(
painter = painterResource(R.drawable.ic_abox_logo_master),
contentDescription = stringResource(R.string.app_name),
modifier = Modifier
.size(88.dp)
.clip(RoundedCornerShape(16.dp)),
)
Text(
stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
Text(
stringResource(R.string.about_tagline),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AboutSection(stringResource(R.string.about_app_info)) {
AboutValueRow(stringResource(R.string.about_version), "$versionName (${versionCode ?: stringResource(R.string.about_unknown)})")
AboutValueRow(stringResource(R.string.about_package), context.packageName)
AboutValueRow(stringResource(R.string.about_platform), stringResource(R.string.about_platform_value))
}
AboutSection(stringResource(R.string.about_product)) {
Text(
stringResource(R.string.about_product_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
AboutSection(stringResource(R.string.about_team)) {
TeamMember("KremeCN", stringResource(R.string.about_role_foundation), "https://github.com/KremeCN", "https://kimico.de/")
TeamMember("alZerNest", stringResource(R.string.about_role_devices), "https://github.com/alZerNest")
TeamMember("Aurum", stringResource(R.string.about_role_environment))
TeamMember("Moler", stringResource(R.string.about_role_infrared), "https://github.com/mxhsnd", "https://emoler.net/")
TeamMember("tzh", stringResource(R.string.about_role_scenes))
}
Text(
stringResource(R.string.about_copyright),
modifier = Modifier.fillMaxWidth(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(12.dp))
}
}
}
@Composable
private fun AboutSection(
title: String,
content: @Composable () -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
content()
}
}
}
}
@Composable
private fun AboutValueRow(label: String, value: String) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, fontWeight = FontWeight.Medium, modifier = Modifier.padding(start = 16.dp))
}
}
@Composable
private fun TeamMember(
name: String,
role: String,
githubUrl: String? = null,
personalUrl: String? = null,
) {
val uriHandler = LocalUriHandler.current
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(name, fontWeight = FontWeight.SemiBold)
if (githubUrl != null) {
Icon(
painter = painterResource(R.drawable.ic_github),
contentDescription = stringResource(R.string.about_link_github),
modifier = Modifier
.padding(start = 6.dp)
.size(18.dp)
.clickable { uriHandler.openUri(githubUrl) },
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (personalUrl != null) {
Icon(
imageVector = Icons.Outlined.Language,
contentDescription = stringResource(R.string.about_link_personal),
modifier = Modifier
.padding(start = 6.dp)
.size(18.dp)
.clickable { uriHandler.openUri(personalUrl) },
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
role,
modifier = Modifier.padding(start = 16.dp),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,18 @@
package com.flagship.abox.manager.ui.common
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.DomainError
object DomainErrorMapper {
fun map(error: DomainError): UiText = when (error) {
is DomainError.Authentication -> UiText.StringResource(R.string.error_authentication)
is DomainError.TokenInvalid -> UiText.StringResource(R.string.error_token_invalid)
is DomainError.Network -> UiText.StringResource(R.string.error_network)
is DomainError.DeviceNotFound -> UiText.StringResource(R.string.error_device_not_found)
is DomainError.DeviceOffline -> UiText.StringResource(R.string.error_device_offline)
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown)
}
}
@@ -0,0 +1,36 @@
package com.flagship.abox.manager.ui.common
import android.content.Context
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
sealed interface UiText {
data class DynamicString(val value: String) : UiText
data class StringResource(
@StringRes val resId: Int,
val args: List<Any> = emptyList(),
) : UiText {
constructor(@StringRes resId: Int, vararg args: Any) : this(resId, args.toList())
}
@Composable
fun asString(): String = when (this) {
is DynamicString -> value
is StringResource -> if (args.isEmpty()) {
stringResource(resId)
} else {
stringResource(resId, *args.toTypedArray())
}
}
fun asString(context: Context): String = when (this) {
is DynamicString -> value
is StringResource -> if (args.isEmpty()) {
context.getString(resId)
} else {
context.getString(resId, *args.toTypedArray())
}
}
}
@@ -0,0 +1,122 @@
package com.flagship.abox.manager.ui.defaults
import android.content.Context
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.first
@Singleton
class ABoxDefaultContentInitializer @Inject constructor(
@ApplicationContext context: Context,
private val profileStore: ProviderProfileStore,
private val deviceRepository: ConfiguredDeviceRepository,
private val remoteRepository: VirtualRemoteRepository,
) {
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
suspend fun initialize(profileId: ProviderProfileId) {
val profile = profileStore.activeProfile()
if (profile?.id != profileId || profile.type != ProviderType.ABOX) return
val marker = "$MARKER_PREFIX${profileId.value}"
if (preferences.getInt(marker, 0) >= CONTENT_VERSION) return
if (!ensureDevices(profileId)) return
if (!ensureCustomRemotes(profileId)) return
preferences.edit().putInt(marker, CONTENT_VERSION).apply()
}
private suspend fun ensureDevices(profileId: ProviderProfileId): Boolean {
val existing = when (val result = deviceRepository.observe(profileId).first()) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return false
}
val defaults = listOf(
DeviceName("智能插座") to DeviceType.SOCKET,
DeviceName("温湿度传感器") to DeviceType.TEMPERATURE_HUMIDITY,
DeviceName("红外控制器") to DeviceType.INFRARED,
)
for ((name, type) in defaults) {
if (existing.any { it.type == type }) continue
if (deviceRepository.add(profileId, name, type) is DomainResult.Failure) return false
}
return true
}
private suspend fun ensureCustomRemotes(profileId: ProviderProfileId): Boolean {
val deviceName = DeviceName("红外控制器")
val existing = when (val result = remoteRepository.observeRemotes(profileId, deviceName).first()) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return false
}
val now = System.currentTimeMillis()
val remotes = listOf(
customRemote(profileId, deviceName, "custom-tv", "自定义电视遥控器", PanelLayoutStyle.TV_NAV, now),
customRemote(profileId, deviceName, "custom-panel", "自定义面板遥控器", PanelLayoutStyle.GRID, now + 1),
)
for (remote in remotes) {
if (existing.any { it.id == remote.id }) continue
val templates = PresetCodeLibrary.getTemplatesForType(
type = RemoteDeviceType.CUSTOM,
layoutStyle = remote.layoutStyle,
)
val bindings = templates.mapIndexed { index, template ->
RemoteKeyBinding(
id = "${remote.id.value}:${index + 1}",
remoteId = remote.id,
profileId = profileId,
hardwareDeviceName = deviceName,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = null,
rawHexCode = null,
status = InfraredKeyStatus.UNLEARNED,
)
}
if (remoteRepository.createRemote(remote, bindings) is DomainResult.Failure) return false
}
return true
}
private fun customRemote(
profileId: ProviderProfileId,
deviceName: DeviceName,
idSuffix: String,
displayName: String,
layout: PanelLayoutStyle,
createdAt: Long,
) = VirtualRemote(
id = VirtualRemoteId("${profileId.value}:default:$idSuffix"),
profileId = profileId,
deviceName = deviceName,
displayName = displayName,
type = RemoteDeviceType.CUSTOM,
layoutStyle = layout,
transmissionMode = TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = createdAt,
updatedAtEpochMillis = createdAt,
)
private companion object {
const val PREFERENCES_NAME = "abox_default_content"
const val MARKER_PREFIX = "content_version_"
const val CONTENT_VERSION = 1
}
}
@@ -0,0 +1,55 @@
package com.flagship.abox.manager.ui.devicecenter
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
data class DeviceCardKey(val profileId: ProviderProfileId, val deviceName: DeviceName)
fun DeviceCardKey.saveableKey(): String = "${profileId.value}\u0000${deviceName.value}"
enum class DeviceCategoryFilter { ALL, SOCKET, SENSOR, INFRARED }
data class DeviceCardUiModel(
val key: DeviceCardKey,
val device: DeviceDescriptor,
val displayName: String = device.name.value,
val typeLabel: String = when (device.type) {
DeviceType.SOCKET -> "插座"
DeviceType.TEMPERATURE_HUMIDITY -> "温湿度"
DeviceType.INFRARED -> "红外控制器"
},
val detail: DeviceCardDetail = DeviceCardDetail.Loading,
)
sealed interface DeviceCardDetail {
data object Loading : DeviceCardDetail
data class Socket(
val powerState: SocketPowerState,
val operating: Boolean = false,
val pendingTarget: SocketPowerState? = null,
val lastConfirmedAtMillis: Long? = null,
) : DeviceCardDetail
data class Sensor(
val temperature: String?,
val humidity: String?,
val refreshing: Boolean = false,
val lastUpdatedAtMillis: Long? = null,
) : DeviceCardDetail
data class Infrared(
val remoteNames: List<String>,
val quota: SlotQuotaSummary,
) : DeviceCardDetail
data object Offline : DeviceCardDetail
data object Unavailable : DeviceCardDetail
}
enum class DeviceCenterContent { LOADING, CONTENT, EMPTY, ERROR }
data class DeviceCenterUiState(
val profileId: ProviderProfileId? = null,
val filter: DeviceCategoryFilter = DeviceCategoryFilter.ALL,
val cards: List<DeviceCardUiModel> = emptyList(),
val selectedCard: DeviceCardKey? = null,
val content: DeviceCenterContent = DeviceCenterContent.LOADING,
)
@@ -0,0 +1,31 @@
package com.flagship.abox.manager.ui.devicecenter
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
@Composable
fun DeviceCenterRoute(
profileId: ProviderProfileId,
onOpenDevice: (String, DeviceType) -> Unit,
modifier: Modifier = Modifier,
viewModel: DeviceCenterViewModel = hiltViewModel(),
) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
LaunchedEffect(profileId) { viewModel.initialize(profileId) }
DeviceCenterScreen(
state = state,
onFilterChanged = viewModel::setFilter,
onOpenDevice = { card -> onOpenDevice(card.device.name.value, card.device.type) },
onConfigureDevice = viewModel::addConfiguredDevice,
onQuickAction = { card ->
if (card.device.type == DeviceType.SOCKET) viewModel.toggleSocket(card)
else viewModel.refreshDevice(card)
},
modifier = modifier,
)
}
@@ -0,0 +1,350 @@
package com.flagship.abox.manager.ui.devicecenter
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxCard
import com.flagship.abox.manager.designsystem.ABoxEmptyState
import com.flagship.abox.manager.designsystem.ABoxFilterChip
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.SocketPowerState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun DeviceCenterScreen(
state: DeviceCenterUiState,
onFilterChanged: (DeviceCategoryFilter) -> Unit,
onOpenDevice: (DeviceCardUiModel) -> Unit,
onConfigureDevice: (String, DeviceType) -> Unit,
onQuickAction: (DeviceCardUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
var showConfiguration by remember { mutableStateOf(false) }
Column(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.padding(horizontal = 2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(stringResource(R.string.device_center_heading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.device_center_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
when (state.content) {
DeviceCenterContent.LOADING -> Text(stringResource(R.string.device_center_loading), modifier = Modifier.testTag("device_center_loading"))
DeviceCenterContent.EMPTY -> ABoxEmptyState(
title = stringResource(R.string.device_center_empty_title),
description = stringResource(R.string.device_center_empty_description),
modifier = Modifier.testTag("device_center_empty"),
action = {
Button(onClick = { showConfiguration = true }) { Text(stringResource(R.string.device_center_configure)) }
},
)
DeviceCenterContent.ERROR -> Text(stringResource(R.string.device_center_error), modifier = Modifier.testTag("device_center_error"))
DeviceCenterContent.CONTENT -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 220.dp),
contentPadding = PaddingValues(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.testTag("device_center_grid"),
) {
items(state.cards, key = { it.key.saveableKey() }) { card ->
when (card.device.type) {
DeviceType.SOCKET -> SocketDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) })
DeviceType.TEMPERATURE_HUMIDITY -> SensorDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) })
DeviceType.INFRARED -> InfraredDeviceCard(card, { onOpenDevice(card) })
}
}
}
}
}
if (showConfiguration) {
DeviceConfigurationDialog(
onDismiss = { showConfiguration = false },
onConfirm = { name, type ->
showConfiguration = false
onConfigureDevice(name, type)
},
)
}
}
@Composable
private fun DeviceConfigurationDialog(
onDismiss: () -> Unit,
onConfirm: (String, DeviceType) -> Unit,
) {
var name by remember { mutableStateOf("") }
var type by remember { mutableStateOf(DeviceType.SOCKET) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.device_center_configure_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.device_center_device_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
FilterRow(
selected = when (type) {
DeviceType.SOCKET -> DeviceCategoryFilter.SOCKET
DeviceType.TEMPERATURE_HUMIDITY -> DeviceCategoryFilter.SENSOR
DeviceType.INFRARED -> DeviceCategoryFilter.INFRARED
},
onSelected = { selected ->
type = when (selected) {
DeviceCategoryFilter.SOCKET -> DeviceType.SOCKET
DeviceCategoryFilter.SENSOR -> DeviceType.TEMPERATURE_HUMIDITY
DeviceCategoryFilter.INFRARED -> DeviceType.INFRARED
DeviceCategoryFilter.ALL -> type
}
},
)
}
},
confirmButton = {
Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.action_confirm)) }
},
dismissButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
)
}
@Composable
private fun FilterRow(
selected: DeviceCategoryFilter,
onSelected: (DeviceCategoryFilter) -> Unit,
) {
androidx.compose.foundation.lazy.LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
item { FilterChipItem(DeviceCategoryFilter.ALL, "全部", selected, onSelected) }
item { FilterChipItem(DeviceCategoryFilter.SOCKET, "插座", selected, onSelected) }
item { FilterChipItem(DeviceCategoryFilter.SENSOR, "温湿度", selected, onSelected) }
item { FilterChipItem(DeviceCategoryFilter.INFRARED, "红外", selected, onSelected) }
}
}
@Composable
private fun FilterChipItem(
filter: DeviceCategoryFilter,
label: String,
selected: DeviceCategoryFilter,
onSelected: (DeviceCategoryFilter) -> Unit,
) {
ABoxFilterChip(selected = selected == filter, label = label, onClick = { onSelected(filter) })
}
@Composable
private fun SocketDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onToggle: () -> Unit) {
val detail = card.detail as? DeviceCardDetail.Socket
val isOffline = card.detail is DeviceCardDetail.Offline
val isAwaitingConfirmation = detail?.pendingTarget != null && !detail.operating
val isOn = detail?.powerState == SocketPowerState.ON
DeviceSurface(card, onOpen, minimumHeight = 0.dp) {
DeviceHeader(
icon = ABoxIconKey.SOCKET,
title = card.displayName,
subtitle = "${stringResource(R.string.socket_title)} · ${when { isOffline -> stringResource(R.string.device_status_offline); detail == null -> stringResource(R.string.device_status_unavailable); detail.operating -> stringResource(R.string.device_status_switching); isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation); isOn -> stringResource(R.string.device_status_on); else -> stringResource(R.string.device_status_off) }}",
action = {
Surface(
modifier = Modifier
.size(48.dp)
.border(
width = if (isOn) 2.dp else 1.dp,
color = if (isOn) MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)
else MaterialTheme.colorScheme.outline,
shape = CircleShape,
),
shape = CircleShape,
color = if (isOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
tonalElevation = if (isOn) 6.dp else 0.dp,
) {
IconButton(
onClick = onToggle,
enabled = !isOffline && detail != null && !detail.operating && !isAwaitingConfirmation,
) {
if (detail?.operating == true) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = if (isOn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
)
} else {
ABoxIcon(
ABoxIconKey.POWER,
if (isOn) "关闭插座" else "开启插座",
modifier = Modifier.size(22.dp),
)
}
}
}
},
)
CardFooter(
left = when {
isOffline -> stringResource(R.string.device_status_offline)
isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation)
detail?.lastConfirmedAtMillis != null -> stringResource(R.string.device_status_confirmed)
else -> stringResource(R.string.device_status_waiting_first_confirmation)
},
right = stringResource(R.string.device_open_control_panel),
modifier = Modifier.padding(top = 24.dp),
)
}
}
@Composable
private fun SensorDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onRefresh: () -> Unit) {
val detail = card.detail as? DeviceCardDetail.Sensor
val isOffline = card.detail is DeviceCardDetail.Offline
DeviceSurface(card, onOpen, minimumHeight = 190.dp) {
DeviceHeader(
icon = ABoxIconKey.SENSOR,
title = card.displayName,
subtitle = if (isOffline) {
"${stringResource(R.string.device_sensor_subtitle)} · ${stringResource(R.string.device_status_offline)}"
} else {
stringResource(R.string.device_sensor_subtitle)
},
action = {
IconButton(onClick = onRefresh, enabled = detail?.refreshing != true) {
if (detail?.refreshing == true) CircularProgressIndicator(Modifier.size(20.dp))
else ABoxIcon(ABoxIconKey.REFRESH, "刷新温湿度")
}
},
)
Row(Modifier.fillMaxWidth().padding(vertical = 22.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
Metric("环境温度", detail?.temperature, "°C", ABoxIconKey.TEMPERATURE)
Surface(Modifier.width(1.dp).height(48.dp), color = MaterialTheme.colorScheme.outlineVariant) {}
Metric("相对湿度", detail?.humidity, "%", ABoxIconKey.HUMIDITY)
}
CardFooter(
if (isOffline) stringResource(R.string.device_status_offline) else stringResource(R.string.device_live_reading),
stringResource(R.string.device_details),
Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun InfraredDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit) {
val detail = card.detail as? DeviceCardDetail.Infrared
DeviceSurface(card, onOpen, minimumHeight = 172.dp) {
DeviceHeader(
icon = ABoxIconKey.INFRARED,
title = card.displayName,
subtitle = stringResource(R.string.device_infrared_subtitle, detail?.remoteNames?.size ?: 0),
)
Text(
text = stringResource(R.string.device_infrared_mounted, detail?.remoteNames?.joinToString("、")?.takeIf { it.isNotBlank() } ?: stringResource(R.string.device_infrared_no_remotes)),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 22.dp, bottom = 18.dp),
)
CardFooter(
left = detail?.quota?.let { stringResource(R.string.device_infrared_slots, it.usedCount, it.totalCapacity) } ?: stringResource(R.string.device_infrared_slots_unavailable),
right = stringResource(R.string.device_infrared_center),
)
}
}
@Composable
private fun DeviceSurface(
card: DeviceCardUiModel,
onOpen: () -> Unit,
minimumHeight: androidx.compose.ui.unit.Dp,
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().testTag("device_card_${card.device.id.value}"),
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = minimumHeight)
.clickable(onClick = onOpen)
.padding(horizontal = 18.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
content = content,
)
}
}
@Composable
private fun DeviceHeader(icon: ABoxIconKey, title: String, subtitle: String, action: (@Composable () -> Unit)? = null) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) {
ABoxIcon(icon, subtitle, Modifier.padding(10.dp).size(24.dp))
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
action?.invoke()
}
}
@Composable
private fun Metric(label: String, value: String?, unit: String, icon: ABoxIconKey) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
ABoxIcon(icon, label, Modifier.size(18.dp))
Text(value ?: stringResource(R.string.device_metric_no_data), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 6.dp))
if (value != null) Text(unit, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 2.dp))
}
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun CardFooter(left: String, right: String, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(left, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(right, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
}
}
@@ -0,0 +1,216 @@
package com.flagship.abox.manager.ui.devicecenter
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.ui.defaults.ABoxDefaultContentInitializer
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class DeviceCenterViewModel @Inject constructor(
private val catalog: DeviceCatalog,
private val configuredRepository: ConfiguredDeviceRepository,
private val socketGateway: SocketGateway,
private val temperatureHumidityGateway: TemperatureHumidityGateway,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val defaultContentInitializer: ABoxDefaultContentInitializer,
private val activityWriter: ActivityEventWriter,
) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceCenterUiState())
val uiState: StateFlow<DeviceCenterUiState> = _uiState.asStateFlow()
private var catalogJob: Job? = null
private var allCards: List<DeviceCardUiModel> = emptyList()
fun initialize(profileId: ProviderProfileId) {
if (_uiState.value.profileId == profileId && catalogJob?.isActive == true) return
catalogJob?.cancel()
allCards = emptyList()
_uiState.value = DeviceCenterUiState(profileId = profileId)
catalogJob = viewModelScope.launch {
defaultContentInitializer.initialize(profileId)
catalog.observeDevices(profileId).collectLatest { result ->
when (result) {
is DomainResult.Success -> {
allCards = result.value.map(::toCard)
publishCards()
refreshAllCards()
}
is DomainResult.Failure -> _uiState.value = _uiState.value.copy(content = DeviceCenterContent.ERROR)
}
}
}
}
fun setFilter(filter: DeviceCategoryFilter) {
_uiState.value = _uiState.value.copy(filter = filter)
publishCards()
}
fun select(device: DeviceDescriptor) {
_uiState.value = _uiState.value.copy(selectedCard = DeviceCardKey(device.profileId, device.name))
}
fun addConfiguredDevice(name: String, type: DeviceType) {
val profileId = _uiState.value.profileId ?: return
viewModelScope.launch { configuredRepository.add(profileId, com.flagship.abox.manager.domain.DeviceName(name), type) }
}
fun removeConfiguredDevice(device: DeviceCardUiModel) {
val profileId = _uiState.value.profileId ?: return
viewModelScope.launch { configuredRepository.remove(profileId, device.device.name) }
}
fun refreshDevice(card: DeviceCardUiModel) {
when (card.device.type) {
DeviceType.SOCKET -> refreshSocket(card)
DeviceType.TEMPERATURE_HUMIDITY -> refreshSensor(card)
DeviceType.INFRARED -> refreshInfrared(card)
}
}
fun toggleSocket(card: DeviceCardUiModel) {
val detail = card.detail as? DeviceCardDetail.Socket ?: return
if (detail.operating) return
val target = if (detail.powerState == SocketPowerState.ON) SocketPowerState.OFF else SocketPowerState.ON
updateCard(card.key) { it.copy(detail = detail.copy(operating = true, pendingTarget = target)) }
viewModelScope.launch {
val command = socketGateway.commandPower(card.device.profileId, card.device.name, target)
activityWriter.recordDevice(
card.device.profileId,
card.device.name,
ActivityEventKind.SOCKET_COMMAND,
command.activityOutcome(),
command.activityErrorCode(),
)
if (command is DomainResult.Success) {
repeat(CONFIRMATION_ATTEMPTS) { attempt ->
if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS)
when (val confirmed = socketGateway.getStatus(card.device.profileId, card.device.name)) {
is DomainResult.Success -> if (confirmed.value.powerState == target) {
activityWriter.recordDevice(card.device.profileId, card.device.name, ActivityEventKind.SOCKET_CONFIRMATION, ActivityOutcome.SUCCEEDED)
updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Socket(target, lastConfirmedAtMillis = System.currentTimeMillis()))
}
return@launch
}
is DomainResult.Failure -> Unit
}
}
activityWriter.recordDevice(
card.device.profileId,
card.device.name,
ActivityEventKind.SOCKET_CONFIRMATION,
ActivityOutcome.AWAITING_CONFIRMATION,
)
updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = target)) }
} else {
updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = null)) }
}
}
}
private fun refreshAllCards() = allCards.forEach(::refreshDevice)
private fun refreshSocket(card: DeviceCardUiModel) {
viewModelScope.launch {
when (val result = socketGateway.getStatus(card.device.profileId, card.device.name)) {
is DomainResult.Success -> updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Socket(result.value.powerState, lastConfirmedAtMillis = System.currentTimeMillis()))
}
is DomainResult.Failure -> updateCard(card.key) {
it.copy(detail = if (result.error is DomainError.DeviceOffline) DeviceCardDetail.Offline else DeviceCardDetail.Unavailable)
}
}
}
}
private fun refreshSensor(card: DeviceCardUiModel) {
val previous = card.detail as? DeviceCardDetail.Sensor
updateCard(card.key) { it.copy(detail = previous?.copy(refreshing = true) ?: DeviceCardDetail.Loading) }
viewModelScope.launch {
when (val result = temperatureHumidityGateway.getReading(card.device.profileId, card.device.name)) {
is DomainResult.Success -> updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Sensor(
temperature = result.value.temperature,
humidity = result.value.humidity,
lastUpdatedAtMillis = System.currentTimeMillis(),
))
}
is DomainResult.Failure -> updateCard(card.key) {
it.copy(
detail = if (result.error is DomainError.DeviceOffline) {
DeviceCardDetail.Offline
} else {
previous?.copy(refreshing = false) ?: DeviceCardDetail.Unavailable
},
)
}
}
}
}
private fun refreshInfrared(card: DeviceCardUiModel) {
viewModelScope.launch {
val remotes = virtualRemoteRepository.observeRemotes(card.device.profileId, card.device.name).first()
val quota = virtualRemoteRepository.observeSlotQuota(card.device.profileId, card.device.name).first()
if (remotes is DomainResult.Success && quota is DomainResult.Success) {
updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Infrared(remotes.value.map { remote -> remote.displayName }, quota.value))
}
} else updateCard(card.key) { it.copy(detail = DeviceCardDetail.Unavailable) }
}
}
private fun updateCard(key: DeviceCardKey, transform: (DeviceCardUiModel) -> DeviceCardUiModel) {
allCards = allCards.map { if (it.key == key) transform(it) else it }
publishCards()
}
private fun publishCards() {
val filter = _uiState.value.filter
val cards = allCards.filter { filter.matches(it.device.type) }
_uiState.value = _uiState.value.copy(cards = cards, content = if (allCards.isEmpty()) DeviceCenterContent.EMPTY else DeviceCenterContent.CONTENT)
}
private fun toCard(device: DeviceDescriptor) = DeviceCardUiModel(DeviceCardKey(device.profileId, device.name), device)
private companion object {
const val CONFIRMATION_ATTEMPTS = 4
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
}
}
private fun DomainResult<*>.activityOutcome(): ActivityOutcome =
if (this is DomainResult.Success) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED
private fun DomainResult<*>.activityErrorCode(): String? =
(this as? DomainResult.Failure)?.error?.externalCode
private fun DeviceCategoryFilter.matches(type: DeviceType): Boolean = when (this) {
DeviceCategoryFilter.ALL -> true
DeviceCategoryFilter.SOCKET -> type == DeviceType.SOCKET
DeviceCategoryFilter.SENSOR -> type == DeviceType.TEMPERATURE_HUMIDITY
DeviceCategoryFilter.INFRARED -> type == DeviceType.INFRARED
}
@@ -0,0 +1,95 @@
package com.flagship.abox.manager.ui.devicecontrollist
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxTheme
@Composable
fun DeviceListScreen(
modifier: Modifier = Modifier,
onSocketClick: () -> Unit = {},
onTemperatureHumidityClick: () -> Unit = {},
onInfraredClick: () -> Unit = {},
) {
Column(
modifier = modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(R.string.device_list_title),
style = MaterialTheme.typography.headlineLarge,
textAlign = TextAlign.Center,
)
Text(
text = stringResource(R.string.device_list_description),
modifier = Modifier.padding(top = 12.dp, bottom = 32.dp),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
DeviceEntryButton(
text = stringResource(R.string.device_socket_title),
description = stringResource(R.string.device_socket_description),
onClick = onSocketClick,
)
DeviceEntryButton(
text = stringResource(R.string.device_temperature_humidity_title),
description = stringResource(R.string.device_temperature_humidity_description),
onClick = onTemperatureHumidityClick,
)
DeviceEntryButton(
text = stringResource(R.string.device_infrared_title),
description = stringResource(R.string.device_infrared_description),
onClick = onInfraredClick,
)
}
}
@Composable
private fun DeviceEntryButton(
text: String,
description: String,
onClick: () -> Unit,
) {
ABoxButton(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp),
) {
Column(
modifier = Modifier.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = text, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodySmall)
}
}
}
@Preview(showBackground = true)
@Composable
private fun DeviceListScreenPreview() {
ABoxTheme {
DeviceListScreen()
}
}
@@ -0,0 +1,537 @@
package com.flagship.abox.manager.ui.navigation
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute
import com.flagship.abox.manager.automation.ui.activity.ActivityRoute
import com.flagship.abox.manager.ui.scene.SceneListScreen
import com.flagship.abox.manager.ui.preferences.SettingsRoute
import com.flagship.abox.manager.ui.scene.SceneRoutes
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass
import com.flagship.abox.manager.ui.shell.DetailPresentation
import com.flagship.abox.manager.ui.shell.DeviceSelection
import com.flagship.abox.manager.ui.shell.MainAppShell
import com.flagship.abox.manager.ui.shell.MainTab
import com.flagship.abox.manager.ui.shell.detailPresentation
import com.flagship.abox.manager.ui.shell.widthClass
import com.flagship.abox.manager.ui.socket.SocketScreen
import com.flagship.abox.manager.ui.socket.SocketUiEvent
import com.flagship.abox.manager.ui.socket.SocketViewModel
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityScreen
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityUiEvent
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityViewModel
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
/** Pure authenticated graph state for tab, detail, and profile continuity tests. */
data class AuthenticatedGraphState(
val profileId: ProviderProfileId,
val currentTab: MainTab = MainTab.DEVICES,
private val backStacks: Map<MainTab, List<String>> = MainTab.entries.associateWith { listOf(it.route) },
val selectedDevice: DeviceSelection? = null,
) {
fun selectTab(tab: MainTab): AuthenticatedGraphState {
val nextStack = if (tab == currentTab) {
listOf(tab.route)
} else {
backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) }
}
return copy(
currentTab = tab,
backStacks = backStacks + (tab to nextStack),
selectedDevice = null,
)
}
fun pushChild(route: String): AuthenticatedGraphState = copy(
backStacks = backStacks + (currentTab to (backStacks[currentTab].orEmpty() + route)),
)
fun tabBackStack(tab: MainTab): List<String> =
backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) }
fun recreateForProfile(newProfileId: ProviderProfileId): AuthenticatedGraphState =
AuthenticatedGraphState(profileId = newProfileId)
fun selectDevice(deviceName: DeviceName, type: DeviceType): AuthenticatedGraphState = copy(
selectedDevice = DeviceSelection(profileId, deviceName, type),
)
fun handleBack(): AuthenticatedGraphState = when {
selectedDevice != null -> copy(selectedDevice = null)
tabBackStack(currentTab).size > 1 -> copy(
backStacks = backStacks + (currentTab to tabBackStack(currentTab).dropLast(1)),
)
else -> this
}
}
data class AuthenticatedNavigationSnapshot(
val currentRoute: String?,
val selectedDevice: DeviceSelection?,
val detailPresentation: DetailPresentation?,
)
private val NullableDeviceSelectionSaver: Saver<DeviceSelection?, Any> = DeviceSelection.Saver
/**
* Navigation-owned state host. The authenticated graph and routing tests share this exact state
* machine so saved selection, route transitions, resize reconciliation, and BackHandler behavior
* cannot drift apart.
*/
@Composable
fun AuthenticatedNavigationStateHost(
profileId: ProviderProfileId,
windowSizeClass: ABoxWindowSizeClass,
navController: NavHostController,
deviceRoute: (DeviceSelection) -> String,
selectionForRoute: (String?) -> DeviceSelection? = { null },
content: @Composable (
snapshot: AuthenticatedNavigationSnapshot,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
) -> Unit,
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.actualRoute()
val routeSelection = selectionForRoute(currentRoute)?.takeIf { it.profileId == profileId }
var savedSelection by rememberSaveable(
profileId.value,
stateSaver = NullableDeviceSelectionSaver,
) {
mutableStateOf<DeviceSelection?>(null)
}
val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES
val selectedDevice = when {
routeSelection != null -> routeSelection
currentRoute == MainTab.DEVICES.route -> savedSelection?.takeIf { it.profileId == profileId }
else -> null
}
val selectedPresentation = selectedDevice?.let {
detailPresentation(windowSizeClass, it)
}
LaunchedEffect(profileId, currentRoute, selectedDevice, windowSizeClass) {
if (savedSelection?.profileId != null && savedSelection?.profileId != profileId) {
savedSelection = null
return@LaunchedEffect
}
val selection = selectedDevice ?: return@LaunchedEffect
when (detailPresentation(windowSizeClass, selection)) {
DetailPresentation.PANE -> {
if (savedSelection != selection) {
savedSelection = selection
}
if (currentRoute != MainTab.DEVICES.route) {
navController.popBackStack(MainTab.DEVICES.route, inclusive = false)
}
}
DetailPresentation.FULL_SCREEN -> {
if (currentRoute == MainTab.DEVICES.route) {
navController.navigate(deviceRoute(selection)) {
launchSingleTop = true
}
savedSelection = null
} else if (savedSelection != null) {
savedSelection = null
}
}
}
}
val snapshot = AuthenticatedNavigationSnapshot(
currentRoute = currentRoute,
selectedDevice = selectedDevice,
detailPresentation = selectedPresentation,
)
content(
snapshot,
{ tab ->
savedSelection = null
if (tab == currentTab) {
navController.popBackStack(tab.route, inclusive = false)
} else {
navController.navigate(tab.route) {
launchSingleTop = true
restoreState = true
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
}
}
},
{ deviceName, type -> savedSelection = DeviceSelection(profileId, deviceName, type) },
{ savedSelection = null },
)
}
@Composable
fun AuthenticatedNavigation(
username: Username,
profile: ProviderProfile,
onLogout: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
onOpenSettings: () -> Unit = {},
windowSizeClass: ABoxWindowSizeClass? = null,
) {
val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp)
key(profile.id.value) {
val navController = rememberNavController()
AuthenticatedNavigationStateHost(
profileId = profile.id,
windowSizeClass = resolvedWindowSizeClass,
navController = navController,
deviceRoute = { selection ->
when (selection.type) {
DeviceType.SOCKET -> DeviceDetailRoutes.socketRoute(profile.id, selection.deviceName)
DeviceType.TEMPERATURE_HUMIDITY -> DeviceDetailRoutes.temperatureHumidityRoute(
profile.id,
selection.deviceName,
)
DeviceType.INFRARED -> Routes.infraredHub(profile.id.value, selection.deviceName.value)
}
},
selectionForRoute = { route -> profile.selectionForRoute(route) },
) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection ->
val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES
AuthenticatedGraphContent(
navController = navController,
profile = profile,
username = username,
currentTab = currentTab,
windowSizeClass = resolvedWindowSizeClass,
isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
snapshot.currentRoute == MainTab.DEVICES.route,
selectedDevice = snapshot.selectedDevice,
onTabClick = onTabClick,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
onLogout = onLogout,
onOpenSettings = onOpenSettings,
)
}
}
}
@Composable
private fun AuthenticatedGraphContent(
navController: NavHostController,
profile: ProviderProfile,
username: Username,
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
isExpandedDevicePane: Boolean,
selectedDevice: DeviceSelection?,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) {
AuthenticatedNavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
profile = profile,
username = username,
currentTab = currentTab,
windowSizeClass = windowSizeClass,
isExpandedDevicePane = isExpandedDevicePane,
selectedDevice = selectedDevice,
onTabClick = onTabClick,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
onLogout = onLogout,
onOpenSettings = onOpenSettings,
)
}
@Composable
private fun AuthenticatedNavHost(
modifier: Modifier,
navController: NavHostController,
profile: ProviderProfile,
username: Username,
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
isExpandedDevicePane: Boolean,
selectedDevice: DeviceSelection?,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) {
val activeBackStackEntry by navController.currentBackStackEntryAsState()
val activeRoute = activeBackStackEntry?.actualRoute()
var backNavigationInFlight by remember { mutableStateOf(false) }
LaunchedEffect(activeRoute) {
backNavigationInFlight = false
}
val navigateBackFromDetail: () -> Unit = {
val liveRoute = navController.currentBackStackEntry?.actualRoute()
val isMainDestination = MainTab.entries.any { it.route == liveRoute }
if (!backNavigationInFlight && !isMainDestination) {
backNavigationInFlight = true
if (!navController.popBackStack()) {
backNavigationInFlight = false
}
}
}
NavHost(
navController = navController,
startDestination = MainTab.DEVICES.route,
modifier = modifier,
) {
composable(MainTab.DEVICES.route) {
MainAppShell(
currentTab = currentTab,
windowSizeClass = windowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
) { paddingValues ->
Row(Modifier.fillMaxSize().padding(paddingValues)) {
DeviceCenterRoute(
profileId = profile.id,
onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) },
modifier = Modifier.weight(1f),
)
if (isExpandedDevicePane && selectedDevice != null) {
DeviceDetailPane(
modifier = Modifier.weight(1f),
selection = selectedDevice,
onBack = onClearDeviceSelection,
)
}
}
}
}
composable(MainTab.SCENES.route) {
MainAppShell(
currentTab = currentTab,
windowSizeClass = windowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
) { paddingValues ->
Box(Modifier.fillMaxSize().padding(paddingValues)) {
SceneListScreen(
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
}
}
}
composable(MainTab.ACTIVITY.route) {
MainAppShell(
currentTab = currentTab,
windowSizeClass = windowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
) { paddingValues ->
Box(Modifier.fillMaxSize().padding(paddingValues)) {
ActivityRoute(profileId = profile.id)
}
}
}
composable(Routes.SETTINGS) {
SettingsRoute()
}
deviceDetailGraph(
onBackClick = {
onClearDeviceSelection()
navigateBackFromDetail()
},
)
sceneEditorDestination(
onBack = navigateBackFromDetail,
onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) },
)
sceneExecutionDestination(onBack = navigateBackFromDetail)
composable(
route = Routes.INFRARED_HUB,
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("deviceName") { type = NavType.StringType },
),
) { entry ->
InfraredHubRoute(
onBack = {
onClearDeviceSelection()
navigateBackFromDetail()
},
onOpenRemote = { remoteId ->
navController.navigate(
"${Routes.INFRARED_PANEL}/${Uri.encode(profile.id.value)}/${Uri.encode(remoteId)}",
)
},
onOpenSettings = {
val deviceName = entry.arguments?.getString("deviceName").orEmpty()
navController.navigate(Routes.infraredSettings(profile.id.value, deviceName))
},
onSessionInvalid = onLogout,
)
}
composable(
route = "${Routes.INFRARED_PANEL}/{profileId}/{remoteId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("remoteId") { type = NavType.StringType },
),
) {
RemotePanelRoute(
onBack = navigateBackFromDetail,
onSessionInvalid = onLogout,
)
}
composable(
route = Routes.INFRARED_SETTINGS,
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("deviceName") { type = NavType.StringType },
),
) {
InfraredControlRoute(
onBack = navigateBackFromDetail,
onSessionInvalid = onLogout,
)
}
}
}
@Composable
private fun DeviceDetailPane(
selection: DeviceSelection,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModelKey = "${selection.profileId.value}:${selection.deviceName.value}:${selection.type.name}"
when (selection.type) {
DeviceType.SOCKET -> {
val viewModel: SocketViewModel = hiltViewModel(key = viewModelKey)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(selection) {
viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName))
}
SocketScreen(
state = state,
onEvent = viewModel::onEvent,
onBackClick = onBack,
modifier = modifier,
)
}
DeviceType.TEMPERATURE_HUMIDITY -> {
val viewModel: TemperatureHumidityViewModel = hiltViewModel(key = viewModelKey)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(selection) {
viewModel.onEvent(
TemperatureHumidityUiEvent.Initialize(selection.profileId, selection.deviceName),
)
}
TemperatureHumidityScreen(
state = state,
onEvent = viewModel::onEvent,
onBackClick = onBack,
modifier = modifier,
)
}
DeviceType.INFRARED -> Unit
}
}
private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? {
val parts = route?.split('/') ?: return null
return when {
parts.size >= 4 && parts[0] == "device" && parts[1] == "socket" -> DeviceSelection(
profileId = id,
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
type = DeviceType.SOCKET,
)
parts.size >= 4 && parts[0] == "device" && parts[1] == "th" -> DeviceSelection(
profileId = id,
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
type = DeviceType.TEMPERATURE_HUMIDITY,
)
parts.size >= 3 && parts[0] == "infrared_hub" -> DeviceSelection(
profileId = id,
deviceName = DeviceName(parts[2].decodeRoutePart()),
type = DeviceType.INFRARED,
)
else -> null
}
}
private fun String.decodeRoutePart(): String = runCatching {
URLDecoder.decode(this, StandardCharsets.UTF_8.name())
}.getOrDefault(this)
private fun NavBackStackEntry.actualRoute(): String? {
var route = destination.route ?: return null
destination.arguments.keys.forEach { argumentName ->
arguments?.getString(argumentName)?.let { value ->
route = route.replace("{$argumentName}", Uri.encode(value))
}
}
return route
}
@@ -0,0 +1,59 @@
package com.flagship.abox.manager.ui.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.NavType
import androidx.navigation.compose.composable
import androidx.navigation.navArgument
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.ui.socket.SocketRoute
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityRoute
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object DeviceDetailRoutes {
const val SOCKET_PATTERN = "device/socket/{profileId}/{deviceName}"
const val TH_PATTERN = "device/th/{profileId}/{deviceName}"
fun socketRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
"device/socket/${encode(profileId.value)}/${encode(deviceName.value)}"
fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
"device/th/${encode(profileId.value)}/${encode(deviceName.value)}"
private fun encode(value: String): String =
URLEncoder.encode(value, StandardCharsets.UTF_8.name())
}
fun NavController.navigateToSocket(profileId: ProviderProfileId, deviceName: DeviceName) {
navigate(DeviceDetailRoutes.socketRoute(profileId, deviceName))
}
fun NavController.navigateToTemperatureHumidity(profileId: ProviderProfileId, deviceName: DeviceName) {
navigate(DeviceDetailRoutes.temperatureHumidityRoute(profileId, deviceName))
}
fun NavGraphBuilder.deviceDetailGraph(
onBackClick: () -> Unit,
) {
composable(
route = DeviceDetailRoutes.SOCKET_PATTERN,
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("deviceName") { type = NavType.StringType },
),
) {
SocketRoute(onBackClick = onBackClick)
}
composable(
route = DeviceDetailRoutes.TH_PATTERN,
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("deviceName") { type = NavType.StringType },
),
) {
TemperatureHumidityRoute(onBackClick = onBackClick)
}
}
@@ -1,10 +1,21 @@
package com.flagship.abox.manager.ui.navigation
import android.net.Uri
object Routes {
const val STARTUP = "startup"
const val PROVIDER_SELECTION = "provider_selection"
const val LOGIN = "login"
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
const val AUTHENTICATED = "authenticated"
const val SETTINGS = "settings"
const val INFRARED_HUB = "infrared_hub/{profileId}/{deviceName}"
const val INFRARED_PANEL = "infrared_panel"
const val INFRARED_SETTINGS = "infrared_settings/{profileId}/{deviceName}"
fun infraredHub(profileId: String, deviceName: String): String =
"infrared_hub/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
fun infraredSettings(profileId: String, deviceName: String): String =
"infrared_settings/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
}
@@ -0,0 +1,595 @@
package com.flagship.abox.manager.ui.preferences
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.saveable.Saver
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxErrorState
import com.flagship.abox.manager.designsystem.LocalABoxShapes
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.designsystem.tokens.ABoxAccentPalette
import com.flagship.abox.manager.designsystem.ABoxBackLayer
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.InterfaceScale
import com.flagship.abox.manager.preferences.ColorStandard
import com.flagship.abox.manager.preferences.ColorStyle
import com.flagship.abox.manager.preferences.NavigationPresentation
private enum class SettingsDestination { HOME, THEME }
@Stable
class SettingsExperienceState(initialDestination: String = SettingsDestination.HOME.name) {
var destination: String by mutableStateOf(initialDestination)
var themeBackReturnsHome: Boolean by mutableStateOf(true)
val isHome: Boolean get() = destination == SettingsDestination.HOME.name
fun showHome() {
destination = SettingsDestination.HOME.name
}
fun showTheme(returnToHome: Boolean = true) {
themeBackReturnsHome = returnToHome
destination = SettingsDestination.THEME.name
}
companion object {
val Saver: Saver<SettingsExperienceState, String> = Saver(
save = { "${it.destination}|${it.themeBackReturnsHome}" },
restore = { saved ->
val parts = saved.split('|', limit = 2)
SettingsExperienceState(parts.first()).apply {
themeBackReturnsHome = parts.getOrNull(1)?.toBooleanStrictOrNull() ?: true
}
},
)
}
}
@Composable
fun SettingsRoute(
experienceState: SettingsExperienceState = SettingsExperienceState(),
onExit: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsExperience(state, experienceState, viewModel::onAction, onExit)
}
@Composable
fun SettingsExperience(
state: SettingsUiState,
experienceState: SettingsExperienceState,
onAction: (SettingsUiAction) -> Unit,
onExit: () -> Unit,
) {
val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) }
.getOrDefault(SettingsDestination.HOME)
Box(Modifier.fillMaxSize()) {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
SettingsHomeScreen(
state = state,
onOpenTheme = { experienceState.showTheme(returnToHome = true) },
onBack = onExit,
onAction = onAction,
)
}
if (destination == SettingsDestination.THEME) {
val leaveTheme = {
if (experienceState.themeBackReturnsHome) experienceState.showHome() else onExit()
}
ABoxBackLayer(onBack = leaveTheme) {
ABoxSurface(modifier = Modifier.fillMaxSize()) {
ThemeSettingsScreen(
state = state,
onBack = leaveTheme,
onAction = onAction,
)
}
}
}
}
}
@Composable
fun SettingsScreen(
state: SettingsUiState,
onAction: (SettingsUiAction) -> Unit,
modifier: Modifier = Modifier,
) = SettingsHomeScreen(state, onOpenTheme = {}, onBack = {}, onAction = onAction, modifier = modifier)
@Composable
private fun SettingsHomeScreen(
state: SettingsUiState,
onOpenTheme: () -> Unit,
onBack: () -> Unit,
onAction: (SettingsUiAction) -> Unit,
modifier: Modifier = Modifier,
) {
var styleMenuExpanded by remember { mutableStateOf(false) }
SettingsPage(modifier = modifier, title = stringResource(R.string.settings_title), onBack = onBack) {
SettingsGroup {
Box(Modifier.fillMaxWidth()) {
SettingsLinkRow(
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_style_entry),
description = stringResource(R.string.settings_renderer_description),
value = rendererLabel(state.preferences.renderer),
modifier = Modifier.testTag("settings-style-entry"),
) { styleMenuExpanded = true }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
DropdownMenu(
expanded = styleMenuExpanded,
onDismissRequest = { styleMenuExpanded = false },
modifier = Modifier.testTag("settings-style-menu"),
) {
DesignRenderer.entries.forEach { renderer ->
DropdownMenuItem(
text = { Text(rendererLabel(renderer)) },
trailingIcon = {
if (renderer == state.preferences.renderer) Icon(Icons.Outlined.Check, null)
},
onClick = {
styleMenuExpanded = false
onAction(SettingsUiAction.SetRenderer(renderer))
},
)
}
}
}
}
GroupDivider()
SettingsLinkRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_theme_entry),
description = stringResource(R.string.settings_theme_entry_description),
value = null,
modifier = Modifier.testTag("settings-theme-entry"),
onClick = onOpenTheme,
)
}
SettingsGroup {
SettingsSwitchRow(
icon = ABoxIconKey.FEEDBACK,
title = stringResource(R.string.settings_haptics),
description = stringResource(R.string.settings_haptics_description),
checked = state.preferences.hapticsEnabled,
tag = "settings-haptics",
) { onAction(SettingsUiAction.SetHaptics(it)) }
}
SettingsGroup {
SettingsInfoRow(
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_accessibility_card_title),
description = stringResource(R.string.settings_accessibility_description),
)
}
SettingsError(state)
}
}
@Composable
private fun ThemeSettingsScreen(
state: SettingsUiState,
onBack: () -> Unit,
onAction: (SettingsUiAction) -> Unit,
) {
SettingsPage(
title = stringResource(R.string.settings_theme_detail_title),
onBack = onBack,
modifier = Modifier.testTag("settings-theme-detail"),
) {
ThemePreview(state)
when (state.preferences.renderer) {
DesignRenderer.MATERIAL3 -> MaterialThemeOptions(state, onAction)
DesignRenderer.MIUIX -> MiuixThemeOptions(state, onAction)
}
SettingsError(state)
}
}
@Composable
private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
SettingsGroup(modifier = Modifier.testTag("settings-material-colors")) {
SettingsSwitchRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_dynamic_color),
description = stringResource(R.string.settings_dynamic_color_description),
checked = state.preferences.dynamicColorEnabled,
tag = "settings-dynamic-color",
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
GroupDivider()
AccentSelector(state, onAction)
}
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
EnumSettingRow(
modifier = Modifier.testTag("settings-color-style"),
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_color_style),
value = colorStyleLabel(state.preferences.colorStyle),
values = ColorStyle.entries,
label = { colorStyleLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) },
enabled = manualColorsEnabled,
)
EnumSettingRow(
modifier = Modifier.testTag("settings-color-standard"),
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_color_standard),
value = colorStandardLabel(state.preferences.colorStandard),
values = ColorStandard.entries,
label = { colorStandardLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) },
enabled = manualColorsEnabled,
)
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@Composable
private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val renderEffectsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
val floatingEnabled = state.preferences.navigationPresentation == NavigationPresentation.FLOATING
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
SettingsGroup(modifier = Modifier.testTag("settings-miuix-accent")) {
SettingsSwitchRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_dynamic_color),
description = stringResource(R.string.settings_dynamic_color_description),
checked = state.preferences.dynamicColorEnabled,
tag = "settings-dynamic-color",
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
GroupDivider()
AccentSelector(state, onAction)
}
SettingsGroup(modifier = Modifier.testTag("settings-blur")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_blur),
description = stringResource(R.string.settings_blur_description),
checked = state.preferences.blurEnabled,
tag = "settings-blur-switch",
enabled = renderEffectsSupported,
unavailableDescription = if (renderEffectsSupported) null else stringResource(R.string.settings_requires_android_13),
) { onAction(SettingsUiAction.SetBlur(it)) }
}
SettingsGroup(modifier = Modifier.testTag("settings-floating-navigation")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_navigation_floating),
description = stringResource(R.string.settings_navigation_floating_description),
checked = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
tag = "settings-floating-navigation-switch",
) { onAction(SettingsUiAction.SetNavigation(if (it) NavigationPresentation.FLOATING else NavigationPresentation.STANDARD)) }
}
SettingsGroup(modifier = Modifier.testTag("settings-liquid-glass")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_liquid_glass),
description = stringResource(R.string.settings_liquid_glass_description),
checked = state.preferences.liquidGlassEnabled,
tag = "settings-liquid-glass-switch",
enabled = renderEffectsSupported && floatingEnabled,
unavailableDescription = when {
!floatingEnabled -> stringResource(R.string.settings_requires_floating_navigation)
!renderEffectsSupported -> stringResource(R.string.settings_requires_android_13)
else -> null
},
) { onAction(SettingsUiAction.SetLiquidGlass(it)) }
}
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@Composable
private fun SettingsPage(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
Row(
modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.action_back)) }
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}
@Composable
private fun SettingsGroup(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
color = MaterialTheme.colorScheme.surfaceContainer,
content = { Column(content = content) },
)
}
@Composable
private fun ThemePreview(state: SettingsUiState) {
Surface(
modifier = Modifier.fillMaxWidth().height(190.dp),
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.width(184.dp).height(142.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(22.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
Spacer(Modifier.width(8.dp))
Box(Modifier.height(10.dp).weight(1f).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape))
}
Box(Modifier.fillMaxWidth().height(58.dp).background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp)))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(3) { Box(Modifier.weight(1f).height(18.dp).background(if (it == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, CircleShape)) }
}
}
}
Text(
text = "${rendererLabel(state.preferences.renderer)} · ${state.preferences.interfaceScale.percent}%",
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun RendererSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
RendererOption(DesignRenderer.MATERIAL3, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-primary"), onAction)
RendererOption(DesignRenderer.MIUIX, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-secondary"), onAction)
}
}
@Composable
private fun RendererOption(value: DesignRenderer, selected: DesignRenderer, modifier: Modifier, onAction: (SettingsUiAction) -> Unit) {
val active = value == selected
Surface(
modifier = modifier.clickable { onAction(SettingsUiAction.SetRenderer(value)) },
shape = RoundedCornerShape(18.dp),
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
border = if (active) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Text(rendererLabel(value), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
if (active) Icon(Icons.Outlined.Check, null, tint = MaterialTheme.colorScheme.primary)
}
}
}
@Composable
private fun ThemeModeSelector(
state: SettingsUiState,
onAction: (SettingsUiAction) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
AppThemeMode.entries.forEach { mode ->
val selected = state.preferences.themeMode == mode
Surface(
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetThemeMode(mode)) },
shape = RoundedCornerShape(14.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
) {
Text(themeLabel(mode), Modifier.padding(vertical = 14.dp), textAlign = TextAlign.Center, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
}
@Composable
private fun <T> EnumSettingRow(
modifier: Modifier,
icon: ABoxIconKey,
title: String,
value: String,
values: List<T>,
label: @Composable (T) -> String,
onSelected: (T) -> Unit,
enabled: Boolean = true,
) {
var expanded by remember { mutableStateOf(false) }
SettingsGroup(modifier = modifier) {
Box(Modifier.fillMaxWidth().alpha(if (enabled) 1f else 0.42f)) {
SettingsLinkRow(
icon = icon,
title = title,
description = stringResource(R.string.settings_tap_to_choose),
value = value,
enabled = enabled,
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
values.forEach { option ->
val optionLabel = label(option)
DropdownMenuItem(
text = { Text(optionLabel) },
trailingIcon = { if (optionLabel == value) Icon(Icons.Outlined.Check, null) },
onClick = {
expanded = false
onSelected(option)
},
)
}
}
}
}
}
@Composable
private fun AccentSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val dark = when (state.preferences.themeMode) {
AppThemeMode.DARK -> true
AppThemeMode.LIGHT -> false
AppThemeMode.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
}
val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.settings_accent_label), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth().alpha(if (manualColorsEnabled) 1f else 0.42f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
AccentPreference.entries.forEach { accent ->
val color = ABoxAccentPalette.preview(
accent = accent,
style = state.preferences.colorStyle,
standard = state.preferences.colorStandard,
dark = dark,
)
Box(
modifier = Modifier.size(44.dp).background(color, CircleShape)
.then(if (state.preferences.accentPreference == accent) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
.clickable(enabled = manualColorsEnabled) { onAction(SettingsUiAction.SetAccent(accent)) },
contentAlignment = Alignment.Center,
) {
if (state.preferences.accentPreference == accent) Icon(Icons.Outlined.Check, null, tint = Color.White)
}
}
}
}
}
@Composable
private fun ScaleSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
SettingsRowHeader(ABoxIconKey.SCALE, stringResource(R.string.settings_scale), stringResource(R.string.settings_scale_description))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
InterfaceScale.entries.forEach { scale ->
val selected = state.preferences.interfaceScale == scale
Surface(
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetInterfaceScale(scale)) },
shape = RoundedCornerShape(14.dp),
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
) { Text("${scale.percent}%", Modifier.padding(vertical = 12.dp), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) }
}
}
}
}
@Composable
private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
Row(modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
SettingsRowHeader(icon, title, description, Modifier.weight(1f))
if (value != null) Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Outlined.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun SettingsSwitchRow(
icon: ABoxIconKey,
title: String,
description: String,
checked: Boolean,
tag: String,
enabled: Boolean = true,
unavailableDescription: String? = null,
onChange: (Boolean) -> Unit,
) {
Row(
Modifier.fillMaxWidth().clickable(enabled = enabled) { onChange(!checked) }.padding(14.dp).alpha(if (enabled) 1f else 0.5f),
verticalAlignment = Alignment.CenterVertically,
) {
SettingsRowHeader(icon, title, unavailableDescription ?: description, Modifier.weight(1f))
Switch(checked, onCheckedChange = onChange, enabled = enabled, modifier = Modifier.testTag(tag))
}
}
@Composable
private fun SettingsInfoRow(icon: ABoxIconKey, title: String, description: String) {
SettingsRowHeader(icon, title, description, Modifier.padding(16.dp))
}
@Composable
private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: String, modifier: Modifier = Modifier) {
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ABoxIcon(icon, title, Modifier.size(28.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable private fun GroupDivider() = Box(Modifier.fillMaxWidth().padding(start = 58.dp).height(1.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)))
@Composable private fun SettingsError(state: SettingsUiState) { state.error?.let { ABoxErrorState(stringResource(R.string.settings_error_save), it.asString()) } }
@Composable private fun rendererLabel(renderer: DesignRenderer) = stringResource(if (renderer == DesignRenderer.MATERIAL3) R.string.settings_renderer_primary else R.string.settings_renderer_secondary)
@Composable private fun themeLabel(mode: AppThemeMode) = stringResource(when (mode) { AppThemeMode.SYSTEM -> R.string.settings_theme_system; AppThemeMode.LIGHT -> R.string.settings_theme_light; AppThemeMode.DARK -> R.string.settings_theme_dark })
@Composable private fun colorStyleLabel(value: ColorStyle) = stringResource(when (value) { ColorStyle.TONAL_SPOT -> R.string.settings_color_style_tonal; ColorStyle.EXPRESSIVE -> R.string.settings_color_style_expressive; ColorStyle.FIDELITY -> R.string.settings_color_style_fidelity })
@Composable private fun colorStandardLabel(value: ColorStandard) = stringResource(when (value) { ColorStandard.SPEC_2021 -> R.string.settings_color_standard_2021; ColorStandard.SPEC_2025 -> R.string.settings_color_standard_2025 })
@@ -0,0 +1,156 @@
package com.flagship.abox.manager.ui.preferences
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.R
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.InterfaceScale
import com.flagship.abox.manager.preferences.ColorStandard
import com.flagship.abox.manager.preferences.ColorStyle
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.PreferenceField
import com.flagship.abox.manager.preferences.UiPreferences
import com.flagship.abox.manager.preferences.UiPreferencesRepository
import com.flagship.abox.manager.preferences.UiPreferencesWriteResult
import com.flagship.abox.manager.ui.common.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: UiPreferencesRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private val observeJob: Job = viewModelScope.launch {
repository.observe().collect { preferences ->
_uiState.value = _uiState.value.copy(
preferences = preferences,
loaded = true,
)
}
}
fun onAction(action: SettingsUiAction) {
when (action) {
is SettingsUiAction.SetRenderer -> update(
field = PreferenceField.RENDERER,
transform = { it.copy(renderer = action.value) },
)
is SettingsUiAction.SetThemeMode -> update(
field = PreferenceField.THEME_MODE,
transform = { it.copy(themeMode = action.value) },
)
is SettingsUiAction.SetDynamicColor -> update(
field = PreferenceField.DYNAMIC_COLOR,
transform = { it.copy(dynamicColorEnabled = action.value) },
)
is SettingsUiAction.SetBlur -> update(
field = PreferenceField.BLUR,
transform = { it.copy(blurEnabled = action.value) },
)
is SettingsUiAction.SetNavigation -> update(
field = PreferenceField.NAVIGATION,
transform = { it.copy(navigationPresentation = action.value) },
)
is SettingsUiAction.SetHaptics -> update(
field = PreferenceField.HAPTICS,
transform = { it.copy(hapticsEnabled = action.value) },
)
is SettingsUiAction.SetInterfaceScale -> update(
field = PreferenceField.INTERFACE_SCALE,
transform = { it.copy(interfaceScale = action.value) },
)
is SettingsUiAction.SetAccent -> update(
field = PreferenceField.ACCENT,
transform = { it.copy(accentPreference = action.value) },
)
is SettingsUiAction.SetColorStyle -> update(
field = PreferenceField.COLOR_STYLE,
transform = { it.copy(colorStyle = action.value) },
)
is SettingsUiAction.SetColorStandard -> update(
field = PreferenceField.COLOR_STANDARD,
transform = { it.copy(colorStandard = action.value) },
)
is SettingsUiAction.SetLiquidGlass -> update(
field = PreferenceField.LIQUID_GLASS,
transform = { it.copy(liquidGlassEnabled = action.value) },
)
is SettingsUiAction.SetPredictiveBack -> update(
field = PreferenceField.PREDICTIVE_BACK,
transform = { it.copy(predictiveBackEnabled = action.value) },
)
SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null)
}
}
private fun update(
field: PreferenceField,
transform: (UiPreferences) -> UiPreferences,
) {
if (field in _uiState.value.saving) return
_uiState.value = _uiState.value.copy(
saving = _uiState.value.saving + field,
error = null,
)
viewModelScope.launch {
try {
when (repository.update(transform)) {
UiPreferencesWriteResult.Success -> Unit
UiPreferencesWriteResult.Failure -> showWriteFailure(field)
}
} catch (cause: CancellationException) {
throw cause
} catch (_: Throwable) {
showWriteFailure(field)
} finally {
_uiState.value = _uiState.value.copy(saving = _uiState.value.saving - field)
}
}
}
private fun showWriteFailure(field: PreferenceField) {
_uiState.value = _uiState.value.copy(
error = UiText.StringResource(R.string.settings_error_save),
saving = _uiState.value.saving - field,
)
}
override fun onCleared() {
observeJob.cancel()
super.onCleared()
}
}
data class SettingsUiState(
val preferences: UiPreferences = UiPreferences(),
val loaded: Boolean = false,
val saving: Set<PreferenceField> = emptySet(),
val error: UiText? = null,
)
sealed interface SettingsUiAction {
data class SetRenderer(val value: DesignRenderer) : SettingsUiAction
data class SetThemeMode(val value: AppThemeMode) : SettingsUiAction
data class SetDynamicColor(val value: Boolean) : SettingsUiAction
data class SetBlur(val value: Boolean) : SettingsUiAction
data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction
data class SetHaptics(val value: Boolean) : SettingsUiAction
data class SetInterfaceScale(val value: InterfaceScale) : SettingsUiAction
data class SetAccent(val value: AccentPreference) : SettingsUiAction
data class SetColorStyle(val value: ColorStyle) : SettingsUiAction
data class SetColorStandard(val value: ColorStandard) : SettingsUiAction
data class SetLiquidGlass(val value: Boolean) : SettingsUiAction
data class SetPredictiveBack(val value: Boolean) : SettingsUiAction
data object DismissError : SettingsUiAction
}
@@ -0,0 +1,94 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneExecutionId
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutionSource
import com.flagship.abox.manager.domain.SceneExecutionUpdate
import com.flagship.abox.manager.domain.SceneExecutor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
class RecordingSceneExecutor(
private val delegate: SceneExecutor,
private val writer: ActivityEventWriter,
) : SceneExecutor {
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val result = delegate.execute(scene)
if (scene.enableLogging) {
when (result) {
is DomainResult.Success -> {
result.value.results.forEach { recordStep(scene, it, ActivitySource.USER) }
recordScene(scene, if (result.value.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED, ActivitySource.USER)
}
is DomainResult.Failure -> recordScene(scene, ActivityOutcome.FAILED, ActivitySource.USER, result.error.externalCode)
}
}
return result
}
override fun execute(
scene: Scene,
executionId: SceneExecutionId,
source: SceneExecutionSource,
): Flow<SceneExecutionUpdate> {
val activitySource = if (source == SceneExecutionSource.AUTOMATION) ActivitySource.AUTOMATION else ActivitySource.USER
return delegate.execute(scene, executionId, source).onEach { update ->
if (!scene.enableLogging) return@onEach
when (update) {
is SceneExecutionUpdate.StepFinished -> recordStep(scene, update.result, activitySource)
is SceneExecutionUpdate.Completed -> recordScene(
scene,
if (update.result.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED,
activitySource,
)
is SceneExecutionUpdate.Rejected -> recordScene(scene, ActivityOutcome.FAILED, activitySource, update.error.externalCode)
else -> Unit
}
}
}
private fun recordScene(scene: Scene, outcome: ActivityOutcome, source: ActivitySource, errorCode: String? = null) {
writer.record(
scene.profileId,
ActivityEventKind.SCENE_EXECUTION,
source,
outcome,
ActivityTarget(ActivityTargetType.SCENE, scene.id.value, scene.name),
errorCode,
)
}
private fun recordStep(scene: Scene, result: SceneActionExecutionResult, source: ActivitySource) {
writer.record(
scene.profileId,
ActivityEventKind.SCENE_ACTION,
source,
when (result.outcome) {
SceneActionOutcome.SUCCESS -> ActivityOutcome.SUCCEEDED
SceneActionOutcome.FAILURE -> ActivityOutcome.FAILED
SceneActionOutcome.SKIPPED -> ActivityOutcome.CANCELLED
},
result.action.activityTarget(),
result.error?.externalCode,
)
}
}
private fun SceneAction.activityTarget(): ActivityTarget = when (this) {
is SceneAction.SocketPower -> ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value)
is SceneAction.InfraredKey -> ActivityTarget(ActivityTargetType.INFRARED_KEY, key, deviceName.value)
is SceneAction.VirtualRemoteButton -> ActivityTarget(ActivityTargetType.REMOTE, remoteId, "$remoteDisplayName · $buttonDisplayName")
is SceneAction.Delay -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, "Delay ${durationSeconds}s")
is SceneAction.Notification -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, title)
}
File diff suppressed because it is too large Load Diff
@@ -2,81 +2,276 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionId
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
data class SceneEditorState(
val sceneId: SceneId? = null,
val type: AutomationType = AutomationType.MANUAL,
val name: String = "",
val description: String = "",
val enabled: Boolean = true,
val icon: SceneIcon = SceneIcon.HOME,
val colorHex: String = "#FF8A65",
val colorHex: String = "#3F51B5",
val triggers: List<SceneTrigger> = emptyList(),
val conditions: List<SceneCondition> = emptyList(),
val actions: List<SceneAction> = emptyList(),
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
val enableLogging: Boolean = true,
val availableRemotes: List<VirtualRemote> = emptyList(),
val availableDevices: List<DeviceDescriptor> = emptyList(),
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val isSaved: Boolean = false,
val errorMessage: String? = null,
) {
val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty()
val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
val isEditing: Boolean get() = sceneId != null
}
@HiltViewModel
class SceneEditorViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val profileStore: ProviderProfileStore,
private val deviceCatalog: DeviceCatalog,
private val automationScheduler: AutomationScheduler? = null,
) : ViewModel() {
private val _state = MutableStateFlow(SceneEditorState())
val state: StateFlow<SceneEditorState> = _state.asStateFlow()
fun initialize(sceneId: SceneId?) {
private var initializedSceneId: SceneId? = null
private var isInitialized: Boolean = false
init {
observeAvailableRemotes()
observeAvailableDevices()
}
private fun observeAvailableDevices() {
viewModelScope.launch {
profileStore.observeActiveProfile().collectLatest { profile ->
if (profile == null) {
_state.update { it.copy(availableDevices = emptyList()) }
} else {
deviceCatalog.observeDevices(profile.id).collectLatest { result ->
if (result is DomainResult.Success) {
_state.update { it.copy(availableDevices = result.value) }
}
}
}
}
}
}
private fun observeAvailableRemotes() {
viewModelScope.launch {
profileStore.observeActiveProfile().collectLatest { profile ->
if (profile != null) {
virtualRemoteRepository.observeRemotes(profile.id, DeviceName("红外控制器")).collectLatest { res ->
if (res is DomainResult.Success) {
_state.update { it.copy(availableRemotes = res.value) }
}
}
}
}
}
}
fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) {
if (isInitialized && initializedSceneId == sceneId) return
isInitialized = true
initializedSceneId = sceneId
if (sceneId == null) {
_state.value = SceneEditorState()
_state.update {
SceneEditorState(
type = initialType ?: AutomationType.MANUAL,
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
availableRemotes = it.availableRemotes,
isLoading = false,
)
}
return
}
_state.update {
it.copy(
sceneId = sceneId,
isLoading = true,
errorMessage = null,
)
}
viewModelScope.launch {
val profile = profileStore.activeProfile()
if (profile == null) {
_state.update { it.copy(errorMessage = "No active profile") }
_state.update { it.copy(isLoading = false, errorMessage = "No active profile") }
return@launch
}
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
if (scene != null) {
_state.value = SceneEditorState(
sceneId = scene.id,
name = scene.name,
icon = scene.icon,
colorHex = scene.color.hex,
actions = scene.actions,
)
_state.update {
it.copy(
sceneId = scene.id,
type = scene.type,
name = scene.name,
description = scene.description,
enabled = scene.enabled,
icon = scene.icon,
colorHex = scene.color.hex,
triggers = scene.triggers,
conditions = scene.conditions,
actions = scene.actions,
strategy = scene.strategy,
concurrencyMode = scene.concurrencyMode,
enableLogging = scene.enableLogging,
isLoading = false,
)
}
} else {
_state.update { it.copy(errorMessage = "Scene not found") }
_state.update { it.copy(isLoading = false, errorMessage = "Scene not found") }
}
}
}
fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) }
fun updateDescription(desc: String) = _state.update { it.copy(description = desc) }
fun updateType(type: AutomationType) = _state.update { it.copy(type = type) }
fun updateEnabled(enabled: Boolean) = _state.update { it.copy(enabled = enabled) }
fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) }
fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) }
fun updateStrategy(strategy: ExecutionStrategy) = _state.update { it.copy(strategy = strategy) }
fun updateConcurrencyMode(mode: ConcurrencyMode) = _state.update { it.copy(concurrencyMode = mode) }
fun updateLogging(enabled: Boolean) = _state.update { it.copy(enableLogging = enabled) }
// --- Triggers ---
fun addTimeTrigger(timeOfDay: String, daysOfWeek: Set<Int> = emptySet()) {
val trigger = SceneTrigger.Time(
id = SceneTriggerId(UUID.randomUUID().toString()),
timeOfDay = timeOfDay,
daysOfWeek = daysOfWeek,
label = "定时 $timeOfDay",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun addTemperatureTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val trigger = SceneTrigger.Temperature(
id = SceneTriggerId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdCelsius = threshold,
label = "$deviceName 温度 ${operator.symbol} $threshold°C",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun addHumidityTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val trigger = SceneTrigger.Humidity(
id = SceneTriggerId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdPercent = threshold,
label = "$deviceName 湿度 ${operator.symbol} $threshold%",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
val parsedState = runCatching { SocketPowerState.valueOf(expectedState.trim().uppercase()) }.getOrNull()
?: return
val trigger = SceneTrigger.DeviceState(
id = SceneTriggerId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
expectedState = parsedState,
label = "$deviceName 状态变为 ${parsedState.name}",
)
_state.update { it.copy(triggers = it.triggers + trigger) }
}
fun removeTrigger(triggerId: SceneTriggerId) =
_state.update { it.copy(triggers = it.triggers.filterNot { t -> t.id == triggerId }) }
// --- Conditions ---
fun addTemperatureCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val cond = SceneCondition.Temperature(
id = SceneConditionId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdCelsius = threshold,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun addHumidityCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) {
val cond = SceneCondition.Humidity(
id = SceneConditionId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
operator = operator,
thresholdPercent = threshold,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun addSocketPowerCondition(deviceName: String, powerState: SocketPowerState) {
val cond = SceneCondition.SocketPower(
id = SceneConditionId(UUID.randomUUID().toString()),
deviceName = DeviceName(deviceName),
expectedState = powerState,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun addTimeRangeCondition(startTime: String, endTime: String) {
val cond = SceneCondition.TimeRange(
id = SceneConditionId(UUID.randomUUID().toString()),
startTime = startTime,
endTime = endTime,
)
_state.update { it.copy(conditions = it.conditions + cond) }
}
fun removeCondition(conditionId: SceneConditionId) =
_state.update { it.copy(conditions = it.conditions.filterNot { c -> c.id == conditionId }) }
// --- Actions ---
fun addSocketAction(deviceName: String, powerState: SocketPowerState) {
if (deviceName.isBlank()) return
viewModelScope.launch {
@@ -91,6 +286,26 @@ class SceneEditorViewModel @Inject constructor(
}
}
fun addVirtualRemoteAction(
remoteId: String,
remoteDisplayName: String,
buttonId: String,
buttonDisplayName: String,
) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.VirtualRemoteButton(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
remoteId = remoteId,
remoteDisplayName = remoteDisplayName,
buttonId = buttonId,
buttonDisplayName = buttonDisplayName,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addInfraredAction(deviceName: String, key: String) {
if (deviceName.isBlank() || key.isBlank()) return
viewModelScope.launch {
@@ -105,6 +320,33 @@ class SceneEditorViewModel @Inject constructor(
}
}
fun addDelayAction(seconds: Int) {
if (seconds <= 0) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.Delay(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
durationSeconds = seconds,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun addNotificationAction(title: String, message: String) {
if (title.isBlank()) return
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val action = SceneAction.Notification(
id = SceneActionId(UUID.randomUUID().toString()),
profileId = profile.id,
title = title,
message = message,
)
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
}
}
fun removeAction(actionId: SceneActionId) =
_state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) }
@@ -115,6 +357,13 @@ class SceneEditorViewModel @Inject constructor(
_state.update { it.copy(actions = updated) }
}
suspend fun getKeyBindings(remoteId: VirtualRemoteId): List<RemoteKeyBinding> {
return when (val res = virtualRemoteRepository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> emptyList()
}
}
fun save() {
val state = _state.value
if (!state.isValid || state.isSaving) return
@@ -129,9 +378,17 @@ class SceneEditorViewModel @Inject constructor(
id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
profileId = profile.id,
name = state.name.trim(),
description = state.description.trim(),
type = state.type,
enabled = state.enabled,
icon = state.icon,
color = SceneColor(state.colorHex),
triggers = state.triggers,
conditions = state.conditions,
actions = state.actions,
strategy = state.strategy,
concurrencyMode = state.concurrencyMode,
enableLogging = state.enableLogging,
)
val result = if (state.isEditing) {
sceneRepository.updateScene(profile.id, scene)
@@ -140,7 +397,10 @@ class SceneEditorViewModel @Inject constructor(
}
_state.update { current ->
when (result) {
is DomainResult.Success -> current.copy(isSaving = false, isSaved = true)
is DomainResult.Success -> {
automationScheduler?.reconcile(profile.id)
current.copy(isSaving = false, isSaved = true)
}
is DomainResult.Failure -> current.copy(
isSaving = false,
errorMessage = result.error.detail ?: "Save failed",
@@ -151,3 +411,4 @@ class SceneEditorViewModel @Inject constructor(
}
}
@@ -1,28 +1,41 @@
package com.flagship.abox.manager.ui.scene
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.outlined.HourglassDisabled
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -30,8 +43,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -39,21 +52,25 @@ import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneId
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SceneExecutionScreen(
sceneId: com.flagship.abox.manager.domain.SceneId,
sceneId: SceneId,
onBack: () -> Unit,
viewModel: SceneExecutionViewModel = hiltViewModel(),
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(sceneId) { viewModel.initialize(sceneId) }
LaunchedEffect(sceneId) {
viewModel.initialize(sceneId)
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.scene_execution_title)) },
title = { Text(stringResource(R.string.scene_execution_title), fontWeight = FontWeight.SemiBold) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back))
@@ -64,29 +81,28 @@ fun SceneExecutionScreen(
) { innerPadding ->
when (val state = uiState) {
SceneExecutionUiState.Loading -> CenteredLoading(innerPadding)
is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message)
is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message, onRetry = { viewModel.initialize(sceneId) })
is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute)
is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name)
is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack)
is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack, onRerun = viewModel::execute)
}
}
}
@Composable
private fun CenteredLoading(paddingValues: PaddingValues) {
Row(
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
@Composable
private fun CenteredMessage(paddingValues: PaddingValues, message: String) {
private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) {
Column(
modifier = Modifier
.fillMaxSize()
@@ -96,6 +112,12 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String) {
verticalArrangement = Arrangement.Center,
) {
Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(16.dp))
Button(onClick = onRetry) {
Icon(Icons.Filled.Refresh, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.action_retry))
}
}
}
@@ -113,10 +135,18 @@ private fun ReadyContent(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(sceneName, style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.size(24.dp))
Button(onClick = onExecute) {
Text(stringResource(R.string.scene_execution_run))
Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(8.dp))
Text(stringResource(R.string.scene_exec_ready_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Spacer(Modifier.height(24.dp))
Button(
onClick = onExecute,
modifier = Modifier.fillMaxWidth(0.6f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.scene_execution_run), fontWeight = FontWeight.SemiBold)
}
}
}
@@ -131,11 +161,12 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Spacer(Modifier.size(16.dp))
CircularProgressIndicator(modifier = Modifier.size(56.dp))
Spacer(Modifier.height(20.dp))
Text(
stringResource(R.string.scene_execution_running, sceneName),
style = MaterialTheme.typography.bodyLarge,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Medium,
)
}
}
@@ -143,8 +174,9 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
@Composable
private fun CompletedContent(
paddingValues: PaddingValues,
result: com.flagship.abox.manager.domain.SceneExecutionResult,
result: SceneExecutionResult,
onBack: () -> Unit,
onRerun: () -> Unit,
) {
LazyColumn(
modifier = Modifier
@@ -153,57 +185,137 @@ private fun CompletedContent(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
Text(
text = stringResource(
R.string.scene_execution_summary,
result.succeededCount,
result.failedCount,
result.totalActions,
item(key = "summary") {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.outlinedCardColors(
containerColor = if (result.allSucceeded) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} else {
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
},
),
style = MaterialTheme.typography.titleMedium,
)
border = BorderStroke(
1.dp,
if (result.allSucceeded) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
else MaterialTheme.colorScheme.error.copy(alpha = 0.4f),
),
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.scene_execution_done),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(
R.string.scene_execution_summary,
result.succeededCount,
result.failedCount,
result.totalActions,
),
style = MaterialTheme.typography.bodyMedium,
)
if (result.totalDurationMillis > 0) {
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.scene_execution_duration, result.totalDurationMillis),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
items(result.results, key = { it.action.id.value }) { entry ->
ActionResultRow(entry)
itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry ->
ActionResultRow(index + 1, entry)
}
item {
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.scene_execution_done))
item(key = "buttons") {
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(
onClick = onRerun,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp))
Text(stringResource(R.string.scene_exec_rerun))
}
Button(
onClick = onBack,
modifier = Modifier.weight(1f).height(48.dp),
shape = RoundedCornerShape(12.dp),
) {
Text(stringResource(R.string.action_back), fontWeight = FontWeight.SemiBold)
}
}
}
}
}
@Composable
private fun ActionResultRow(entry: SceneActionExecutionResult) {
Card(modifier = Modifier.fillMaxWidth()) {
private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) {
OutlinedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) {
Icons.Filled.Check
} else {
Icons.Filled.Close
Surface(
shape = CircleShape,
color = when (entry.outcome) {
SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer
SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer
SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant
},
contentDescription = null,
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
},
)
Spacer(Modifier.size(12.dp))
modifier = Modifier.size(32.dp),
) {
Box(contentAlignment = Alignment.Center) {
when (entry.outcome) {
SceneActionOutcome.SUCCESS -> Icon(
Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp),
)
SceneActionOutcome.FAILURE -> Icon(
Icons.Filled.Close,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp),
)
SceneActionOutcome.SKIPPED -> Icon(
Icons.Outlined.HourglassDisabled,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(16.dp),
)
}
}
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = entry.action.describe(),
text = stringResource(R.string.scene_step_format, stepNumber, entry.action.describe()),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
entry.error?.detail?.let { detail ->
Spacer(Modifier.height(2.dp))
Text(
text = detail,
style = MaterialTheme.typography.bodySmall,
@@ -211,11 +323,23 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) {
)
}
}
if (entry.executionDurationMillis > 0) {
Text(
text = "${entry.executionDurationMillis}ms",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun SceneAction.describe(): String = when (this) {
is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}"
is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key"
is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name)
is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName)
is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key)
is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds)
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
}
@@ -8,6 +8,9 @@ import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneExecutionId
import com.flagship.abox.manager.domain.SceneExecutionSource
import com.flagship.abox.manager.domain.SceneExecutionUpdate
import com.flagship.abox.manager.domain.SceneRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@@ -15,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.collect
sealed interface SceneExecutionUiState {
data object Loading : SceneExecutionUiState
@@ -34,7 +38,13 @@ class SceneExecutionViewModel @Inject constructor(
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
val uiState: StateFlow<SceneExecutionUiState> = _uiState.asStateFlow()
private var initializedSceneId: SceneId? = null
fun initialize(sceneId: SceneId) {
if (initializedSceneId == sceneId && _uiState.value !is SceneExecutionUiState.Loading) {
return
}
initializedSceneId = sceneId
_uiState.value = SceneExecutionUiState.Loading
viewModelScope.launch {
val profile = profileStore.activeProfile()
@@ -53,16 +63,31 @@ class SceneExecutionViewModel @Inject constructor(
fun execute() {
val current = _uiState.value
val scene = (current as? SceneExecutionUiState.Ready)?.scene
?: (current as? SceneExecutionUiState.Running)?.scene
?: return
val scene = when (current) {
is SceneExecutionUiState.Ready -> current.scene
is SceneExecutionUiState.Completed -> current.result.scene
else -> return
}
_uiState.value = SceneExecutionUiState.Running(scene)
viewModelScope.launch {
when (val result = sceneExecutor.execute(scene)) {
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
result.error.detail ?: "Execution failed",
)
val profile = profileStore.activeProfile()
if (profile == null || profile.id != scene.profileId) {
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
return@launch
}
sceneExecutor.execute(
scene = scene,
executionId = SceneExecutionId("manual-${scene.id.value}"),
source = SceneExecutionSource.USER,
).collect { update ->
when (update) {
is SceneExecutionUpdate.Completed -> _uiState.value = SceneExecutionUiState.Completed(update.result)
is SceneExecutionUpdate.Rejected -> _uiState.value = SceneExecutionUiState.Failed(
update.error.detail ?: "Execution failed",
)
is SceneExecutionUpdate.Started -> _uiState.value = SceneExecutionUiState.Running(update.scene)
else -> Unit
}
}
}
}
@@ -1,5 +1,7 @@
package com.flagship.abox.manager.ui.scene
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -10,73 +12,142 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SceneListScreen(
onAddScene: () -> Unit,
onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
viewModel: SceneListViewModel = hiltViewModel(),
) {
val scenes by viewModel.scenes.collectAsStateWithLifecycle()
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
var showFabMenu by remember { mutableStateOf(false) }
var fabExpanded by remember { mutableStateOf(true) }
val listState = rememberLazyListState()
LaunchedEffect(listState) {
var previousIndex = 0
var previousOffset = 0
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { (index, offset) ->
val movingUp = index > previousIndex || (index == previousIndex && offset > previousOffset)
val movingDown = index < previousIndex || (index == previousIndex && offset < previousOffset)
if (movingUp) fabExpanded = false
if (movingDown || (index == 0 && offset == 0)) fabExpanded = true
previousIndex = index
previousOffset = offset
}
}
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.userMessage.collectLatest { msg ->
val messageRes = if (msg == "executed") R.string.scene_toast_executed else R.string.scene_toast_failed
Toast.makeText(context, context.getString(messageRes), Toast.LENGTH_SHORT).show()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.scene_list_title)) },
)
},
containerColor = Color.Transparent,
floatingActionButton = {
ExtendedFloatingActionButton(
onClick = onAddScene,
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.scene_create_action)) },
onClick = { showFabMenu = true },
expanded = fabExpanded,
icon = {
Icon(
Icons.Filled.Add,
contentDescription = stringResource(R.string.scene_create_action),
)
},
text = { Text(stringResource(R.string.scene_create_action), fontWeight = FontWeight.Bold) },
shape = RoundedCornerShape(18.dp),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 6.dp,
pressedElevation = 10.dp,
focusedElevation = 8.dp,
hoveredElevation = 8.dp,
),
)
},
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
end = 16.dp,
top = innerPadding.calculateTopPadding(),
top = 12.dp,
bottom = innerPadding.calculateBottomPadding() + 80.dp,
),
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -86,15 +157,50 @@ fun SceneListScreen(
headerContent()
}
}
item(key = "tabs") {
SecondaryTabRow(
selectedTabIndex = selectedTab.ordinal,
containerColor = Color.Transparent,
divider = {},
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
) {
SceneFilterTab.entries.forEach { tab ->
Tab(
selected = selectedTab == tab,
onClick = { viewModel.selectTab(tab) },
text = {
Text(
text = when (tab) {
SceneFilterTab.ALL -> stringResource(R.string.scene_tab_all)
SceneFilterTab.MANUAL -> stringResource(R.string.scene_tab_manual)
SceneFilterTab.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
},
fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal,
)
},
)
}
}
}
if (scenes.isEmpty()) {
item(key = "empty") {
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
}
} else {
items(scenes, key = { it.id.value }) { scene ->
SceneRow(
SceneCard(
scene = scene,
onClick = { onExecuteScene(scene.id) },
onClick = {
if (scene.type == AutomationType.MANUAL) {
onExecuteScene(scene.id)
} else {
onEditScene(scene.id)
}
},
onQuickExecute = { viewModel.executeQuick(scene) },
onToggleEnabled = { viewModel.toggleEnabled(scene) },
onEdit = { onEditScene(scene.id) },
onDelete = { viewModel.markPendingDelete(scene.id) },
)
@@ -110,7 +216,7 @@ fun SceneListScreen(
text = { Text(stringResource(R.string.scene_delete_dialog_message)) },
confirmButton = {
TextButton(onClick = { viewModel.deleteScene(sceneId) }) {
Text(stringResource(R.string.scene_delete_confirm))
Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
@@ -120,76 +226,240 @@ fun SceneListScreen(
},
)
}
}
@Composable
private fun SceneRow(
scene: Scene,
onClick: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.clickable(onClick = onClick)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.background(
color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary,
shape = CircleShape,
),
)
Spacer(Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
if (showFabMenu) {
ModalBottomSheet(onDismissRequest = { showFabMenu = false }) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(stringResource(R.string.scene_create_sheet_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
text = scene.name,
style = MaterialTheme.typography.titleMedium,
)
Text(
text = stringResource(
R.string.scene_action_count,
scene.actions.size,
),
style = MaterialTheme.typography.bodySmall,
stringResource(R.string.scene_create_sheet_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
IconButton(onClick = onEdit) {
Icon(
Icons.Filled.Edit,
contentDescription = stringResource(R.string.scene_edit),
SceneCreationOption(
icon = ABoxIconKey.SCENES,
title = stringResource(R.string.scene_create_manual),
description = stringResource(R.string.scene_create_manual_description),
onClick = {
showFabMenu = false
onAddManual()
},
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.scene_delete),
SceneCreationOption(
icon = ABoxIconKey.PENDING,
title = stringResource(R.string.scene_create_automatic),
description = stringResource(R.string.scene_create_automatic_description),
onClick = {
showFabMenu = false
onAddAutomatic()
},
)
Spacer(Modifier.height(20.dp))
}
}
}
}
@Composable
private fun SceneCreationOption(
icon: ABoxIconKey,
title: String,
description: String,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
ABoxIcon(icon, title, modifier = Modifier.padding(10.dp).size(24.dp))
}
Column(Modifier.weight(1f).padding(start = 14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
private fun SceneCard(
scene: Scene,
onClick: () -> Unit,
onQuickExecute: () -> Unit,
onToggleEnabled: () -> Unit,
onEdit: () -> Unit,
onDelete: () -> Unit,
) {
val accentColor = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.outlinedCardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
) {
Column(
modifier = Modifier.padding(16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Box(
modifier = Modifier
.size(44.dp)
.background(
color = accentColor.copy(alpha = 0.12f),
shape = RoundedCornerShape(12.dp),
),
contentAlignment = Alignment.Center,
) {
ABoxIcon(
key = scene.icon.toABoxIconKey(),
contentDescription = scene.name,
modifier = Modifier.size(28.dp),
)
}
Spacer(Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(
text = scene.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
TypeBadge(type = scene.type)
}
if (scene.description.isNotBlank()) {
Spacer(Modifier.height(2.dp))
Text(
text = scene.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
if (scene.type == AutomationType.AUTOMATIC) {
Switch(
checked = scene.enabled,
onCheckedChange = { onToggleEnabled() },
)
}
}
Spacer(Modifier.height(12.dp))
// Trigger & Action summary and controls
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
// Summary text without nested heavy container shells
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
if (scene.type == AutomationType.AUTOMATIC) {
Text(
text = if (scene.triggers.isNotEmpty()) {
stringResource(R.string.scene_summary_triggers_and_actions, scene.triggers.size, scene.actions.size)
} else {
stringResource(R.string.scene_summary_actions_only, scene.actions.size)
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
text = stringResource(R.string.scene_summary_actions_only, scene.actions.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
FilledTonalButton(
onClick = onQuickExecute,
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
modifier = Modifier.height(32.dp),
shape = RoundedCornerShape(8.dp),
) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(4.dp))
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
}
Spacer(Modifier.width(4.dp))
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
}
}
@Composable
private fun TypeBadge(type: AutomationType) {
val label = when (type) {
AutomationType.MANUAL -> stringResource(R.string.scene_tab_manual)
AutomationType.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
}
Surface(
shape = RoundedCornerShape(4.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
) {
Text(
text = label,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun EmptySceneList(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.fillMaxSize()
.padding(32.dp),
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
ABoxIcon(ABoxIconKey.SCENES, contentDescription = "暂无情景", modifier = Modifier.size(48.dp))
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_title),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.size(12.dp))
Spacer(Modifier.size(8.dp))
Text(
text = stringResource(R.string.scene_empty_message),
style = MaterialTheme.typography.bodyMedium,
@@ -199,8 +469,22 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
}
}
internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? {
fun SceneIcon.toABoxIconKey(): ABoxIconKey = when (this) {
SceneIcon.HOME -> ABoxIconKey.DEVICES
SceneIcon.OFFICE -> ABoxIconKey.ACTIVITY
SceneIcon.NIGHT -> ABoxIconKey.PENDING
SceneIcon.MOVIE -> ABoxIconKey.SCENES
SceneIcon.AWAY -> ABoxIconKey.OFFLINE
SceneIcon.READING -> ABoxIconKey.DEVICES
SceneIcon.DINING -> ABoxIconKey.DEVICES
SceneIcon.GAMING -> ABoxIconKey.SCENES
SceneIcon.THERMOSTAT -> ABoxIconKey.TEMPERATURE
SceneIcon.LIGHT -> ABoxIconKey.POWER
SceneIcon.CLEAN -> ABoxIconKey.REFRESH
SceneIcon.CUSTOM -> ABoxIconKey.UNKNOWN_DEVICE
}
internal fun SceneColor.toColorOrNull(): Color? {
val cleanHex = hex.removePrefix("#")
return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
}
@@ -2,32 +2,50 @@ package com.flagship.abox.manager.ui.scene
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
enum class SceneFilterTab {
ALL,
MANUAL,
AUTOMATIC,
}
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class SceneListViewModel @Inject constructor(
private val sceneRepository: SceneRepository,
private val sceneExecutor: SceneExecutor,
private val profileStore: ProviderProfileStore,
private val automationScheduler: AutomationScheduler? = null,
) : ViewModel() {
val scenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
val selectedTab: StateFlow<SceneFilterTab> = _selectedTab.asStateFlow()
private val allScenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
.flatMapLatest { profile ->
if (profile == null) {
flowOf(DomainResult.Success(emptyList()))
@@ -42,13 +60,55 @@ class SceneListViewModel @Inject constructor(
initialValue = emptyList(),
)
val scenes: StateFlow<List<Scene>> = combine(allScenes, _selectedTab) { list, tab ->
when (tab) {
SceneFilterTab.ALL -> list
SceneFilterTab.MANUAL -> list.filter { it.type == AutomationType.MANUAL }
SceneFilterTab.AUTOMATIC -> list.filter { it.type == AutomationType.AUTOMATIC }
}
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList(),
)
private val _pendingDelete = MutableStateFlow<SceneId?>(null)
val pendingDelete: StateFlow<SceneId?> = _pendingDelete.asStateFlow()
private val _userMessage = MutableSharedFlow<String>()
val userMessage: SharedFlow<String> = _userMessage.asSharedFlow()
fun selectTab(tab: SceneFilterTab) {
_selectedTab.value = tab
}
fun toggleEnabled(scene: Scene) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
val updated = scene.copy(enabled = !scene.enabled)
if (sceneRepository.updateScene(profile.id, updated) is DomainResult.Success) {
automationScheduler?.reconcile(profile.id)
}
}
}
fun executeQuick(scene: Scene) {
viewModelScope.launch {
when (val result = sceneExecutor.execute(scene)) {
is DomainResult.Success -> {
_userMessage.emit(if (result.value.failedCount == 0) "executed" else "failed")
}
is DomainResult.Failure -> _userMessage.emit("failed")
}
}
}
fun deleteScene(sceneId: SceneId) {
viewModelScope.launch {
val profile = profileStore.activeProfile() ?: return@launch
sceneRepository.deleteScene(profile.id, sceneId)
if (sceneRepository.deleteScene(profile.id, sceneId) is DomainResult.Success) {
automationScheduler?.reconcile(profile.id)
}
_pendingDelete.value = null
}
}
@@ -62,3 +122,4 @@ class SceneListViewModel @Inject constructor(
}
}
@@ -9,15 +9,17 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.SceneId
/**
* Scene navigation routes. [SceneRoutes.LIST] is the entry point; the editor and execution routes
* carry the scene id as a path segment so deep linking works without manual argument parsing.
* Scene navigation routes.
*/
object SceneRoutes {
const val LIST = "scene/list"
const val EDITOR = "scene/editor"
const val EDITOR_MANUAL = "scene/editor/manual"
const val EDITOR_AUTOMATIC = "scene/editor/automatic"
const val EDITOR_WITH_ID = "scene/editor/{sceneId}"
const val EXECUTION = "scene/execution/{sceneId}"
@@ -39,7 +41,8 @@ fun SceneNavHost(
modifier = modifier,
) {
sceneListDestination(
onAddScene = { navController.navigate(SceneRoutes.EDITOR) },
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
@@ -56,14 +59,16 @@ fun SceneNavHost(
}
fun NavGraphBuilder.sceneListDestination(
onAddScene: () -> Unit,
onAddManual: () -> Unit,
onAddAutomatic: () -> Unit,
onEditScene: (SceneId) -> Unit,
onExecuteScene: (SceneId) -> Unit,
headerContent: @Composable (() -> Unit)? = null,
) {
composable(SceneRoutes.LIST) {
SceneListScreen(
onAddScene = onAddScene,
onAddManual = onAddManual,
onAddAutomatic = onAddAutomatic,
onEditScene = onEditScene,
onExecuteScene = onExecuteScene,
headerContent = headerContent,
@@ -75,10 +80,14 @@ fun NavGraphBuilder.sceneEditorDestination(
onBack: () -> Unit,
onSaved: () -> Unit,
) {
composable(
route = SceneRoutes.EDITOR,
) {
SceneEditorScreen(sceneId = null, onBack = onBack, onSaved = onSaved)
composable(SceneRoutes.EDITOR) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
}
composable(SceneRoutes.EDITOR_MANUAL) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
}
composable(SceneRoutes.EDITOR_AUTOMATIC) {
SceneEditorScreen(sceneId = null, initialType = AutomationType.AUTOMATIC, onBack = onBack, onSaved = onSaved)
}
composable(
route = SceneRoutes.EDITOR_WITH_ID,
@@ -87,6 +96,7 @@ fun NavGraphBuilder.sceneEditorDestination(
val id = backStackEntry.arguments?.getString("sceneId")
SceneEditorScreen(
sceneId = id?.let { SceneId(it) },
initialType = null,
onBack = onBack,
onSaved = onSaved,
)
@@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.ui.common.DomainErrorMapper
import com.flagship.abox.manager.ui.common.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor(
private val _uiState = MutableStateFlow(SessionActionsUiState())
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
fun onAction(action: SessionUiAction) {
when (action) {
SessionUiAction.EndSession -> runAction(coordinator::endActiveSession)
SessionUiAction.ForgetSavedCredentials -> runAction(coordinator::forgetSavedCredentials)
SessionUiAction.SwitchProvider -> runAction(coordinator::switchProvider)
SessionUiAction.ValidateConnection -> runValidation()
SessionUiAction.DismissError -> dismissError()
}
}
fun dispatch(action: SessionUiAction) = onAction(action)
fun logout() = runAction(coordinator::logout)
fun switchProvider() = runAction(coordinator::switchProvider)
fun switchProvider() = onAction(SessionUiAction.SwitchProvider)
fun dismissError() {
_uiState.value = _uiState.value.copy(errorMessage = null)
@@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor(
_uiState.value = SessionActionsUiState(inProgress = true)
val result = action()
_uiState.value = SessionActionsUiState(
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
)
}
}
private fun runValidation() {
if (_uiState.value.inProgress) return
viewModelScope.launch {
_uiState.value = SessionActionsUiState(inProgress = true)
val result = coordinator.validateActiveSession()
_uiState.value = SessionActionsUiState(
validity = (result as? DomainResult.Success)?.value,
errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
)
}
}
@@ -40,5 +67,14 @@ class SessionActionsViewModel @Inject constructor(
data class SessionActionsUiState(
val inProgress: Boolean = false,
val errorMessage: String? = null,
val validity: SessionValidity? = null,
val errorMessage: UiText? = null,
)
sealed interface SessionUiAction {
data object EndSession : SessionUiAction
data object ForgetSavedCredentials : SessionUiAction
data object SwitchProvider : SessionUiAction
data object ValidateConnection : SessionUiAction
data object DismissError : SessionUiAction
}
@@ -1,183 +0,0 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.SheetState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
/**
* Account and Provider management bottom sheet triggered by clicking the top bar user avatar.
* Allows viewing current session info and signing out.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountProviderBottomSheet(
userName: String,
providerName: String,
onDismissRequest: () -> Unit,
onLogout: () -> Unit,
actionInProgress: Boolean,
errorMessage: String?,
modifier: Modifier = Modifier,
sheetState: SheetState = rememberModalBottomSheetState(),
) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
modifier = modifier,
containerColor = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp)
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Header with large avatar & user info
ABoxUserAvatar(
name = userName,
onClick = {},
size = 56.dp,
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = userName.ifBlank { stringResource(R.string.account_sheet_title) },
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = stringResource(R.string.account_sheet_signed_in_as),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider(
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
// Provider Info Card
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.CloudDone,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(22.dp),
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.account_sheet_provider),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = providerName,
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
// Action button
ABoxButton(
onClick = onLogout,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = null,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.size(8.dp))
Text(stringResource(R.string.account_sheet_logout))
}
errorMessage?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
TextButton(
onClick = onDismissRequest,
enabled = !actionInProgress,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.account_sheet_close),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@@ -0,0 +1,185 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Logout
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountProviderDialog(
userName: String,
providerName: String,
onDismissRequest: () -> Unit,
onOpenSettings: () -> Unit,
onOpenAbout: () -> Unit,
onSwitchProvider: () -> Unit,
onEndSession: () -> Unit,
onForgetCredentials: () -> Unit,
modifier: Modifier = Modifier,
) {
var confirmLogout by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
modifier = modifier.testTag("account_dialog"),
dragHandle = {
Surface(
modifier = Modifier.padding(top = 10.dp, bottom = 6.dp).size(width = 40.dp, height = 4.dp),
shape = RoundedCornerShape(2.dp),
color = MaterialTheme.colorScheme.outlineVariant,
) {}
},
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ABoxUserAvatar(
name = userName,
onClick = {},
size = 76.dp,
contentDescription = stringResource(R.string.account_current),
)
Text(
text = stringResource(R.string.account_greeting, userName.ifBlank { stringResource(R.string.account_current) }),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Text(
text = userName.ifBlank { stringResource(R.string.account_current) },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(Icons.Outlined.CloudDone, contentDescription = stringResource(R.string.account_connection_method), modifier = Modifier.size(26.dp))
Column {
Text(stringResource(R.string.account_connection_method), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(providerName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
}
AccountSheetAction(
icon = Icons.Outlined.Palette,
label = stringResource(R.string.account_interface_settings),
description = stringResource(R.string.account_interface_settings_desc),
tag = "account_dialog_open_settings",
onClick = onOpenSettings,
)
AccountSheetAction(
icon = Icons.Outlined.Info,
label = stringResource(R.string.account_about),
description = stringResource(R.string.account_about_desc),
tag = "account_dialog_open_about",
onClick = onOpenAbout,
)
AccountSheetAction(
icon = Icons.AutoMirrored.Outlined.Logout,
label = stringResource(R.string.account_logout),
description = stringResource(R.string.account_logout_desc),
tag = "account_dialog_end_session",
dangerous = true,
onClick = { confirmLogout = true },
)
Spacer(Modifier.height(28.dp))
}
}
if (confirmLogout) {
AlertDialog(
onDismissRequest = { confirmLogout = false },
title = { Text(stringResource(R.string.account_logout)) },
text = { Text(stringResource(R.string.account_logout_confirm)) },
confirmButton = {
ABoxButton(
onClick = {
confirmLogout = false
onEndSession()
},
modifier = Modifier.testTag("account_dialog_confirm"),
) { Text(stringResource(R.string.account_logout)) }
},
dismissButton = { TextButton(onClick = { confirmLogout = false }) { Text(stringResource(R.string.action_cancel)) } },
)
}
}
@Composable
private fun AccountSheetAction(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
tag: String,
dangerous: Boolean = false,
onClick: () -> Unit,
) {
TextButton(
onClick = onClick,
modifier = Modifier.fillMaxWidth().testTag(tag),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(
icon,
contentDescription = label,
tint = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
)
Column(Modifier.weight(1f)) {
Text(label, color = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@@ -0,0 +1,79 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.saveable.Saver
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
/** Width buckets used by the authenticated shell. */
enum class ABoxWindowSizeClass {
COMPACT,
MEDIUM,
EXPANDED,
}
fun widthClass(widthDp: Int): ABoxWindowSizeClass = when {
widthDp < 600 -> ABoxWindowSizeClass.COMPACT
widthDp < 840 -> ABoxWindowSizeClass.MEDIUM
else -> ABoxWindowSizeClass.EXPANDED
}
enum class MainNavigationPresentation {
BOTTOM_NAVIGATION,
NAVIGATION_RAIL,
}
fun navigationPresentation(windowSizeClass: ABoxWindowSizeClass): MainNavigationPresentation = when (windowSizeClass) {
ABoxWindowSizeClass.COMPACT -> MainNavigationPresentation.BOTTOM_NAVIGATION
ABoxWindowSizeClass.MEDIUM,
ABoxWindowSizeClass.EXPANDED,
-> MainNavigationPresentation.NAVIGATION_RAIL
}
@Immutable
data class DeviceSelection(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val type: DeviceType,
) {
companion object {
/** Saver used by navigation-owned state so selection survives recreation and resize. */
val Saver: Saver<DeviceSelection?, Any> = Saver(
save = { selection ->
selection?.let {
listOf(it.profileId.value, it.deviceName.value, it.type.name)
}
},
restore = { saved ->
val values = saved as? List<*> ?: return@Saver null
if (values.size != 3) return@Saver null
val profileId = values[0] as? String ?: return@Saver null
val deviceName = values[1] as? String ?: return@Saver null
val type = (values[2] as? String)?.let { value ->
runCatching { DeviceType.valueOf(value) }.getOrNull()
} ?: return@Saver null
DeviceSelection(
profileId = ProviderProfileId(profileId),
deviceName = DeviceName(deviceName),
type = type,
)
},
)
}
}
enum class DetailPresentation {
FULL_SCREEN,
PANE,
}
fun detailPresentation(
windowSizeClass: ABoxWindowSizeClass,
selection: DeviceSelection,
): DetailPresentation = when {
windowSizeClass == ABoxWindowSizeClass.EXPANDED && selection.type != DeviceType.INFRARED -> {
DetailPresentation.PANE
}
else -> DetailPresentation.FULL_SCREEN
}
@@ -1,186 +1,253 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
import com.flagship.abox.manager.designsystem.ABoxTopPillBar
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
import com.flagship.abox.manager.designsystem.LocalABoxNavigationPresentation
import com.flagship.abox.manager.designsystem.LocalABoxRenderer
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.NavigationPresentation
/**
* Main authenticated application shell.
*
* Provides:
* 1. A floating/pinned pill top app bar with provider context, '+' action, and avatar account sheet.
* 2. Material 3 bottom navigation bar with pill indicator for Devices, Scenes, and Activity tabs.
* 3. Clear composable slot boundaries for team member integrations.
*/
@OptIn(ExperimentalMaterial3Api::class)
/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
@Composable
fun MainAppShell(
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
userName: String,
providerName: String,
onLogout: () -> Unit,
modifier: Modifier = Modifier,
initialTab: MainTab = MainTab.DEVICES,
onTabSelected: (MainTab) -> Unit = {},
onAddClick: (() -> Unit)? = null,
actionInProgress: Boolean = false,
errorMessage: String? = null,
devicesSlot: @Composable () -> Unit = { DefaultDevicesSlot() },
scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
onTabClick: (MainTab) -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
showNavigationChrome: Boolean = true,
content: @Composable (PaddingValues) -> Unit,
) {
var activeTab by rememberSaveable { mutableStateOf(initialTab) }
var showAccountSheet by remember { mutableStateOf(false) }
val tabStateHolder = rememberSaveableStateHolder()
val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
val renderer = LocalABoxRenderer.current
val floating = renderer == DesignRenderer.MIUIX &&
LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail
Scaffold(
modifier = modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(),
topBar = {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
ABoxTopPillBar(
title = stringResource(R.string.top_bar_prompt_format, providerName),
if (showNavigationChrome) {
ProviderTopBar(
currentTab = currentTab,
providerName = providerName,
userName = userName,
onAvatarClick = { showAccountSheet = true },
onAddClick = onAddClick,
addActionContentDescription = stringResource(R.string.action_add),
avatarContentDescription = stringResource(R.string.action_account),
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
)
}
},
bottomBar = {
ABoxNavigationBar {
MainTab.values().forEach { tab ->
val isSelected = (tab == activeTab)
ABoxNavigationBarItem(
selected = isSelected,
onClick = {
activeTab = tab
onTabSelected(tab)
},
icon = {
Icon(
imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes),
)
},
label = {
Text(text = stringResource(tab.labelRes))
},
)
}
if (showNavigationChrome && !useRail) {
MainBottomBar(currentTab, onTabClick, renderer, floating)
}
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
Row(
modifier = Modifier.fillMaxSize(),
) {
Crossfade(
targetState = activeTab,
label = "MainTabCrossfade",
) { tab ->
tabStateHolder.SaveableStateProvider(tab) {
when (tab) {
MainTab.DEVICES -> devicesSlot()
MainTab.SCENES -> scenesSlot()
MainTab.ACTIVITY -> activitySlot()
}
}
if (showNavigationChrome && useRail) {
MainNavigationRail(
currentTab = currentTab,
onTabClick = onTabClick,
)
}
Box(
modifier = Modifier.weight(1f).fillMaxHeight(),
) {
content(innerPadding)
}
}
}
if (showAccountSheet) {
AccountProviderBottomSheet(
userName = userName,
providerName = providerName,
onDismissRequest = { showAccountSheet = false },
onLogout = onLogout,
actionInProgress = actionInProgress,
errorMessage = errorMessage,
)
}
}
@Composable
private fun DefaultDevicesSlot() {
SlotPlaceholder(
title = stringResource(R.string.devices_placeholder_title),
description = stringResource(R.string.devices_placeholder_description),
)
}
@Composable
private fun DefaultScenesSlot() {
SlotPlaceholder(
title = stringResource(R.string.scenes_placeholder_title),
description = stringResource(R.string.scenes_placeholder_description),
)
}
@Composable
private fun DefaultActivitySlot() {
SlotPlaceholder(
title = stringResource(R.string.activity_placeholder_title),
description = stringResource(R.string.activity_placeholder_description),
)
}
@Composable
private fun SlotPlaceholder(
title: String,
description: String,
private fun MainBottomBar(
currentTab: MainTab,
onTabClick: (MainTab) -> Unit,
renderer: DesignRenderer,
floating: Boolean,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
if (renderer == DesignRenderer.MIUIX) {
Surface(
modifier = if (floating) {
Modifier.padding(horizontal = 20.dp, vertical = 8.dp).clip(RoundedCornerShape(36.dp))
} else {
Modifier
},
shape = if (floating) RoundedCornerShape(36.dp) else RoundedCornerShape(0.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = if (floating) 3.dp else 0.dp,
) {
ABoxNavigationBar(containerColor = Color.Transparent) {
MainTab.entries.forEach { tab ->
val selected = tab == currentTab
ABoxNavigationBarItem(
selected = tab == currentTab,
onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = {
Icon(
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes),
)
},
label = { Text(stringResource(tab.labelRes)) },
)
}
}
}
} else {
ABoxNavigationBar {
MainTab.entries.forEach { tab ->
val selected = tab == currentTab
ABoxNavigationBarItem(
selected = selected,
onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = { Icon(if (selected) tab.selectedIcon else tab.unselectedIcon, stringResource(tab.labelRes)) },
label = { Text(stringResource(tab.labelRes)) },
)
}
}
}
}
@Composable
private fun ProviderTopBar(
currentTab: MainTab,
providerName: String,
userName: String,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.testTag("main_provider_pill")
.clickable(
onClickLabel = stringResource(R.string.account_sheet_provider),
onClick = onProviderClick,
)
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = providerName,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(22.dp),
)
Text(
text = providerName,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp),
)
}
Text(
text = stringResource(currentTab.labelRes),
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp),
)
Box(modifier = Modifier.testTag("main_avatar")) {
ABoxUserAvatar(
name = userName,
onClick = onAvatarClick,
size = 36.dp,
contentDescription = stringResource(R.string.action_account),
)
}
}
}
}
}
@Composable
private fun MainNavigationRail(
currentTab: MainTab,
onTabClick: (MainTab) -> Unit,
) {
NavigationRail(
modifier = Modifier.fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surface,
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
Spacer(modifier = Modifier.height(8.dp))
MainTab.entries.forEach { tab ->
val selected = tab == currentTab
NavigationRailItem(
selected = selected,
onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = {
Icon(
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes),
)
},
label = { Text(stringResource(tab.labelRes)) },
)
}
}
}
@@ -11,9 +11,7 @@ import androidx.compose.material.icons.outlined.ViewAgenda
import androidx.compose.ui.graphics.vector.ImageVector
import com.flagship.abox.manager.R
/**
* Main bottom navigation tabs for the authenticated application shell.
*/
/** Main destinations available while authenticated. */
enum class MainTab(
@StringRes val labelRes: Int,
val selectedIcon: ImageVector,
@@ -24,18 +22,30 @@ enum class MainTab(
labelRes = R.string.tab_devices,
selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home,
route = "shell/devices",
route = "tab/devices",
),
SCENES(
labelRes = R.string.tab_scenes,
selectedIcon = Icons.Filled.AutoAwesome,
unselectedIcon = Icons.Outlined.AutoAwesome,
route = "shell/scenes",
route = "tab/scenes",
),
ACTIVITY(
labelRes = R.string.tab_activity,
selectedIcon = Icons.Filled.ViewAgenda,
unselectedIcon = Icons.Outlined.ViewAgenda,
route = "shell/activity",
route = "tab/activity",
),
;
companion object {
fun fromRoute(route: String?): MainTab? = when {
route == null -> null
route == DEVICES.route || route.startsWith("device/") ||
route.startsWith("infrared_") -> DEVICES
route == SCENES.route || route.startsWith("scene/") -> SCENES
route == ACTIVITY.route -> ACTIVITY
else -> null
}
}
}
@@ -0,0 +1,415 @@
package com.flagship.abox.manager.ui.socket
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxStatus
import com.flagship.abox.manager.designsystem.ABoxStatusBanner
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.SocketPowerState
import java.text.DateFormat
import java.util.Date
@Composable
fun SocketRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: SocketViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
SocketScreen(
state = state,
onEvent = viewModel::onEvent,
onBackClick = onBackClick,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SocketScreen(
state: SocketUiState,
onEvent: (SocketUiEvent) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val configuration = LocalConfiguration.current
var showDeviceNameDialog by remember { mutableStateOf(false) }
ABoxDeviceDetailScaffold(
modifier = modifier.fillMaxSize(),
title = state.deviceName?.value ?: stringResource(R.string.socket_title),
onBack = onBackClick,
subtitle = stringResource(R.string.socket_detail_subtitle),
icon = ABoxIconKey.SOCKET,
action = {
Row {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = stringResource(R.string.socket_name_settings),
onClick = { showDeviceNameDialog = true },
enabled = !state.isOperating,
)
ABoxIconButton(
key = ABoxIconKey.REFRESH,
contentDescription = stringResource(R.string.action_refresh),
onClick = { onEvent(SocketUiEvent.Refresh) },
enabled = !state.isLoading && !state.isOperating,
)
}
},
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 24.dp, vertical = 16.dp)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (state.isOffline) {
OfflineBanner(
onRetry = { onEvent(SocketUiEvent.Refresh) },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
state.errorMessage?.let { errorText ->
ErrorBanner(
message = errorText.asString(),
onDismiss = { onEvent(SocketUiEvent.ClearError) },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
Box(modifier = Modifier.fillMaxWidth().height(64.dp)) {
SocketOperationBanner(operation = state.operation)
}
// Central Status Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp, bottom = 18.dp),
colors = CardDefaults.cardColors(
containerColor = if (state.isPowerOn) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
shape = MaterialTheme.shapes.large,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
PowerIndicatorBadge(
powerState = state.powerState,
isOffline = state.isOffline,
isLoading = state.isLoading,
isOperating = state.isOperating,
enabled = state.isActionEnabled,
onClick = { onEvent(SocketUiEvent.TogglePower) },
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.socket_status_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = when {
state.isOffline -> stringResource(R.string.socket_status_offline)
state.powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on)
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
else -> stringResource(R.string.socket_status_unknown)
},
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = when {
state.isOffline -> MaterialTheme.colorScheme.error
state.powerState == SocketPowerState.ON -> MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.onSurface
},
modifier = Modifier.padding(top = 4.dp),
)
state.lastConfirmedTimeMillis?.let { timeMillis ->
val formattedTime = remember(timeMillis, configuration) {
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
}
Text(
text = stringResource(R.string.socket_confirmed_at, formattedTime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
Text(
text = stringResource(R.string.socket_confirmation_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth(),
)
}
}
if (showDeviceNameDialog) {
DeviceNameDialog(
currentName = state.deviceName?.value.orEmpty(),
title = stringResource(R.string.socket_name_settings),
description = stringResource(R.string.gateway_device_name_socket_desc),
onDismiss = { showDeviceNameDialog = false },
onConfirm = { name ->
showDeviceNameDialog = false
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(name)))
},
)
}
}
@Composable
private fun DeviceNameDialog(
currentName: String,
title: String,
description: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var value by remember(currentName) { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(description, style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(stringResource(R.string.gateway_device_name)) },
singleLine = true,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
)
}
@Composable
private fun PowerIndicatorBadge(
powerState: SocketPowerState?,
isOffline: Boolean,
isLoading: Boolean,
isOperating: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val indicatorColor = when {
isOffline -> MaterialTheme.colorScheme.error
powerState == SocketPowerState.ON -> MaterialTheme.colorScheme.primary
powerState == SocketPowerState.OFF -> MaterialTheme.colorScheme.outline
else -> MaterialTheme.colorScheme.outlineVariant
}
Box(
modifier = modifier
.size(160.dp)
.clip(CircleShape)
.background(indicatorColor.copy(alpha = 0.15f))
.border(4.dp, indicatorColor, CircleShape)
.clickable(enabled = enabled, onClickLabel = "切换插座电源", onClick = onClick)
.testTag("socket_power_dial"),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (isLoading || isOperating) {
ABoxLoadingIndicator(modifier = Modifier.size(44.dp))
} else {
ABoxIcon(
key = ABoxIconKey.POWER,
contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭",
modifier = Modifier.size(52.dp),
)
}
Spacer(Modifier.height(10.dp))
Text(
text = when {
isOperating -> stringResource(R.string.device_status_switching)
powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on)
powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
else -> stringResource(R.string.device_status_unavailable)
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun SocketOperationBanner(
operation: SocketOperationState,
modifier: Modifier = Modifier,
) {
when (operation) {
is SocketOperationState.PendingCommand -> ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在发送电源指令",
modifier = modifier,
)
is SocketOperationState.RefreshingConfirmation -> ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在确认插座状态",
modifier = modifier,
)
is SocketOperationState.AwaitingConfirmation -> ABoxStatusBanner(
state = ABoxStatus.WARNING,
contentDescription = "指令已发送,等待状态确认",
modifier = modifier.testTag("socket_awaiting_confirmation"),
)
is SocketOperationState.Failed -> ABoxStatusBanner(
state = ABoxStatus.ERROR,
contentDescription = "插座操作失败",
modifier = modifier,
)
SocketOperationState.Unavailable -> ABoxStatusBanner(
state = ABoxStatus.OFFLINE,
contentDescription = "插座当前不可用",
modifier = modifier,
)
SocketOperationState.IdleConfirmed -> Unit
}
}
@Composable
private fun OfflineBanner(
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.status_device_offline_banner),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onRetry) {
Text(stringResource(R.string.action_retry))
}
}
}
}
@Composable
private fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.85f),
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
ABoxIconButton(
key = ABoxIconKey.CLOSE,
contentDescription = "关闭错误提示",
onClick = onDismiss,
)
}
}
}
@@ -0,0 +1,45 @@
package com.flagship.abox.manager.ui.socket
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.ui.common.UiText
data class SocketUiState(
val profileId: ProviderProfileId? = null,
val deviceName: DeviceName? = null,
val powerState: SocketPowerState? = null,
val isLoading: Boolean = false,
val isOperating: Boolean = false,
val isOffline: Boolean = false,
val errorMessage: UiText? = null,
val lastConfirmedTimeMillis: Long? = null,
val operation: SocketOperationState = SocketOperationState.IdleConfirmed,
) {
val isActionEnabled: Boolean
get() = !isLoading && operation !is SocketOperationState.PendingCommand &&
operation !is SocketOperationState.RefreshingConfirmation &&
operation !is SocketOperationState.AwaitingConfirmation &&
!isOffline && profileId != null && deviceName != null
val isPowerOn: Boolean
get() = powerState == SocketPowerState.ON
}
sealed interface SocketOperationState {
data object IdleConfirmed : SocketOperationState
data class PendingCommand(val target: SocketPowerState) : SocketOperationState
data class RefreshingConfirmation(val target: SocketPowerState) : SocketOperationState
data class AwaitingConfirmation(val target: SocketPowerState) : SocketOperationState
data class Failed(val target: SocketPowerState) : SocketOperationState
data object Unavailable : SocketOperationState
}
sealed interface SocketUiEvent {
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
data object Refresh : SocketUiEvent
data object TogglePower : SocketUiEvent
data class SetPower(val targetState: SocketPowerState) : SocketUiEvent
data object ClearError : SocketUiEvent
}
@@ -0,0 +1,234 @@
package com.flagship.abox.manager.ui.socket
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.ui.common.DomainErrorMapper
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject
@HiltViewModel
class SocketViewModel @Inject constructor(
private val socketGateway: SocketGateway,
private val configuredDeviceRepository: ConfiguredDeviceRepository,
private val activityWriter: ActivityEventWriter,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val _state = MutableStateFlow(SocketUiState())
val state: StateFlow<SocketUiState> = _state.asStateFlow()
init {
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent(
SocketUiEvent.Initialize(
profileId = ProviderProfileId(profileIdString),
deviceName = DeviceName(deviceNameString),
),
)
}
}
private fun decode(value: String): String = try {
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
} catch (_: Exception) {
value
}
fun onEvent(event: SocketUiEvent) {
when (event) {
is SocketUiEvent.Initialize -> {
_state.update {
it.copy(
profileId = event.profileId,
deviceName = event.deviceName,
operation = SocketOperationState.Unavailable,
)
}
loadStatus()
}
is SocketUiEvent.ChangeDeviceName -> {
val trimmed = event.deviceName.value.trim()
val current = _state.value
val profileId = current.profileId
val oldName = current.deviceName
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
viewModelScope.launch {
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
is DomainResult.Success -> {
_state.update { it.copy(deviceName = DeviceName(trimmed), powerState = null, errorMessage = null, lastConfirmedTimeMillis = null) }
loadStatus()
}
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
}
}
}
}
SocketUiEvent.Refresh -> {
loadStatus()
}
SocketUiEvent.TogglePower -> {
val currentState = _state.value
val targetState = if (currentState.isPowerOn) {
SocketPowerState.OFF
} else {
SocketPowerState.ON
}
setPower(targetState)
}
is SocketUiEvent.SetPower -> {
setPower(event.targetState)
}
SocketUiEvent.ClearError -> {
_state.update { it.copy(errorMessage = null) }
}
}
}
private fun loadStatus() {
val profileId = _state.value.profileId ?: return
val deviceName = _state.value.deviceName ?: return
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
when (val result = socketGateway.getStatus(profileId, deviceName)) {
is DomainResult.Success -> {
_state.update {
it.copy(
powerState = result.value.powerState,
isLoading = false,
isOffline = false,
errorMessage = null,
lastConfirmedTimeMillis = System.currentTimeMillis(),
operation = SocketOperationState.IdleConfirmed,
)
}
}
is DomainResult.Failure -> {
val isOffline = result.error is DomainError.DeviceOffline
_state.update {
it.copy(
isLoading = false,
isOffline = isOffline,
errorMessage = DomainErrorMapper.map(result.error),
operation = SocketOperationState.Unavailable,
)
}
}
}
}
}
private fun setPower(targetState: SocketPowerState) {
val current = _state.value
val profileId = current.profileId ?: return
val deviceName = current.deviceName ?: return
// Prevent duplicate action if currently operating, loading or offline
if (current.isOperating || current.isLoading || current.isOffline || !current.isActionEnabled) {
return
}
_state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
viewModelScope.launch {
when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) {
is DomainResult.Success -> {
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED)
_state.update {
it.copy(
isOperating = true,
isOffline = false,
errorMessage = null,
operation = SocketOperationState.RefreshingConfirmation(targetState),
)
}
var confirmationError: DomainError? = null
repeat(CONFIRMATION_ATTEMPTS) { attempt ->
if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS)
when (val confirmation = socketGateway.getStatus(profileId, deviceName)) {
is DomainResult.Success -> if (confirmation.value.powerState == targetState) {
activityWriter.recordDevice(
profileId,
deviceName,
ActivityEventKind.SOCKET_CONFIRMATION,
ActivityOutcome.SUCCEEDED,
)
_state.update {
it.copy(
powerState = targetState,
isOperating = false,
isOffline = false,
errorMessage = null,
lastConfirmedTimeMillis = System.currentTimeMillis(),
operation = SocketOperationState.IdleConfirmed,
)
}
return@launch
}
is DomainResult.Failure -> confirmationError = confirmation.error
}
}
activityWriter.recordDevice(
profileId,
deviceName,
ActivityEventKind.SOCKET_CONFIRMATION,
ActivityOutcome.AWAITING_CONFIRMATION,
confirmationError?.externalCode,
)
_state.update {
it.copy(
isOperating = false,
isOffline = confirmationError is DomainError.DeviceOffline,
errorMessage = confirmationError?.let(DomainErrorMapper::map),
operation = SocketOperationState.AwaitingConfirmation(targetState),
)
}
}
is DomainResult.Failure -> {
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.FAILED, result.error.externalCode)
val isOffline = result.error is DomainError.DeviceOffline
_state.update {
it.copy(
isOperating = false,
isOffline = isOffline,
errorMessage = DomainErrorMapper.map(result.error),
operation = SocketOperationState.Failed(targetState),
)
}
}
}
}
}
private companion object {
const val CONFIRMATION_ATTEMPTS = 4
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
}
}
@@ -0,0 +1,447 @@
package com.flagship.abox.manager.ui.temperaturehumidity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.DeviceName
import java.text.DateFormat
import java.util.Date
@Composable
fun TemperatureHumidityRoute(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
viewModel: TemperatureHumidityViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
TemperatureHumidityScreen(
state = state,
onEvent = viewModel::onEvent,
onBackClick = onBackClick,
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TemperatureHumidityScreen(
state: TemperatureHumidityUiState,
onEvent: (TemperatureHumidityUiEvent) -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val configuration = LocalConfiguration.current
var showDeviceNameDialog by remember { mutableStateOf(false) }
ABoxDeviceDetailScaffold(
modifier = modifier.fillMaxSize(),
title = state.deviceName?.value ?: stringResource(R.string.th_title),
onBack = onBackClick,
subtitle = stringResource(R.string.device_sensor_subtitle),
icon = ABoxIconKey.SENSOR,
action = {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = stringResource(R.string.sensor_name_settings),
onClick = { showDeviceNameDialog = true },
enabled = !state.isLoading && !state.isRefreshing,
)
},
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 24.dp, vertical = 16.dp)
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (state.isOffline) {
OfflineBanner(
onRetry = { onEvent(TemperatureHumidityUiEvent.Refresh) },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
state.errorMessage?.let { errorText ->
ErrorBanner(
message = errorText.asString(),
onDismiss = { onEvent(TemperatureHumidityUiEvent.ClearError) },
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
)
}
if (state.isLoading && !state.hasValidData) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
ABoxLoadingIndicator(modifier = Modifier.size(40.dp))
Spacer(modifier = Modifier.height(16.dp))
Text(
text = stringResource(R.string.status_loading),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
} else if (state.hasNoData) {
NoDataCard(
onRefresh = { onEvent(TemperatureHumidityUiEvent.Refresh) },
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
)
} else {
// Telemetry Cards Row / Column
Text(
text = stringResource(R.string.sensor_current_readings),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
TelemetryMetricCard(
title = stringResource(R.string.th_temperature_label),
value = state.temperature,
unit = stringResource(R.string.th_unit_celsius),
icon = ABoxIconKey.TEMPERATURE,
testTag = "sensor_metric_temperature",
modifier = Modifier.weight(1f),
)
TelemetryMetricCard(
title = stringResource(R.string.th_humidity_label),
value = state.humidity,
unit = stringResource(R.string.th_unit_percent),
icon = ABoxIconKey.HUMIDITY,
testTag = "sensor_metric_humidity",
modifier = Modifier.weight(1f),
)
}
state.lastUpdatedTimeMillis?.let { timeMillis ->
val formattedTime = remember(timeMillis, configuration) {
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
}
Text(
text = stringResource(R.string.th_last_updated, formattedTime),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Read-Only Informational Card
Card(
modifier = Modifier.fillMaxWidth().testTag("sensor_monitoring_notice"),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
),
shape = MaterialTheme.shapes.medium,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
) {
Text(
text = stringResource(R.string.sensor_monitoring_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.sensor_monitoring_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
modifier = Modifier.padding(top = 4.dp),
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Refresh Button
ABoxButton(
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
) {
if (state.isRefreshing || state.isLoading) {
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
Spacer(modifier = Modifier.width(10.dp))
Text(stringResource(R.string.status_loading))
} else {
Text(
text = stringResource(R.string.action_refresh),
style = MaterialTheme.typography.titleMedium,
)
}
}
}
}
if (showDeviceNameDialog) {
SensorDeviceNameDialog(
currentName = state.deviceName?.value.orEmpty(),
onDismiss = { showDeviceNameDialog = false },
onConfirm = { name ->
showDeviceNameDialog = false
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(name)))
},
)
}
}
@Composable
private fun SensorDeviceNameDialog(
currentName: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var value by remember(currentName) { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.sensor_name_settings)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.gateway_device_name_sensor_desc), style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(stringResource(R.string.gateway_device_name)) },
singleLine = true,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
)
}
@Composable
private fun TelemetryMetricCard(
title: String,
value: String?,
unit: String,
icon: ABoxIconKey,
testTag: String,
modifier: Modifier = Modifier,
) {
if (value == null) return
Card(
modifier = modifier.testTag(testTag),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = MaterialTheme.shapes.large,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
ABoxIcon(icon, contentDescription = title, modifier = Modifier.size(28.dp))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = title,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.Center,
) {
Text(
text = value,
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = " $unit",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 4.dp),
)
}
}
}
}
@Composable
private fun NoDataCard(
onRefresh: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = MaterialTheme.shapes.large,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
ABoxIcon(
key = ABoxIconKey.SENSOR,
contentDescription = "暂无传感器数据",
modifier = Modifier.size(40.dp),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.th_no_data_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = stringResource(R.string.th_no_data_description),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp, bottom = 16.dp),
)
TextButton(onClick = onRefresh) {
Text(stringResource(R.string.action_refresh))
}
}
}
}
@Composable
private fun OfflineBanner(
onRetry: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(R.string.status_device_offline_banner),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onRetry) {
Text(stringResource(R.string.action_retry))
}
}
}
}
@Composable
private fun ErrorBanner(
message: String,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
Card(
modifier = modifier,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.85f),
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = message,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
)
ABoxIconButton(
key = ABoxIconKey.CLOSE,
contentDescription = "关闭错误提示",
onClick = onDismiss,
)
}
}
}
@@ -0,0 +1,34 @@
package com.flagship.abox.manager.ui.temperaturehumidity
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.ui.common.UiText
data class TemperatureHumidityUiState(
val profileId: ProviderProfileId? = null,
val deviceName: DeviceName? = null,
val temperature: String? = null,
val humidity: String? = null,
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val isOffline: Boolean = false,
val hasNoData: Boolean = false,
val errorMessage: UiText? = null,
val lastUpdatedTimeMillis: Long? = null,
) {
val formattedTemperature: String?
get() = temperature?.takeIf { it.isNotBlank() }?.let { "$it °C" }
val formattedHumidity: String?
get() = humidity?.takeIf { it.isNotBlank() }?.let { "$it %" }
val hasValidData: Boolean
get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank()
}
sealed interface TemperatureHumidityUiEvent {
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : TemperatureHumidityUiEvent
data class ChangeDeviceName(val deviceName: DeviceName) : TemperatureHumidityUiEvent
data object Refresh : TemperatureHumidityUiEvent
data object ClearError : TemperatureHumidityUiEvent
}
@@ -0,0 +1,144 @@
package com.flagship.abox.manager.ui.temperaturehumidity
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.ui.common.DomainErrorMapper
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject
@HiltViewModel
class TemperatureHumidityViewModel @Inject constructor(
private val thGateway: TemperatureHumidityGateway,
private val configuredDeviceRepository: ConfiguredDeviceRepository,
private val activityWriter: ActivityEventWriter,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val _state = MutableStateFlow(TemperatureHumidityUiState())
val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow()
init {
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent(
TemperatureHumidityUiEvent.Initialize(
profileId = ProviderProfileId(profileIdString),
deviceName = DeviceName(deviceNameString),
),
)
}
}
private fun decode(value: String): String = try {
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
} catch (_: Exception) {
value
}
fun onEvent(event: TemperatureHumidityUiEvent) {
when (event) {
is TemperatureHumidityUiEvent.Initialize -> {
_state.update {
it.copy(
profileId = event.profileId,
deviceName = event.deviceName,
)
}
loadReading(isRefresh = false)
}
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
val trimmed = event.deviceName.value.trim()
val current = _state.value
val profileId = current.profileId
val oldName = current.deviceName
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
viewModelScope.launch {
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
is DomainResult.Success -> {
_state.update { it.copy(deviceName = DeviceName(trimmed), temperature = null, humidity = null, hasNoData = false, errorMessage = null, lastUpdatedTimeMillis = null) }
loadReading(isRefresh = false)
}
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
}
}
}
}
TemperatureHumidityUiEvent.Refresh -> {
loadReading(isRefresh = true)
}
TemperatureHumidityUiEvent.ClearError -> {
_state.update { it.copy(errorMessage = null) }
}
}
}
private fun loadReading(isRefresh: Boolean) {
val profileId = _state.value.profileId ?: return
val deviceName = _state.value.deviceName ?: return
viewModelScope.launch {
if (isRefresh) {
_state.update { it.copy(isRefreshing = true, errorMessage = null) }
} else {
_state.update { it.copy(isLoading = true, errorMessage = null) }
}
when (val result = thGateway.getReading(profileId, deviceName)) {
is DomainResult.Success -> {
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.SUCCEEDED)
val reading = result.value
val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank()
_state.update {
it.copy(
temperature = reading.temperature?.takeIf { t -> t.isNotBlank() },
humidity = reading.humidity?.takeIf { h -> h.isNotBlank() },
isLoading = false,
isRefreshing = false,
isOffline = false,
hasNoData = isBlankData,
errorMessage = null,
lastUpdatedTimeMillis = System.currentTimeMillis(),
)
}
}
is DomainResult.Failure -> {
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.FAILED, result.error.externalCode)
val isOffline = result.error is DomainError.DeviceOffline
val hasNoData = result.error is DomainError.NoData
_state.update {
it.copy(
isLoading = false,
isRefreshing = false,
isOffline = isOffline,
hasNoData = hasNoData,
errorMessage = DomainErrorMapper.map(result.error),
)
}
}
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,2C6.48,2 2,6.58 2,12.26c0,4.53 2.87,8.37 6.84,9.73c0.5,0.09 0.68,-0.22 0.68,-0.49c0,-0.24 -0.01,-0.88 -0.01,-1.49c-2.78,0.62 -3.37,-1.37 -3.37,-1.37c-0.45,-1.18 -1.11,-1.5 -1.11,-1.5c-0.91,-0.64 0.07,-0.62 0.07,-0.62c1.01,0.07 1.53,1.06 1.53,1.06c0.89,1.57 2.34,1.12 2.91,0.86c0.09,-0.66 0.35,-1.12 0.63,-1.38c-2.22,-0.26 -4.56,-1.14 -4.56,-5.07c0,-1.12 0.39,-2.03 1.03,-2.75c-0.1,-0.26 -0.45,-1.3 0.1,-2.71c0,0 0.84,-0.28 2.75,1.05c0.8,-0.23 1.65,-0.34 2.5,-0.34c0.85,0 1.7,0.12 2.5,0.34c1.91,-1.33 2.75,-1.05 2.75,-1.05c0.55,1.41 0.2,2.45 0.1,2.71c0.64,0.71 1.03,1.63 1.03,2.75c0,3.94 -2.34,4.8 -4.57,5.06c0.36,0.31 0.68,0.92 0.68,1.86c0,1.34 -0.01,2.42 -0.01,2.75c0,0.27 0.18,0.58 0.69,0.48c3.97,-1.36 6.83,-5.2 6.83,-9.73C22,6.58 17.52,2 12,2z"/>
</vector>
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<color xmlns:android="http://schemas.android.com/apk/res/android"
android:color="#F8FAFC" />
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

@@ -1,5 +1,5 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="app_name">ABox 家庭</string>
<string name="provider_selection_title">选择提供方</string>
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
<string name="provider_abox_name">ABox</string>
@@ -23,7 +23,6 @@
<string name="action_switch_provider">切换提供方</string>
<string name="authenticated_title">已连接到 %1$s</string>
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
<!-- 认证后壳层 -->
<string name="tab_devices">设备</string>
<string name="tab_scenes">情景</string>
@@ -43,42 +42,363 @@
<string name="activity_placeholder_title">活动记录</string>
<string name="activity_placeholder_description">未记录任何事件。</string>
<!-- Scene: list -->
<string name="scene_list_title">情景模式</string>
<string name="scene_create_action">新建情景</string>
<!-- Scene & Automation strings -->
<string name="scene_list_title">自动化与情景</string>
<string name="scene_tab_all">全部</string>
<string name="scene_tab_manual">一键指令</string>
<string name="scene_tab_automatic">自动指令</string>
<string name="scene_create_manual">建立一键指令</string>
<string name="scene_create_automatic">建立自动指令</string>
<string name="scene_create_action">新建指令</string>
<string name="scene_action_count">%1$d 个动作</string>
<string name="scene_edit">编辑情景</string>
<string name="scene_delete">删除情景</string>
<string name="scene_empty_title">还没有情景</string>
<string name="scene_empty_message">新建第一个情景,组合插座和红外动作。</string>
<string name="scene_edit">编辑指令</string>
<string name="scene_delete">删除指令</string>
<string name="scene_empty_title">还没有自动化指令</string>
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
<string name="scene_action_run_now">立即执行</string>
<string name="scene_toast_executed">指令已触发执行</string>
<string name="scene_toast_failed">指令执行失败</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">删除情景</string>
<string name="scene_delete_dialog_message">此操作无法撤销。</string>
<string name="scene_delete_dialog_title">删除指令</string>
<string name="scene_delete_dialog_message">确定要删除该指令吗?此操作无法撤销。</string>
<string name="scene_delete_confirm">删除</string>
<string name="scene_delete_cancel">取消</string>
<!-- Scene: editor -->
<string name="scene_editor_new_title">新建情景</string>
<string name="scene_editor_edit_title">编辑情景</string>
<string name="scene_editor_manual_title">建立一键指令</string>
<string name="scene_editor_auto_title">建立自动指令</string>
<string name="scene_editor_edit_title">编辑指令</string>
<string name="scene_back">返回</string>
<string name="scene_name_label">名称</string>
<string name="scene_icon_label">图标</string>
<string name="scene_color_label">主题色(十六进制)</string>
<string name="scene_actions_label">动作(%1$d)</string>
<string name="scene_add_socket_action">添加插座动作</string>
<string name="scene_add_infrared_action">添加红外动作</string>
<string name="scene_device_name_label">设备名称</string>
<string name="scene_infrared_key_label">红外按键</string>
<string name="scene_name_label">标题</string>
<string name="scene_name_hint">请输入指令标题(如:回家一键开灯、高温自动降温)</string>
<string name="scene_description_label">描述</string>
<string name="scene_description_hint">可填写该指令的详细说明或备注</string>
<!-- Sections -->
<string name="scene_section_triggers">触发器</string>
<string name="scene_section_conditions">条件(可选)</string>
<string name="scene_section_actions">动作</string>
<string name="scene_section_advanced">进阶选项</string>
<string name="scene_section_other">其他选项与外观</string>
<!-- Add Buttons -->
<string name="scene_add_trigger">新增触发器</string>
<string name="scene_add_condition">新增条件</string>
<string name="scene_add_action_btn">新增动作</string>
<string name="scene_add_action">添加</string>
<string name="scene_save_action">保存情景</string>
<string name="scene_action_socket">插座 %1$s -&gt; %2$s</string>
<string name="scene_action_infrared">红外 %1$s -&gt; %2$s</string>
<string name="scene_save_action">保存指令</string>
<!-- Strategies & Toggles -->
<string name="scene_repeat_strategy">重复执行策略</string>
<string name="scene_strategy_continue">继续执行新任务</string>
<string name="scene_concurrency_mode">多指令同步策略</string>
<string name="scene_concurrency_sync">同步 (串行执行)</string>
<string name="scene_concurrency_async">异步 (并发执行)</string>
<string name="scene_error_policy">遇到错误时</string>
<string name="scene_policy_continue">继续执行后续动作</string>
<string name="scene_policy_abort">立即中止剩余动作</string>
<string name="scene_appearance_icon">图示</string>
<string name="scene_appearance_color">图示背景色</string>
<string name="scene_enable_rule">启用该自动指令</string>
<string name="scene_enable_logging">开启指令执行记录</string>
<!-- Trigger Types & Dialog -->
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
<string name="scene_trigger_type_time">定时时间触发</string>
<string name="scene_trigger_type_temp">环境温度触发</string>
<string name="scene_trigger_type_humidity">环境湿度触发</string>
<string name="scene_trigger_type_device">插座状态变更触发</string>
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
<string name="scene_operator_label">比较条件</string>
<string name="scene_operator_gt">高于 (&gt;)</string>
<string name="scene_operator_lt">低于 (&lt;)</string>
<string name="scene_operator_eq">等于 (=)</string>
<!-- Condition Types & Dialog -->
<string name="scene_dialog_add_condition_title">选择限制条件</string>
<string name="scene_condition_type_temp">环境温度范围</string>
<string name="scene_condition_type_humidity">环境湿度范围</string>
<string name="scene_condition_type_socket">插座当前电源状态</string>
<string name="scene_condition_type_time_range">生效时间区间</string>
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
<!-- Action Types & Dialog -->
<string name="scene_dialog_add_action_title">选择执行动作</string>
<string name="scene_action_type_socket">智能插座控制</string>
<string name="scene_action_type_virtual_remote">红外遥控按键</string>
<string name="scene_action_type_delay">延时等待(缓冲)</string>
<string name="scene_action_type_notification">发送系统提示</string>
<string name="scene_select_socket">选择目标插座</string>
<string name="scene_socket_power_state">设定电源状态</string>
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
<string name="scene_select_remote_button">选择遥控器功能按键</string>
<string name="scene_delay_duration_label">等待时长 (秒)</string>
<string name="scene_notification_title_label">提示标题</string>
<string name="scene_notification_msg_label">提示消息内容</string>
<string name="scene_unit_seconds">%1$d 秒</string>
<!-- Action Card Displays -->
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
<string name="scene_action_remote">遥控【%1$s】: 发送 %2$s</string>
<string name="scene_action_infrared">红外【%1$s】: 槽位 %2$s</string>
<string name="scene_action_delay">等待 %1$d 秒</string>
<string name="scene_action_notification">提示:%1$s</string>
<!-- Scene: execution -->
<string name="scene_execution_title">执行情景</string>
<string name="scene_execution_title">执行指令</string>
<string name="scene_execution_run">执行</string>
<string name="scene_execution_running">正在执行 %1$s…</string>
<string name="scene_execution_running">正在执行:%1$s…</string>
<string name="scene_execution_summary">%3$d 个动作中成功 %1$d 个,失败 %2$d 个</string>
<string name="scene_execution_done">完成</string>
<string name="scene_execution_done">执行完成</string>
<string name="scene_execution_duration">总耗时:%1$s ms</string>
<string name="scene_step_pending">排队中</string>
<string name="scene_step_running">正在执行…</string>
<string name="scene_step_delaying">正在延时等待 (%1$d s)…</string>
<string name="scene_step_success">已完成</string>
<string name="scene_step_failed">执行失败</string>
<string name="scene_step_skipped">已跳过</string>
<!-- 校验与弹窗选项/占位文案 -->
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</string>
<string name="scene_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
<string name="scene_chip_time">定时</string>
<string name="scene_chip_temp">温度</string>
<string name="scene_chip_humidity">湿度</string>
<string name="scene_chip_socket">插座</string>
<string name="scene_chip_remote">遥控</string>
<string name="scene_chip_delay">延时</string>
<string name="scene_chip_notify">提示</string>
<string name="scene_chip_above">&gt; 高于</string>
<string name="scene_chip_below">&lt; 低于</string>
<string name="scene_chip_power_on">开启</string>
<string name="scene_chip_power_off">关闭</string>
<string name="scene_threshold_temp">设定阈值:%1$d °C</string>
<string name="scene_threshold_hum">设定阈值:%1$d %%</string>
<string name="scene_target_socket_state">要求插座当前状态为:</string>
<string name="scene_no_virtual_remotes">未找到虚拟遥控器,将默认使用红外控制器</string>
<string name="scene_remote_button_slot_label">按键名称/槽位</string>
<string name="scene_remote_button_name_label">按键名称</string>
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
<string name="scene_empty_actions_hint">尚未添加执行动作,请点击下方按钮添加</string>
<string name="scene_summary_triggers_and_actions">%1$d 个触发器 · %2$d 个动作</string>
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
<string name="scene_exec_rerun">重新执行</string>
<string name="scene_step_format">步骤 %1$d:%2$s</string>
<string name="scene_trigger_desc_temp">温度 %1$s %2$s°C</string>
<string name="scene_trigger_desc_humidity">湿度 %1$s %2$s%%</string>
<string name="scene_trigger_desc_device">%1$s 状态变为 %2$s</string>
<string name="scene_condition_desc_temp">温度需 %1$s %2$s°C</string>
<string name="scene_condition_desc_humidity">湿度需 %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s 处于 %2$s</string>
<string name="scene_condition_desc_time">时间在 %1$s ~ %2$s 之间</string>
<string name="scene_device_name_label">设备名称(网关配对标识)</string>
<string name="scene_loading_scene">正在加载情景配置…</string>
<string name="scene_load_failed">无法加载情景配置。</string>
<string name="scene_default_button_power">电源</string>
<string name="scene_default_notify_title">情景完成</string>
<string name="scene_default_notify_msg">自动化任务执行完成</string>
<!-- 设备列表 -->
<string name="device_list_title">设备列表</string>
<string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string>
<string name="device_socket_title">智能插座</string>
<string name="device_socket_description">进入智能插座相关功能。</string>
<string name="device_temperature_humidity_title">温湿度传感器</string>
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
<string name="device_infrared_title">红外控制器</string>
<string name="device_infrared_description">进入红外控制器相关功能。</string>
<string name="infrared_control_action">红外控制</string>
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
<!-- Socket strings -->
<string name="socket_title">智能插座</string>
<string name="socket_status_label">电源状态</string>
<string name="socket_status_on">已开启</string>
<string name="socket_status_off">已关闭</string>
<string name="socket_status_offline">已离线</string>
<string name="socket_status_unknown">未知状态</string>
<string name="socket_action_toggle">切换电源</string>
<string name="socket_operating_label">正在切换状态…</string>
<string name="socket_confirmed_at">已于 %1$s 确认状态</string>
<!-- Temperature & Humidity strings -->
<string name="th_title">温湿度传感器</string>
<string name="th_temperature_label">温度</string>
<string name="th_humidity_label">湿度</string>
<string name="th_unit_celsius">°C</string>
<string name="th_unit_percent">%</string>
<string name="th_read_only_notice">传感器设备(只读监控)</string>
<string name="th_read_only_description">本设备提供实时环境温湿度遥测,不支持远程控制操作。</string>
<string name="th_no_data_title">暂无传感器数据</string>
<string name="th_no_data_description">尚未获取到该传感器的遥测数据,请点击刷新重新查询。</string>
<string name="th_last_updated">更新于 %1$s</string>
<!-- Common action & error strings -->
<string name="action_refresh">刷新</string>
<string name="action_retry">重试</string>
<string name="action_back">返回</string>
<string name="action_edit_device_name">修改设备名称</string>
<string name="dialog_edit_device_name_title">修改设备名称</string>
<string name="dialog_edit_device_name_hint">请输入网关中的设备名称</string>
<string name="action_confirm">确定</string>
<string name="action_cancel">取消</string>
<string name="status_loading">加载中…</string>
<string name="status_device_offline_banner">设备已离线,无法进行通信。</string>
<string name="error_authentication">认证失败,请重新登录。</string>
<string name="error_token_invalid">登录已过期,请重新登录。</string>
<string name="error_network">网络连接异常,请检查网络设置。</string>
<string name="error_device_not_found">未找到该设备。</string>
<string name="error_device_offline">设备当前处于离线状态。</string>
<string name="error_invalid_request">请求参数无效。</string>
<string name="error_operation_failed">操作执行失败,请重试。</string>
<string name="error_no_data">暂无可用数据。</string>
<string name="error_unknown">发生未知错误。</string>
<!-- Interface settings -->
<string name="settings_title">界面设置</string>
<string name="settings_style_section">样式</string>
<string name="settings_renderer_label">渲染器</string>
<string name="settings_renderer_description">选择应用使用的界面渲染方式</string>
<string name="settings_renderer_material3">Material</string>
<string name="settings_renderer_miuix">Miuix</string>
<string name="settings_theme_section">主题</string>
<string name="settings_theme_label">主题模式</string>
<string name="settings_theme_system">跟随系统</string>
<string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string>
<string name="settings_dynamic_color">动态取色</string>
<string name="settings_dynamic_color_description">使用系统壁纸颜色生成界面配色</string>
<string name="settings_surface_section">界面效果</string>
<string name="settings_blur">模糊效果</string>
<string name="settings_blur_description">在支持的界面中启用背景模糊</string>
<string name="settings_navigation">导航样式</string>
<string name="settings_navigation_standard">标准导航栏</string>
<string name="settings_navigation_floating">悬浮导航栏</string>
<string name="settings_feedback_section">交互反馈</string>
<string name="settings_haptics">触觉反馈</string>
<string name="settings_haptics_description">操作时提供轻微震动反馈</string>
<string name="settings_accessibility_section">系统辅助功能</string>
<string name="settings_accessibility_card_title">遵循系统辅助功能设置</string>
<string name="settings_accessibility_description">动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。</string>
<string name="settings_error_save">无法保存界面设置,请重试</string>
<string name="settings_style_entry">界面风格</string>
<string name="settings_theme_entry">主题</string>
<string name="settings_theme_entry_description">配色、明暗、视觉效果与缩放</string>
<string name="settings_theme_detail_title">主题</string>
<string name="settings_renderer_primary">Material</string>
<string name="settings_renderer_secondary">Miuix</string>
<string name="settings_navigation_floating_description">让底部导航独立悬浮在页面内容上方</string>
<string name="settings_accent_label">强调色</string>
<string name="settings_scale">界面缩放</string>
<string name="settings_scale_description">调整界面元素的显示比例</string>
<string name="settings_color_style">色彩风格</string>
<string name="settings_color_style_tonal">Tonal Spot</string>
<string name="settings_color_style_expressive">Expressive</string>
<string name="settings_color_style_fidelity">Fidelity</string>
<string name="settings_color_standard">色彩规范</string>
<string name="settings_color_standard_2021">Spec 2021</string>
<string name="settings_color_standard_2025">Spec 2025</string>
<string name="settings_liquid_glass">液态玻璃</string>
<string name="settings_liquid_glass_description">为悬浮底栏启用半透明玻璃质感</string>
<string name="settings_predictive_back">预测性返回手势</string>
<string name="settings_predictive_back_description">侧滑返回时预览即将返回的页面</string>
<string name="settings_tap_to_choose">点击选择</string>
<string name="settings_requires_android_13">需要 Android 13 或更高版本</string>
<string name="settings_requires_floating_navigation">请先启用悬浮底栏</string>
<!-- Redesigned device center and account surfaces -->
<string name="device_center_heading">已连接终端设备</string>
<string name="device_center_description">选择需要控制或查看状态的终端设备</string>
<string name="device_center_loading">正在加载设备</string>
<string name="device_center_empty_title">尚未配置可管理设备</string>
<string name="device_center_empty_description">请先配置网关设备名称和类型</string>
<string name="device_center_configure">配置设备</string>
<string name="device_center_error">设备列表加载失败</string>
<string name="device_center_configure_title">配置设备</string>
<string name="device_center_device_name">设备名称</string>
<string name="device_status_unavailable">状态不可用</string>
<string name="device_status_offline">离线</string>
<string name="device_status_switching">正在切换</string>
<string name="device_status_awaiting_confirmation">等待状态确认</string>
<string name="device_status_on">已开启</string>
<string name="device_status_off">已关闭</string>
<string name="device_status_confirmed">状态已确认</string>
<string name="device_status_waiting_first_confirmation">等待首次确认</string>
<string name="device_open_control_panel">控制面板</string>
<string name="device_sensor_subtitle">环境传感器 · 只读监控</string>
<string name="device_live_reading">实时设备读数</string>
<string name="device_details">详情</string>
<string name="device_metric_no_data">无数据</string>
<string name="device_infrared_subtitle">红外控制器 · %1$d 个虚拟遥控器</string>
<string name="device_infrared_mounted">已挂载:%1$s</string>
<string name="device_infrared_no_remotes">暂无遥控器</string>
<string name="device_infrared_slots">硬件槽位 %1$d/%2$d</string>
<string name="device_infrared_slots_unavailable">槽位状态不可用</string>
<string name="device_infrared_center">红外中枢</string>
<string name="account_current">当前账户</string>
<string name="account_greeting">%1$s,您好</string>
<string name="account_connection_method">连接方式</string>
<string name="account_interface_settings">主题</string>
<string name="account_interface_settings_desc">配色、明暗、视觉效果与缩放</string>
<string name="account_logout">退出当前会话</string>
<string name="account_logout_desc">结束当前连接并返回登录流程</string>
<string name="account_logout_confirm">确定退出当前会话吗?</string>
<string name="account_about">关于</string>
<string name="account_about_desc">App 信息、版本与开发团队</string>
<string name="about_title">关于</string>
<string name="about_tagline">面向 Android 的 ABox 智能家居管理应用</string>
<string name="about_app_info">App 信息</string>
<string name="about_version">版本</string>
<string name="about_package">应用包名</string>
<string name="about_platform">平台</string>
<string name="about_platform_value">Android 手机与平板</string>
<string name="about_product">产品介绍</string>
<string name="about_product_desc">ABox 家庭通过 ABSDK 连接 ABox 网关,提供设备控制、红外遥控器、情景、自动化与活动记录。本地体验提供独立的模拟环境,不使用 ABox 凭据。</string>
<string name="about_team">开发团队</string>
<string name="about_role_foundation">项目架构与全模块开发</string>
<string name="about_role_devices">设备目录与导航</string>
<string name="about_role_environment">插座与环境传感器</string>
<string name="about_role_infrared">红外遥控器</string>
<string name="about_role_scenes">情景与应用集成</string>
<string name="about_copyright">由 Flagship 开发团队共同构建。</string>
<string name="about_unknown">未知</string>
<string name="about_link_github">GitHub 主页</string>
<string name="about_link_personal">个人主页</string>
<string name="scene_create_sheet_title">新建指令</string>
<string name="scene_create_sheet_description">选择指令的启动方式</string>
<string name="scene_create_manual_description">手动点击运行,适合常用的一键设备组合。</string>
<string name="scene_create_automatic_description">根据时间、设备状态或传感器条件自动执行。</string>
<string name="socket_detail_subtitle">智能插座 · 状态确认控制</string>
<string name="socket_name_settings">设置插座设备名称</string>
<string name="sensor_name_settings">设置传感器设备名称</string>
<string name="gateway_device_name">网关设备名称</string>
<string name="gateway_device_name_socket_desc">用于匹配和控制网关设备,必须与网关中的名称完全一致。</string>
<string name="gateway_device_name_sensor_desc">用于匹配和读取网关设备,必须与网关中的名称完全一致。</string>
<string name="action_save_device_name">保存设备名称</string>
<string name="socket_confirmation_hint">指令只有在网关回读到结果状态后才会确认。等待确认时可使用刷新操作。</string>
<string name="sensor_current_readings">当前读数</string>
<string name="sensor_monitoring_title">实时环境监控</string>
<string name="sensor_monitoring_description">数据直接来自当前设备。缺失指标保持不显示,不使用估算值或占位数据。</string>
<string name="infrared_center_title">红外遥控中心</string>
<string name="infrared_settings_title">红外设备设置</string>
<string name="infrared_settings_subtitle">遥控器、槽位与网关设备名称</string>
<string name="infrared_manage_title">虚拟遥控器</string>
<string name="infrared_remote_count">%1$d 个遥控器</string>
<string name="infrared_add_remote">添加遥控器</string>
<string name="infrared_hardware_slots_title">硬件槽位</string>
<string name="infrared_gateway_name_title">网关设备名称</string>
<string name="infrared_gateway_name_desc">用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。</string>
<string name="infrared_save_name">保存设备名称</string>
<string name="infrared_edit_remote">编辑遥控器</string>
<string name="infrared_move_remote">调整遥控器顺序</string>
<string name="infrared_move_up">上移</string>
<string name="infrared_move_down">下移</string>
<string name="infrared_delete_remote">删除遥控器</string>
<string name="infrared_layout">面板布局</string>
<string name="infrared_transmission_mode">传输模式</string>
</resources>
+347 -27
View File
@@ -1,5 +1,5 @@
<resources>
<string name="app_name">ABox Manager</string>
<string name="app_name">ABox Home</string>
<string name="provider_selection_title">Choose a provider</string>
<string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
<string name="provider_abox_name">ABox</string>
@@ -23,7 +23,6 @@
<string name="action_switch_provider">Switch provider</string>
<string name="authenticated_title">Connected to %1$s</string>
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
<!-- Authenticated shell -->
<string name="tab_devices">Devices</string>
<string name="tab_scenes">Scenes</string>
@@ -43,42 +42,363 @@
<string name="activity_placeholder_title">Activity Log</string>
<string name="activity_placeholder_description">No recorded events yet.</string>
<!-- Scene: list -->
<string name="scene_list_title">Scenes</string>
<string name="scene_create_action">New scene</string>
<!-- Scene & Automation strings -->
<string name="scene_list_title">Automation &amp; Scenes</string>
<string name="scene_tab_all">All</string>
<string name="scene_tab_manual">Manual</string>
<string name="scene_tab_automatic">Automatic</string>
<string name="scene_create_manual">New Manual Command</string>
<string name="scene_create_automatic">New Automatic Rule</string>
<string name="scene_create_action">New Command</string>
<string name="scene_action_count">%1$d actions</string>
<string name="scene_edit">Edit scene</string>
<string name="scene_delete">Delete scene</string>
<string name="scene_empty_title">No scenes yet</string>
<string name="scene_empty_message">Create your first scene to combine socket and infrared actions.</string>
<string name="scene_edit">Edit Command</string>
<string name="scene_delete">Delete Command</string>
<string name="scene_empty_title">No automation commands yet</string>
<string name="scene_empty_message">Tap the button below to create instant commands or trigger-based automatic rules.</string>
<string name="scene_action_run_now">Run Now</string>
<string name="scene_toast_executed">Command triggered</string>
<string name="scene_toast_failed">Command execution failed</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">Delete scene</string>
<string name="scene_delete_dialog_message">This action cannot be undone.</string>
<string name="scene_delete_dialog_title">Delete Command</string>
<string name="scene_delete_dialog_message">Are you sure you want to delete this command? This cannot be undone.</string>
<string name="scene_delete_confirm">Delete</string>
<string name="scene_delete_cancel">Cancel</string>
<!-- Scene: editor -->
<string name="scene_editor_new_title">New scene</string>
<string name="scene_editor_edit_title">Edit scene</string>
<string name="scene_editor_manual_title">Create Manual Command</string>
<string name="scene_editor_auto_title">Create Automatic Rule</string>
<string name="scene_editor_edit_title">Edit Command</string>
<string name="scene_back">Back</string>
<string name="scene_name_label">Name</string>
<string name="scene_icon_label">Icon</string>
<string name="scene_color_label">Accent color (hex)</string>
<string name="scene_actions_label">Actions (%1$d)</string>
<string name="scene_add_socket_action">Add socket action</string>
<string name="scene_add_infrared_action">Add infrared action</string>
<string name="scene_device_name_label">Device name</string>
<string name="scene_infrared_key_label">Infrared key</string>
<string name="scene_name_label">Title</string>
<string name="scene_name_hint">Enter title (e.g. Movie Night, High Temp Auto Cooling)</string>
<string name="scene_description_label">Description</string>
<string name="scene_description_hint">Optional notes or details about this rule</string>
<!-- Sections -->
<string name="scene_section_triggers">Triggers</string>
<string name="scene_section_conditions">Conditions (Optional)</string>
<string name="scene_section_actions">Actions</string>
<string name="scene_section_advanced">Advanced Options</string>
<string name="scene_section_other">Other Options &amp; Appearance</string>
<!-- Add Buttons -->
<string name="scene_add_trigger">Add Trigger</string>
<string name="scene_add_condition">Add Condition</string>
<string name="scene_add_action_btn">Add Action</string>
<string name="scene_add_action">Add</string>
<string name="scene_save_action">Save scene</string>
<string name="scene_action_socket">Socket %1$s -> %2$s</string>
<string name="scene_action_infrared">Infrared %1$s -> %2$s</string>
<string name="scene_save_action">Save Command</string>
<!-- Strategies & Toggles -->
<string name="scene_repeat_strategy">Repeat Execution Strategy</string>
<string name="scene_strategy_continue">Continue new task</string>
<string name="scene_concurrency_mode">Concurrency Mode</string>
<string name="scene_concurrency_sync">Synchronous (Sequential)</string>
<string name="scene_concurrency_async">Asynchronous (Parallel)</string>
<string name="scene_error_policy">On Error Policy</string>
<string name="scene_policy_continue">Continue subsequent actions</string>
<string name="scene_policy_abort">Abort remaining actions</string>
<string name="scene_appearance_icon">Icon</string>
<string name="scene_appearance_color">Icon Background Color</string>
<string name="scene_enable_rule">Enable this automatic rule</string>
<string name="scene_enable_logging">Enable command execution logs</string>
<!-- Trigger Types & Dialog -->
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
<string name="scene_trigger_type_time">Time / Schedule Trigger</string>
<string name="scene_trigger_type_temp">Temperature Threshold</string>
<string name="scene_trigger_type_humidity">Humidity Threshold</string>
<string name="scene_trigger_type_device">Socket State Changed</string>
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</string>
<string name="scene_trigger_temp_threshold">Temperature Threshold (°C)</string>
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
<string name="scene_operator_label">Condition</string>
<string name="scene_operator_gt">Greater than (&gt;)</string>
<string name="scene_operator_lt">Less than (&lt;)</string>
<string name="scene_operator_eq">Equals (=)</string>
<!-- Condition Types & Dialog -->
<string name="scene_dialog_add_condition_title">Select Condition</string>
<string name="scene_condition_type_temp">Temperature Range</string>
<string name="scene_condition_type_humidity">Humidity Range</string>
<string name="scene_condition_type_socket">Current Socket Power</string>
<string name="scene_condition_type_time_range">Active Time Range</string>
<string name="scene_condition_start_time">Start Time (e.g. 22:00)</string>
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
<!-- Action Types & Dialog -->
<string name="scene_dialog_add_action_title">Select Action</string>
<string name="scene_action_type_socket">Smart Socket Control</string>
<string name="scene_action_type_virtual_remote">Infrared Remote Button</string>
<string name="scene_action_type_delay">Delay / Buffer Timer</string>
<string name="scene_action_type_notification">System Notification</string>
<string name="scene_select_socket">Select Target Socket</string>
<string name="scene_socket_power_state">Target Power State</string>
<string name="scene_select_virtual_remote">Select Virtual Remote</string>
<string name="scene_select_remote_button">Select Remote Key</string>
<string name="scene_delay_duration_label">Delay Duration (seconds)</string>
<string name="scene_notification_title_label">Notification Title</string>
<string name="scene_notification_msg_label">Notification Message</string>
<string name="scene_unit_seconds">%1$d sec</string>
<!-- Action Card Displays -->
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
<string name="scene_action_remote">Remote [%1$s]: Send %2$s</string>
<string name="scene_action_infrared">Infrared [%1$s]: Slot %2$s</string>
<string name="scene_action_delay">Delay %1$d seconds</string>
<string name="scene_action_notification">Notify: %1$s</string>
<!-- Scene: execution -->
<string name="scene_execution_title">Run scene</string>
<string name="scene_execution_title">Execute Command</string>
<string name="scene_execution_run">Run</string>
<string name="scene_execution_running">Running %1$s…</string>
<string name="scene_execution_running">Running: %1$s…</string>
<string name="scene_execution_summary">%1$d succeeded, %2$d failed of %3$d actions</string>
<string name="scene_execution_done">Done</string>
<string name="scene_execution_done">Execution Completed</string>
<string name="scene_execution_duration">Total Elapsed: %1$s ms</string>
<string name="scene_step_pending">Pending</string>
<string name="scene_step_running">Running…</string>
<string name="scene_step_delaying">Waiting (%1$d s)…</string>
<string name="scene_step_success">Completed</string>
<string name="scene_step_failed">Failed</string>
<string name="scene_step_skipped">Skipped</string>
<!-- Validation & dialog chips / placeholders -->
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
<string name="scene_condition_time_error">Invalid time format (use HH:mm, e.g. 22:00)</string>
<string name="scene_chip_time">Schedule</string>
<string name="scene_chip_temp">Temperature</string>
<string name="scene_chip_humidity">Humidity</string>
<string name="scene_chip_socket">Socket</string>
<string name="scene_chip_remote">Remote</string>
<string name="scene_chip_delay">Delay</string>
<string name="scene_chip_notify">Notify</string>
<string name="scene_chip_above">&gt; Above</string>
<string name="scene_chip_below">&lt; Below</string>
<string name="scene_chip_power_on">Turn ON</string>
<string name="scene_chip_power_off">Turn OFF</string>
<string name="scene_threshold_temp">Target threshold: %1$d °C</string>
<string name="scene_threshold_hum">Target threshold: %1$d %%</string>
<string name="scene_target_socket_state">Require socket state to be:</string>
<string name="scene_no_virtual_remotes">No virtual remotes found; using infrared controller by default.</string>
<string name="scene_remote_button_slot_label">Button Name / Slot</string>
<string name="scene_remote_button_name_label">Button Name</string>
<string name="scene_empty_triggers_hint">No triggers configured. Tap + below to add.</string>
<string name="scene_empty_conditions_hint">No conditions configured. Tap + below to add.</string>
<string name="scene_empty_actions_hint">No actions configured. Tap + below to add.</string>
<string name="scene_summary_triggers_and_actions">%1$d triggers · %2$d actions</string>
<string name="scene_summary_actions_only">%1$d actions</string>
<string name="scene_exec_ready_hint">Tap the button below to run immediately</string>
<string name="scene_exec_rerun">Re-run</string>
<string name="scene_step_format">Step %1$d: %2$s</string>
<string name="scene_trigger_desc_temp">Temp %1$s %2$s°C</string>
<string name="scene_trigger_desc_humidity">Humidity %1$s %2$s%%</string>
<string name="scene_trigger_desc_device">%1$s state changed to %2$s</string>
<string name="scene_condition_desc_temp">Temp must be %1$s %2$s°C</string>
<string name="scene_condition_desc_humidity">Humidity must be %1$s %2$s%%</string>
<string name="scene_condition_desc_socket">%1$s is %2$s</string>
<string name="scene_condition_desc_time">Time is between %1$s ~ %2$s</string>
<string name="scene_device_name_label">Device Name (Gateway Identifier)</string>
<string name="scene_loading_scene">Loading scene...</string>
<string name="scene_load_failed">Failed to load scene.</string>
<string name="scene_default_button_power">Power</string>
<string name="scene_default_notify_title">Scene Completed</string>
<string name="scene_default_notify_msg">Automation task executed successfully</string>
<!-- Device list -->
<string name="device_list_title">Device List</string>
<string name="device_list_description">Select an ABox terminal device to control or check status.</string>
<string name="device_socket_title">Smart Socket</string>
<string name="device_socket_description">Access smart socket features.</string>
<string name="device_temperature_humidity_title">Temperature &amp; Humidity Sensor</string>
<string name="device_temperature_humidity_description">View temperature and humidity status.</string>
<string name="device_infrared_title">Infrared Controller</string>
<string name="device_infrared_description">Access infrared controller features.</string>
<string name="infrared_control_action">Infrared Control</string>
<string name="infrared_session_invalid_message">ABox session is not authenticated. Please log in first.</string>
<!-- Socket strings -->
<string name="socket_title">Smart Socket</string>
<string name="socket_status_label">Power Status</string>
<string name="socket_status_on">ON</string>
<string name="socket_status_off">OFF</string>
<string name="socket_status_offline">Offline</string>
<string name="socket_status_unknown">Unknown</string>
<string name="socket_action_toggle">Toggle Power</string>
<string name="socket_operating_label">Updating socket state…</string>
<string name="socket_confirmed_at">Confirmed at %1$s</string>
<!-- Temperature & Humidity strings -->
<string name="th_title">Temperature &amp; Humidity</string>
<string name="th_temperature_label">Temperature</string>
<string name="th_humidity_label">Humidity</string>
<string name="th_unit_celsius">°C</string>
<string name="th_unit_percent">%</string>
<string name="th_read_only_notice">Sensor Device (Read-only)</string>
<string name="th_read_only_description">This sensor provides real-time environmental telemetry and does not support remote control actions.</string>
<string name="th_no_data_title">No Sensor Data</string>
<string name="th_no_data_description">No telemetry data has been received from this sensor. Tap Refresh to query again.</string>
<string name="th_last_updated">Updated at %1$s</string>
<!-- Common action & error strings -->
<string name="action_refresh">Refresh</string>
<string name="action_retry">Retry</string>
<string name="action_back">Back</string>
<string name="action_edit_device_name">Edit Device Name</string>
<string name="dialog_edit_device_name_title">Edit Device Name</string>
<string name="dialog_edit_device_name_hint">Enter gateway device name</string>
<string name="action_confirm">Confirm</string>
<string name="action_cancel">Cancel</string>
<string name="status_loading">Loading…</string>
<string name="status_device_offline_banner">Device is offline. Commands cannot be sent.</string>
<string name="error_authentication">Authentication failed. Please log in again.</string>
<string name="error_token_invalid">Session expired. Please log in again.</string>
<string name="error_network">Network error. Please check your connection.</string>
<string name="error_device_not_found">Device not found.</string>
<string name="error_device_offline">Device is currently offline.</string>
<string name="error_invalid_request">Invalid request parameters.</string>
<string name="error_operation_failed">Operation failed. Please try again.</string>
<string name="error_no_data">No sensor data available.</string>
<string name="error_unknown">An unexpected error occurred.</string>
<!-- Interface settings -->
<string name="settings_title">Interface settings</string>
<string name="settings_style_section">Style</string>
<string name="settings_renderer_label">Renderer</string>
<string name="settings_renderer_description">Choose the interface renderer used by the app</string>
<string name="settings_renderer_material3">Material</string>
<string name="settings_renderer_miuix">Miuix</string>
<string name="settings_theme_section">Theme</string>
<string name="settings_theme_label">Theme mode</string>
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic color</string>
<string name="settings_dynamic_color_description">Generate interface colors from the system wallpaper</string>
<string name="settings_surface_section">Surface effects</string>
<string name="settings_blur">Blur effects</string>
<string name="settings_blur_description">Enable background blur on supported screens</string>
<string name="settings_navigation">Navigation style</string>
<string name="settings_navigation_standard">Standard navigation</string>
<string name="settings_navigation_floating">Floating navigation</string>
<string name="settings_feedback_section">Interaction feedback</string>
<string name="settings_haptics">Haptic feedback</string>
<string name="settings_haptics_description">Provide subtle vibration feedback for actions</string>
<string name="settings_accessibility_section">System accessibility</string>
<string name="settings_accessibility_card_title">Follow system accessibility settings</string>
<string name="settings_accessibility_description">Animation, font size, contrast and other accessibility options are controlled by the system.</string>
<string name="settings_error_save">Unable to save interface settings. Try again.</string>
<string name="settings_style_entry">Interface style</string>
<string name="settings_theme_entry">Theme</string>
<string name="settings_theme_entry_description">Colors, brightness, effects and scale</string>
<string name="settings_theme_detail_title">Theme</string>
<string name="settings_renderer_primary">Material</string>
<string name="settings_renderer_secondary">Miuix</string>
<string name="settings_navigation_floating_description">Use a detached navigation surface above page content</string>
<string name="settings_accent_label">Accent color</string>
<string name="settings_scale">Interface scale</string>
<string name="settings_scale_description">Adjust the size of interface elements</string>
<string name="settings_color_style">Color style</string>
<string name="settings_color_style_tonal">Tonal Spot</string>
<string name="settings_color_style_expressive">Expressive</string>
<string name="settings_color_style_fidelity">Fidelity</string>
<string name="settings_color_standard">Color standard</string>
<string name="settings_color_standard_2021">Spec 2021</string>
<string name="settings_color_standard_2025">Spec 2025</string>
<string name="settings_liquid_glass">Liquid glass</string>
<string name="settings_liquid_glass_description">Apply a translucent glass surface to floating navigation</string>
<string name="settings_predictive_back">Predictive back gesture</string>
<string name="settings_predictive_back_description">Preview the destination while swiping back</string>
<string name="settings_tap_to_choose">Tap to choose</string>
<string name="settings_requires_android_13">Requires Android 13 or later</string>
<string name="settings_requires_floating_navigation">Enable floating navigation first</string>
<!-- Redesigned device center and account surfaces -->
<string name="device_center_heading">Connected devices</string>
<string name="device_center_description">Select a device to control it or view its current state</string>
<string name="device_center_loading">Loading devices</string>
<string name="device_center_empty_title">No manageable devices configured</string>
<string name="device_center_empty_description">Configure a gateway device name and type first</string>
<string name="device_center_configure">Configure device</string>
<string name="device_center_error">Unable to load devices</string>
<string name="device_center_configure_title">Configure device</string>
<string name="device_center_device_name">Device name</string>
<string name="device_status_unavailable">Status unavailable</string>
<string name="device_status_offline">Offline</string>
<string name="device_status_switching">Switching</string>
<string name="device_status_awaiting_confirmation">Awaiting confirmation</string>
<string name="device_status_on">On</string>
<string name="device_status_off">Off</string>
<string name="device_status_confirmed">State confirmed</string>
<string name="device_status_waiting_first_confirmation">Waiting for first confirmation</string>
<string name="device_open_control_panel">Control panel</string>
<string name="device_sensor_subtitle">Environment sensor · Read-only monitoring</string>
<string name="device_live_reading">Live device reading</string>
<string name="device_details">Details</string>
<string name="device_metric_no_data">No data</string>
<string name="device_infrared_subtitle">Infrared controller · %1$d virtual remotes</string>
<string name="device_infrared_mounted">Mounted: %1$s</string>
<string name="device_infrared_no_remotes">No remotes</string>
<string name="device_infrared_slots">Hardware slots %1$d/%2$d</string>
<string name="device_infrared_slots_unavailable">Slot state unavailable</string>
<string name="device_infrared_center">Infrared center</string>
<string name="account_current">Current account</string>
<string name="account_greeting">Hello, %1$s</string>
<string name="account_connection_method">Connection method</string>
<string name="account_interface_settings">Theme</string>
<string name="account_interface_settings_desc">Colors, brightness, effects and scale</string>
<string name="account_logout">Sign out</string>
<string name="account_logout_desc">End the current connection and return to sign-in</string>
<string name="account_logout_confirm">Sign out of the current session?</string>
<string name="account_about">About</string>
<string name="account_about_desc">App information, version, and development team</string>
<string name="about_title">About</string>
<string name="about_tagline">ABox smart home management for Android</string>
<string name="about_app_info">App information</string>
<string name="about_version">Version</string>
<string name="about_package">Package</string>
<string name="about_platform">Platform</string>
<string name="about_platform_value">Android phones and tablets</string>
<string name="about_product">Product</string>
<string name="about_product_desc">ABox Home connects to an ABox gateway through ABSDK and provides device control, infrared remotes, scenes, automation, and activity history. Local Experience offers an isolated simulation environment without using ABox credentials.</string>
<string name="about_team">Development team</string>
<string name="about_role_foundation">Project architecture and development across all modules</string>
<string name="about_role_devices">Device catalog and navigation</string>
<string name="about_role_environment">Socket and environment sensors</string>
<string name="about_role_infrared">Infrared remotes</string>
<string name="about_role_scenes">Scenes and integration</string>
<string name="about_copyright">Developed by Flagship.</string>
<string name="about_unknown">Unknown</string>
<string name="about_link_github">GitHub profile</string>
<string name="about_link_personal">Personal website</string>
<string name="scene_create_sheet_title">New command</string>
<string name="scene_create_sheet_description">Choose how the command starts</string>
<string name="scene_create_manual_description">Run manually for frequently used device combinations.</string>
<string name="scene_create_automatic_description">Run automatically from time, device, or sensor conditions.</string>
<string name="socket_detail_subtitle">Smart socket · confirmed control</string>
<string name="socket_name_settings">Set socket device name</string>
<string name="sensor_name_settings">Set sensor device name</string>
<string name="gateway_device_name">Gateway device name</string>
<string name="gateway_device_name_socket_desc">Used to match and control the gateway device. It must exactly match the gateway name.</string>
<string name="gateway_device_name_sensor_desc">Used to match and read the gateway device. It must exactly match the gateway name.</string>
<string name="action_save_device_name">Save device name</string>
<string name="socket_confirmation_hint">A command is confirmed only after the gateway reports the resulting state. Refresh while confirmation is pending.</string>
<string name="sensor_current_readings">Current readings</string>
<string name="sensor_monitoring_title">Live environment monitoring</string>
<string name="sensor_monitoring_description">Values come directly from the current device. Missing metrics remain absent; no estimates or placeholders are used.</string>
<string name="infrared_center_title">Infrared remote center</string>
<string name="infrared_settings_title">Infrared device settings</string>
<string name="infrared_settings_subtitle">Remotes, slots, and gateway device name</string>
<string name="infrared_manage_title">Virtual remotes</string>
<string name="infrared_remote_count">%1$d remotes</string>
<string name="infrared_add_remote">Add remote</string>
<string name="infrared_hardware_slots_title">Hardware slots</string>
<string name="infrared_gateway_name_title">Gateway device name</string>
<string name="infrared_gateway_name_desc">Used to match the infrared controller in the gateway. It must exactly match the gateway name.</string>
<string name="infrared_save_name">Save device name</string>
<string name="infrared_edit_remote">Edit remote</string>
<string name="infrared_move_remote">Reorder remote</string>
<string name="infrared_move_up">Move up</string>
<string name="infrared_move_down">Move down</string>
<string name="infrared_delete_remote">Delete remote</string>
<string name="infrared_layout">Panel layout</string>
<string name="infrared_transmission_mode">Transmission mode</string>
</resources>
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.ui
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import com.android.tools.screenshot.PreviewTest
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@PreviewTest
@Preview
@Composable
fun AppSmokePreview() {
ABoxTheme(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
Text("ABox Home")
}
}
@@ -0,0 +1,86 @@
package com.flagship.abox.manager.ui.devicecenter
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.testing.MainDispatcherRule
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DeviceCenterViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
@Test
fun catalogEmissionMapsCardsAndFilterLocally() = runTest {
val profile = ProviderProfileId("p1")
val catalog = TestCatalog(listOf(
descriptor(profile, "plug", DeviceType.SOCKET),
descriptor(profile, "sensor", DeviceType.TEMPERATURE_HUMIDITY),
))
val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository())
viewModel.initialize(profile)
viewModel.setFilter(DeviceCategoryFilter.SOCKET)
assertEquals(listOf("plug"), viewModel.uiState.value.cards.map { it.displayName })
}
@Test
fun profileChangeClearsSelectionAndLoadsNewCards() = runTest {
val first = ProviderProfileId("one")
val second = ProviderProfileId("two")
val catalog = TestCatalog(listOf(descriptor(first, "old", DeviceType.SOCKET)))
val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository())
viewModel.initialize(first)
viewModel.select(catalog.devices.single())
catalog.emit(listOf(descriptor(second, "new", DeviceType.INFRARED)))
viewModel.initialize(second)
assertEquals(second, viewModel.uiState.value.profileId)
assertEquals(null, viewModel.uiState.value.selectedCard)
assertEquals(listOf("new"), viewModel.uiState.value.cards.map { it.displayName })
}
@Test
fun deviceCardKeyHasBundleCompatibleStableRepresentation() {
val first = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1"))
val same = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1"))
val differentProfile = DeviceCardKey(ProviderProfileId("profile-b"), DeviceName("socket-1"))
assertEquals(first.saveableKey(), same.saveableKey())
assertNotEquals(first.saveableKey(), differentProfile.saveableKey())
}
private fun descriptor(profile: ProviderProfileId, name: String, type: DeviceType) =
DeviceDescriptor(DeviceId("${profile.value}:$name"), profile, DeviceName(name), type)
private class TestCatalog(initial: List<DeviceDescriptor>) : DeviceCatalog {
val devices = initial.toMutableList()
private val state = MutableStateFlow<DomainResult<List<DeviceDescriptor>>>(DomainResult.Success(initial))
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = state
fun emit(value: List<DeviceDescriptor>) { devices.clear(); devices.addAll(value); state.value = DomainResult.Success(value) }
}
private class TestConfiguredRepository : ConfiguredDeviceRepository {
override fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> =
MutableStateFlow(DomainResult.Success(emptyList()))
override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor> =
DomainResult.Failure(DomainError.InvalidRequest())
override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit> =
DomainResult.Success(Unit)
}
}
@@ -0,0 +1,91 @@
package com.flagship.abox.manager.ui.navigation
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass
import com.flagship.abox.manager.ui.shell.DetailPresentation
import com.flagship.abox.manager.ui.shell.MainNavigationPresentation
import com.flagship.abox.manager.ui.shell.MainTab
import com.flagship.abox.manager.ui.shell.DeviceSelection
import com.flagship.abox.manager.ui.shell.detailPresentation
import com.flagship.abox.manager.ui.shell.navigationPresentation
import com.flagship.abox.manager.ui.shell.widthClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AuthenticatedNavigationTest {
@Test
fun `profile key change recreates authenticated graph`() {
val firstProfile = ProviderProfileId("first")
val secondProfile = ProviderProfileId("second")
val firstGraph = AuthenticatedGraphState(firstProfile)
.selectTab(MainTab.SCENES)
.selectDevice(DeviceName("socket"), DeviceType.SOCKET)
val recreated = firstGraph.recreateForProfile(secondProfile)
assertEquals(secondProfile, recreated.profileId)
assertEquals(MainTab.DEVICES, recreated.currentTab)
assertNull(recreated.selectedDevice)
}
@Test
fun `reselecting main tab pops to its root`() {
val state = AuthenticatedGraphState(ProviderProfileId("profile"))
.selectTab(MainTab.SCENES)
.pushChild("scene/editor/manual")
val reselected = state.selectTab(MainTab.SCENES)
assertEquals(MainTab.SCENES, reselected.currentTab)
assertEquals(listOf(MainTab.SCENES.route), reselected.tabBackStack(MainTab.SCENES))
}
@Test
fun `compact uses bottom navigation`() {
assertEquals(MainNavigationPresentation.BOTTOM_NAVIGATION, navigationPresentation(widthClass(599)))
}
@Test
fun `medium and expanded use rail`() {
assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(600)))
assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(840)))
}
@Test
fun `profile replacement clears previously selected device`() {
val state = AuthenticatedGraphState(ProviderProfileId("first"))
.selectDevice(DeviceName("sensor"), DeviceType.TEMPERATURE_HUMIDITY)
val replaced = state.recreateForProfile(ProviderProfileId("second"))
assertNull(replaced.selectedDevice)
}
@Test
fun `selected device uses pane on expanded layouts and full screen otherwise`() {
val selection = DeviceSelection(
profileId = ProviderProfileId("profile"),
deviceName = DeviceName("sensor"),
type = DeviceType.TEMPERATURE_HUMIDITY,
)
assertEquals(DetailPresentation.FULL_SCREEN, detailPresentation(widthClass(599), selection))
assertEquals(DetailPresentation.PANE, detailPresentation(widthClass(840), selection))
}
@Test
fun `back closes child route before leaving its main tab`() {
val state = AuthenticatedGraphState(ProviderProfileId("profile"))
.selectTab(MainTab.SCENES)
.pushChild("scene/editor/manual")
val afterBack = state.handleBack()
assertEquals(MainTab.SCENES, afterBack.currentTab)
assertEquals(listOf(MainTab.SCENES.route), afterBack.tabBackStack(MainTab.SCENES))
}
}
@@ -0,0 +1,148 @@
package com.flagship.abox.manager.ui.preferences
import com.flagship.abox.manager.R
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.InterfaceScale
import com.flagship.abox.manager.preferences.ColorStandard
import com.flagship.abox.manager.preferences.ColorStyle
import com.flagship.abox.manager.ui.common.UiText.StringResource
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.UiPreferences
import com.flagship.abox.manager.preferences.UiPreferencesRepository
import com.flagship.abox.manager.preferences.UiPreferencesWriteResult
import com.flagship.abox.manager.testing.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun rendererUpdatePersistsWithoutCallingSessionCoordinator() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX))
advanceUntilIdle()
assertEquals(1, repository.updateCalls.size)
assertEquals(DesignRenderer.MIUIX, repository.value.renderer)
assertEquals(DesignRenderer.MIUIX, viewModel.uiState.value.preferences.renderer)
}
@Test
fun writeFailureLeavesLastValueAndShowsLocalizedError() = runTest {
val repository = RecordingUiPreferencesRepository(
initial = UiPreferences(renderer = DesignRenderer.MATERIAL3),
writeResult = UiPreferencesWriteResult.Failure,
)
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX))
advanceUntilIdle()
assertEquals(1, repository.updateCalls.size)
assertEquals(DesignRenderer.MATERIAL3, viewModel.uiState.value.preferences.renderer)
assertTrue(viewModel.uiState.value.error is StringResource)
assertEquals(
R.string.settings_error_save,
(viewModel.uiState.value.error as StringResource).resId,
)
assertTrue(viewModel.uiState.value.saving.isEmpty())
}
@Test
fun collectsRepositoryChanges() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
repository.emit(
UiPreferences(
renderer = DesignRenderer.MIUIX,
themeMode = AppThemeMode.DARK,
dynamicColorEnabled = false,
blurEnabled = true,
navigationPresentation = NavigationPresentation.FLOATING,
hapticsEnabled = false,
),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.loaded)
assertEquals(repository.value, viewModel.uiState.value.preferences)
}
@Test
fun scaleAndAccentUpdatesPersistThroughRepository() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetInterfaceScale(InterfaceScale.COMPACT))
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetAccent(AccentPreference.ROSE))
advanceUntilIdle()
assertEquals(InterfaceScale.COMPACT, repository.value.interfaceScale)
assertEquals(AccentPreference.ROSE, repository.value.accentPreference)
}
@Test
fun styleSpecificThemeOptionsPersistThroughRepository() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetColorStyle(ColorStyle.FIDELITY))
viewModel.onAction(SettingsUiAction.SetColorStandard(ColorStandard.SPEC_2025))
viewModel.onAction(SettingsUiAction.SetLiquidGlass(true))
viewModel.onAction(SettingsUiAction.SetPredictiveBack(false))
advanceUntilIdle()
assertEquals(ColorStyle.FIDELITY, repository.value.colorStyle)
assertEquals(ColorStandard.SPEC_2025, repository.value.colorStandard)
assertTrue(repository.value.liquidGlassEnabled)
assertEquals(false, repository.value.predictiveBackEnabled)
}
private class RecordingUiPreferencesRepository(
initial: UiPreferences = UiPreferences(),
private val writeResult: UiPreferencesWriteResult = UiPreferencesWriteResult.Success,
) : UiPreferencesRepository {
private val state = MutableStateFlow(initial)
val updateCalls = mutableListOf<(UiPreferences) -> UiPreferences>()
val value: UiPreferences get() = state.value
override fun observe(): Flow<UiPreferences> = state.asStateFlow()
override suspend fun update(
transform: (UiPreferences) -> UiPreferences,
): UiPreferencesWriteResult {
updateCalls += transform
if (writeResult is UiPreferencesWriteResult.Success) {
state.value = transform(state.value)
}
return writeResult
}
fun emit(value: UiPreferences) {
state.value = value
}
}
}
@@ -1,6 +1,10 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConcurrencyMode
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ExecutionStrategy
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
@@ -8,12 +12,16 @@ import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionId
import com.flagship.abox.manager.domain.SceneColor
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
@@ -31,6 +39,8 @@ class SceneEditorViewModelTest {
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
private val deviceCatalog = FakeDeviceCatalog()
private val profileId = ProviderProfileId("local-experience")
private val existingScene = Scene(
@@ -58,8 +68,8 @@ class SceneEditorViewModelTest {
@Test
fun initializeWithNullPreparesNewScene() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
viewModel.initialize(null, AutomationType.MANUAL)
val state = viewModel.state.value
assertFalse(state.isEditing)
@@ -71,12 +81,20 @@ class SceneEditorViewModelTest {
@Test
fun initializeWithExistingSceneLoadsState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
viewModel.initialize(existingScene.id)
// Immediately binds sceneId and enters loading state
assertTrue(viewModel.state.value.isEditing)
assertEquals(existingScene.id, viewModel.state.value.sceneId)
assertTrue(viewModel.state.value.isLoading)
assertFalse(viewModel.state.value.isValid)
advanceUntilIdle()
val state = viewModel.state.value
assertTrue(state.isEditing)
assertFalse(state.isLoading)
assertEquals("Existing Scene", state.name)
assertEquals(1, state.actions.size)
assertTrue(state.isValid)
@@ -85,34 +103,60 @@ class SceneEditorViewModelTest {
@Test
fun addingAndReorderingActionsUpdatesState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("My New Scene")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.addInfraredAction("ir-1", "power")
viewModel.addDelayAction(3)
viewModel.addNotificationAction("Done", "Completed")
advanceUntilIdle()
assertEquals(2, viewModel.state.value.actions.size)
assertEquals(3, viewModel.state.value.actions.size)
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey)
assertTrue(viewModel.state.value.actions[1] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[2] is SceneAction.Notification)
// Move second to first
viewModel.moveAction(1, 0)
assertTrue(viewModel.state.value.actions[0] is SceneAction.InfraredKey)
assertTrue(viewModel.state.value.actions[0] is SceneAction.Delay)
assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
// Remove first action
val firstId = viewModel.state.value.actions[0].id
viewModel.removeAction(firstId)
assertEquals(1, viewModel.state.value.actions.size)
assertEquals(2, viewModel.state.value.actions.size)
}
@Test
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
viewModel.initialize(null, AutomationType.AUTOMATIC)
viewModel.updateName("Auto Rule")
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
viewModel.addSocketPowerCondition("socket-1", SocketPowerState.OFF)
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL)
viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR)
advanceUntilIdle()
val state = viewModel.state.value
assertEquals(1, state.triggers.size)
assertTrue(state.triggers[0] is SceneTrigger.Temperature)
assertEquals(1, state.conditions.size)
assertTrue(state.conditions[0] is SceneCondition.SocketPower)
assertEquals(ConcurrencyMode.PARALLEL, state.concurrencyMode)
assertEquals(ExecutionStrategy.ABORT_ON_ERROR, state.strategy)
assertTrue(state.isValid)
}
@Test
fun saveNewScenePersistsToRepository() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
viewModel.initialize(null)
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
viewModel.initialize(null, AutomationType.MANUAL)
viewModel.updateName("Brand New Scene")
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
advanceUntilIdle()
@@ -124,4 +168,39 @@ class SceneEditorViewModelTest {
assertTrue(viewModel.state.value.isSaved)
assertFalse(viewModel.state.value.isSaving)
}
@Test
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
viewModel.initialize(null, AutomationType.AUTOMATIC)
viewModel.updateName("Auto Without Trigger")
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
advanceUntilIdle()
// Missing triggers -> invalid
assertFalse(viewModel.state.value.isValid)
// Adding trigger makes it valid
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
advanceUntilIdle()
assertTrue(viewModel.state.value.isValid)
}
@Test
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
setupProfile()
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
viewModel.initialize(existingScene.id)
advanceUntilIdle()
viewModel.updateName("Modified Unsaved Name")
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
// Re-invoking initialize with same sceneId should NOT overwrite draft
viewModel.initialize(existingScene.id)
advanceUntilIdle()
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
}
}
@@ -87,4 +87,39 @@ class SceneExecutionViewModelTest {
assertEquals(testScene.id, completed.result.scene.id)
assertEquals(1, completed.result.results.size)
}
@Test
fun initializeIsIdempotentAndPreservesCompletedState() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
// Re-initializing with same sceneId should preserve Completed state
viewModel.initialize(testScene.id)
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
}
@Test
fun executeFailsWhenActiveProfileMismatches() = runTest {
setupProfile()
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.initialize(testScene.id)
advanceUntilIdle()
// Switch to a different profile
profileStore.setActiveProfile(ProviderProfileId("different-profile"))
viewModel.execute()
advanceUntilIdle()
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Failed)
val failed = viewModel.uiState.value as SceneExecutionUiState.Failed
assertEquals("Active profile mismatch", failed.message)
}
}
@@ -1,5 +1,6 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
@@ -12,14 +13,18 @@ import com.flagship.abox.manager.domain.SceneIcon
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
@@ -33,12 +38,16 @@ class SceneListViewModelTest {
private val profileStore = FakeProviderProfileStore()
private val sceneRepository = FakeSceneRepository()
private val socketGateway = FakeSocketGateway()
private val infraredGateway = FakeInfraredGateway()
private val sceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway)
private val profileId = ProviderProfileId("local-experience")
private val testScene = Scene(
private val manualScene = Scene(
id = SceneId("local-experience:scene-1"),
profileId = profileId,
name = "Test Scene",
name = "Manual Scene",
type = AutomationType.MANUAL,
icon = SceneIcon.HOME,
color = SceneColor("#FF8A65"),
actions = listOf(
@@ -50,39 +59,98 @@ class SceneListViewModelTest {
),
),
)
private val autoScene = Scene(
id = SceneId("local-experience:scene-2"),
profileId = profileId,
name = "Auto Rule",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.THERMOSTAT,
color = SceneColor("#26A69A"),
actions = listOf(
SceneAction.Delay(
id = SceneActionId("local-experience:action-2"),
profileId = profileId,
durationSeconds = 5,
),
),
)
@Test
fun loadsScenesForActiveProfile() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
assertEquals(2, viewModel.scenes.value.size)
assertEquals("Manual Scene", viewModel.scenes.value[0].name)
assertEquals("Auto Rule", viewModel.scenes.value[1].name)
}
@Test
fun filterTabFiltersScenes() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.selectTab(SceneFilterTab.MANUAL)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals("Test Scene", viewModel.scenes.value.first().name)
assertEquals(AutomationType.MANUAL, viewModel.scenes.value[0].type)
viewModel.selectTab(SceneFilterTab.AUTOMATIC)
advanceUntilIdle()
assertEquals(1, viewModel.scenes.value.size)
assertEquals(AutomationType.AUTOMATIC, viewModel.scenes.value[0].type)
}
@Test
fun toggleEnabledUpdatesSceneInRepository() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(autoScene))
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.toggleEnabled(autoScene)
advanceUntilIdle()
val updated = viewModel.scenes.value.first()
assertFalse(updated.enabled)
}
@Test
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
profileStore.setActiveProfile(profileId)
sceneRepository.setScenes(profileId, listOf(testScene))
sceneRepository.setScenes(profileId, listOf(manualScene))
val viewModel = SceneListViewModel(sceneRepository, profileStore)
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.scenes.collect {}
}
advanceUntilIdle()
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.deleteScene(testScene.id)
viewModel.deleteScene(manualScene.id)
advanceUntilIdle()
assertNull(viewModel.pendingDelete.value)
@@ -91,11 +159,12 @@ class SceneListViewModelTest {
@Test
fun cancelDeleteClearsPendingState() = runTest {
val viewModel = SceneListViewModel(sceneRepository, profileStore)
viewModel.markPendingDelete(testScene.id)
assertEquals(testScene.id, viewModel.pendingDelete.value)
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
viewModel.markPendingDelete(manualScene.id)
assertEquals(manualScene.id, viewModel.pendingDelete.value)
viewModel.cancelDelete()
assertNull(viewModel.pendingDelete.value)
}
}
@@ -0,0 +1,197 @@
package com.flagship.abox.manager.ui.session
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import com.flagship.abox.manager.ui.common.UiText
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SessionActionsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profile = ProviderProfile(
id = ProviderProfileId("abox"),
type = ProviderType.ABOX,
displayName = "ABox",
)
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun endSessionActionEndsSessionWithoutForgettingCredentials() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.EndSession)
advanceUntilIdle()
assertTrue(fixture.remote.session(profile.id) == null)
assertEquals(
credentials,
fixture.credentials.loadCredentials(profile.id).successValue(),
)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun forgetCredentialsActionKeepsSessionAndDisablesAutoLogin() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.ForgetSavedCredentials)
advanceUntilIdle()
assertEquals(profile.id, fixture.remote.session(profile.id)?.profileId)
assertNull(fixture.credentials.loadCredentials(profile.id).successValue())
assertTrue(!fixture.profiles.loginPreferences(profile.id).autoLoginEnabled)
}
@Test
fun switchProviderActionClearsProfileOnlyAfterLogout() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.login(profile.id, credentials)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.SwitchProvider)
advanceUntilIdle()
assertEquals(profile.id, fixture.remote.logoutProfileId)
assertNull(fixture.profiles.activeProfile())
}
@Test
fun validateConnectionActionDelegatesWithoutRelogin() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.setValidationResult(
profile.id,
com.flagship.abox.manager.domain.DomainResult.Success(SessionValidity.INVALID),
)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.ValidateConnection)
advanceUntilIdle()
assertEquals(SessionValidity.INVALID, fixture.remote.lastValidation)
assertEquals(0, fixture.remote.loginCalls)
}
@Test
fun mappedActionErrorNeverExposesDomainDetail() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.logoutError = DomainError.Unknown(detail = "keystore secret detail")
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.dispatch(SessionUiAction.EndSession)
advanceUntilIdle()
assertEquals(
UiText.StringResource(R.string.error_unknown),
viewModel.uiState.value.errorMessage,
)
}
@Test
fun dismissErrorActionClearsMappedError() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.logoutError = DomainError.Network(detail = "private network detail")
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.EndSession)
advanceUntilIdle()
viewModel.onAction(SessionUiAction.DismissError)
assertNull(viewModel.uiState.value.errorMessage)
}
private class Fixture {
val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore()
val remote = RecordingSessionGateway(profiles)
val local = FakeLocalSessionGateway()
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
suspend fun activateAuthenticatedProfile() {
profiles.saveProfile(
ProviderProfile(
id = ProviderProfileId("abox"),
type = ProviderType.ABOX,
displayName = "ABox",
),
)
profiles.setActiveProfile(ProviderProfileId("abox"))
}
}
private class RecordingSessionGateway(
private val profiles: FakeProviderProfileStore,
private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : com.flagship.abox.manager.domain.SessionGateway by delegate {
var loginCalls = 0
private set
var logoutProfileId: ProviderProfileId? = null
private set
var lastValidation: SessionValidity? = null
private set
var logoutError: DomainError? = null
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
) = delegate.login(profileId, credentials).also { loginCalls++ }
override suspend fun validateSession(profileId: ProviderProfileId) = delegate
.validateSession(profileId)
.also { result ->
lastValidation = (result as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
}
override suspend fun logout(profileId: ProviderProfileId) = logoutError?.let {
logoutProfileId = profiles.activeProfile()?.id
com.flagship.abox.manager.domain.DomainResult.Failure(it)
} ?: delegate.logout(profileId).also {
logoutProfileId = profiles.activeProfile()?.id
}
suspend fun session(profileId: ProviderProfileId) = delegate.observeSession(profileId)
.let { flow -> flow.first() }
fun setValidationResult(
profileId: ProviderProfileId,
result: com.flagship.abox.manager.domain.DomainResult<SessionValidity>,
) {
delegate.setValidationResult(profileId, result)
}
}
}
private fun <T> com.flagship.abox.manager.domain.DomainResult<T>.successValue(): T? =
(this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
@@ -9,7 +9,7 @@ class MainTabTest {
@Test
fun `all main tabs have distinct non-empty routes and valid label resources`() {
val tabs = MainTab.values()
val tabs = MainTab.entries
assertEquals(3, tabs.size)
val routes = tabs.map { it.route }
@@ -0,0 +1,223 @@
package com.flagship.abox.manager.ui.socket
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import com.flagship.abox.manager.testing.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SocketViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
private lateinit var fakeSocketGateway: FakeSocketGateway
private val profileId = ProviderProfileId("test-profile")
private val deviceName = DeviceName("test-socket")
@Before
fun setUp() {
fakeSocketGateway = FakeSocketGateway()
}
@Test
fun initialization_fromSavedStateHandle_loadsStatusSuccessfully() = runTest {
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
val savedStateHandle = SavedStateHandle(
mapOf(
"profileId" to profileId.value,
"deviceName" to deviceName.value,
),
)
val viewModel = SocketViewModel(fakeSocketGateway, savedStateHandle)
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals(SocketPowerState.ON, state.powerState)
assertTrue(state.isPowerOn)
assertFalse(state.isLoading)
assertFalse(state.isOffline)
assertNull(state.errorMessage)
assertNotNull(state.lastConfirmedTimeMillis)
}
@Test
fun initialization_viaEvent_loadsStatusSuccessfully() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
assertNull(viewModel.state.value.profileId)
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals(SocketPowerState.OFF, state.powerState)
assertFalse(state.isPowerOn)
assertFalse(state.isLoading)
}
@Test
fun loadStatus_whenDeviceOffline_setsOfflineState() = runTest {
fakeSocketGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.isOffline)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun loadStatus_whenNetworkFails_setsErrorMessage() = runTest {
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Timeout"))
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertFalse(state.isOffline)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun togglePower_fromOffToOn_updatesState() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.TogglePower)
val state = viewModel.state.value
assertEquals(SocketPowerState.ON, state.powerState)
assertTrue(state.isPowerOn)
assertFalse(state.isOperating)
assertNull(state.errorMessage)
}
@Test
fun togglePower_fromOnToOff_updatesState() = runTest {
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.TogglePower)
val state = viewModel.state.value
assertEquals(SocketPowerState.OFF, state.powerState)
assertFalse(state.isPowerOn)
assertFalse(state.isOperating)
}
@Test
fun setPower_explicitState_updatesStateCorrectly() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
}
@Test
fun setPower_whenGatewayFails_setsErrorMessageAndPreservesState() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
fakeSocketGateway.setError(profileId, deviceName, DomainError.OperationFailed(detail = "Command failed"))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
val state = viewModel.state.value
assertEquals(SocketPowerState.OFF, state.powerState)
assertFalse(state.isOperating)
assertNotNull(state.errorMessage)
}
@Test
fun commandSuccessReadsBackConfirmedState() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
assertEquals(SocketOperationState.IdleConfirmed, viewModel.state.value.operation)
}
@Test
fun readbackFailureKeepsLastConfirmedStateAndMarksAwaitingConfirmation() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Readback failed"))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
assertEquals(
SocketOperationState.AwaitingConfirmation(SocketPowerState.ON),
viewModel.state.value.operation,
)
}
@Test
fun refresh_queriesUpdatedGatewayStatus() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
// Modify gateway backend directly
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
viewModel.onEvent(SocketUiEvent.Refresh)
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
}
@Test
fun changeDeviceName_updatesNameAndReloads() = runTest {
val newDevice = DeviceName("socket-2")
fakeSocketGateway.setPower(profileId, newDevice, SocketPowerState.ON)
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.ChangeDeviceName(newDevice))
assertEquals(newDevice, viewModel.state.value.deviceName)
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
}
@Test
fun clearError_resetsErrorMessage() = runTest {
fakeSocketGateway.setError(profileId, deviceName, DomainError.InvalidRequest())
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertNotNull(viewModel.state.value.errorMessage)
viewModel.onEvent(SocketUiEvent.ClearError)
assertNull(viewModel.state.value.errorMessage)
}
}
@@ -0,0 +1,205 @@
package com.flagship.abox.manager.ui.temperaturehumidity
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway
import com.flagship.abox.manager.testing.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TemperatureHumidityViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
private lateinit var fakeGateway: FakeTemperatureHumidityGateway
private val profileId = ProviderProfileId("test-profile")
private val deviceName = DeviceName("test-sensor")
@Before
fun setUp() {
fakeGateway = FakeTemperatureHumidityGateway()
}
@Test
fun initialization_fromSavedStateHandle_loadsReadingSuccessfully() = runTest {
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "26.5",
humidity = "58",
),
)
val savedStateHandle = SavedStateHandle(
mapOf(
"profileId" to profileId.value,
"deviceName" to deviceName.value,
),
)
val viewModel = TemperatureHumidityViewModel(fakeGateway, savedStateHandle)
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals("26.5", state.temperature)
assertEquals("58", state.humidity)
assertEquals("26.5 °C", state.formattedTemperature)
assertEquals("58 %", state.formattedHumidity)
assertTrue(state.hasValidData)
assertFalse(state.isLoading)
assertFalse(state.isOffline)
assertFalse(state.hasNoData)
assertNull(state.errorMessage)
assertNotNull(state.lastUpdatedTimeMillis)
}
@Test
fun initialization_viaEvent_loadsReadingSuccessfully() = runTest {
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
assertNull(viewModel.state.value.profileId)
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals("23.5", state.temperature)
assertEquals("45", state.humidity)
assertFalse(state.isLoading)
assertTrue(state.hasValidData)
}
@Test
fun loadReading_whenDeviceOffline_setsOfflineState() = runTest {
fakeGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.isOffline)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun loadReading_whenNoDataError_setsNoDataState() = runTest {
fakeGateway.setError(profileId, deviceName, DomainError.NoData())
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.hasNoData)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun loadReading_whenBlankValuesReturned_handlesAsNoData() = runTest {
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "",
humidity = "",
),
)
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.hasNoData)
assertNull(state.temperature)
assertNull(state.humidity)
assertNull(state.formattedTemperature)
assertNull(state.formattedHumidity)
assertFalse(state.hasValidData)
}
@Test
fun refresh_queriesUpdatedReading() = runTest {
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
assertEquals("23.5", viewModel.state.value.temperature)
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "30.2",
humidity = "65",
),
)
viewModel.onEvent(TemperatureHumidityUiEvent.Refresh)
val state = viewModel.state.value
assertEquals("30.2", state.temperature)
assertEquals("65", state.humidity)
assertEquals("30.2 °C", state.formattedTemperature)
assertEquals("65 %", state.formattedHumidity)
assertFalse(state.isRefreshing)
}
@Test
fun changeDeviceName_updatesNameAndReloads() = runTest {
val newDevice = DeviceName("th-2")
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = newDevice,
temperature = "26.8",
humidity = "55",
),
)
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
assertEquals("23.5", viewModel.state.value.temperature)
viewModel.onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(newDevice))
assertEquals(newDevice, viewModel.state.value.deviceName)
assertEquals("26.8", viewModel.state.value.temperature)
assertEquals("55", viewModel.state.value.humidity)
}
@Test
fun clearError_resetsErrorMessage() = runTest {
fakeGateway.setError(profileId, deviceName, DomainError.Network())
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
assertNotNull(viewModel.state.value.errorMessage)
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
assertNull(viewModel.state.value.errorMessage)
}
@Test
fun partialReadingKeepsAvailableMetric() = runTest {
fakeGateway.setReading(
TemperatureHumidityReading(profileId, deviceName, temperature = "26.5", humidity = null),
)
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
assertEquals("26.5", viewModel.state.value.temperature)
assertNull(viewModel.state.value.humidity)
}
}
+21 -8
View File
@@ -1,11 +1,17 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.compose.screenshot)
}
android {
namespace = "com.flagship.abox.manager.designsystem"
compileSdk = 34
compileSdk = 37
experimentalProperties["android.experimental.enableScreenshotTest"] = true
defaultConfig {
minSdk = 26
@@ -15,25 +21,32 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlinOptions {
jvmTarget = "17"
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
dependencies {
implementation(project(":android:core:preferences"))
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
implementation(libs.androidx.activity.compose)
implementation(libs.miuix.ui.android)
debugImplementation(libs.compose.ui.tooling)
screenshotTestImplementation(libs.compose.screenshot.test)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.activity.compose)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
}
@@ -0,0 +1,183 @@
package com.flagship.abox.manager.designsystem
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ABoxComponentSemanticsTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun allInteractiveControlsHaveAtLeast48DpBounds() {
composeRule.setContent {
ABoxTheme {
ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) {
Text("确定")
}
ABoxIconButton(
key = ABoxIconKey.ADD,
contentDescription = "添加设备",
onClick = {},
modifier = Modifier.testTag("icon"),
)
ABoxSwitch(
checked = false,
onCheckedChange = {},
contentDescription = "启用设备",
modifier = Modifier.testTag("switch"),
)
}
}
listOf("button", "icon", "switch").forEach { tag ->
composeRule.onNodeWithTag(tag)
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
}
}
@Test
fun operableSwitchExposesChineseLabelRoleStateAndToggleAction() {
var checked = false
composeRule.setContent {
ABoxTheme {
ABoxSwitch(
checked = checked,
onCheckedChange = { checked = it },
contentDescription = "启用设备",
modifier = Modifier.testTag("operable-switch"),
)
}
}
val switchNode = composeRule.onNodeWithTag("operable-switch")
.assertContentDescriptionEquals("启用设备")
.assertIsOff()
.assertHasClickAction()
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
.fetchSemanticsNode()
assertEquals(Role.Switch, switchNode.config[SemanticsProperties.Role])
assertTrue(switchNode.config.contains(SemanticsProperties.ToggleableState))
composeRule.onNodeWithTag("operable-switch").performClick()
composeRule.waitForIdle()
assertEquals(true, checked)
}
@Test
fun cardBodyAndQuickActionExposeIndependentSemanticNodes() {
composeRule.setContent {
ABoxTheme {
ABoxCard(
title = "客厅插座",
subtitle = "在线",
onClick = {},
quickAction = ABoxCardQuickAction(
key = ABoxIconKey.REFRESH,
contentDescription = "刷新设备",
onClick = {},
),
)
}
}
composeRule.onNodeWithText("客厅插座").assertIsDisplayed().assertHasClickAction()
composeRule.onNodeWithContentDescription("刷新设备").assertIsDisplayed().assertHasClickAction()
}
@Test
fun renderersExposeSameChineseStateDescription() {
var renderer by mutableStateOf(DesignRenderer.MATERIAL3)
composeRule.setContent {
ABoxTheme(
preferences = UiPreferences(
renderer = renderer,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在处理",
)
}
}
composeRule.onNodeWithContentDescription("正在处理").assertExists()
composeRule.runOnUiThread { renderer = DesignRenderer.MIUIX }
composeRule.waitForIdle()
composeRule.onNodeWithContentDescription("正在处理").assertExists()
}
@Test
fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() {
composeRule.setContent {
ABoxThemeContent(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
systemInDarkTheme = false,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = null,
reducedMotion = true,
) {
ABoxLoadingState(contentDescription = "正在加载")
}
}
val loadingNode = composeRule.onNodeWithContentDescription("正在加载")
.assertExists()
.fetchSemanticsNode()
assertEquals("静态加载", loadingNode.config[SemanticsProperties.StateDescription])
}
@Test
fun interactiveIconRequiresNonBlankChineseDescription() {
composeRule.setContent {
ABoxTheme {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = {},
)
}
}
composeRule.onNodeWithContentDescription("返回")
.assertContentDescriptionEquals("返回")
}
}
@@ -1,51 +1,97 @@
package com.flagship.abox.manager.designsystem
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
import androidx.compose.material3.Icon as MaterialIcon
@Composable
fun ABoxAppSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val colors = LocalABoxColors.current
val blurEnabled = LocalABoxBlurEnabled.current
Surface(
modifier = modifier,
color = if (blurEnabled) colors.surfaceContainer.copy(alpha = 0.92f) else colors.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
content = content,
)
}
/** Compatibility surface retained for existing feature modules. */
@Composable
fun ABoxSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Surface(modifier = modifier, content = content)
}
) = ABoxAppSurface(modifier = modifier, content = content)
@Composable
fun ABoxButton(
@@ -56,7 +102,7 @@ fun ABoxButton(
) {
Button(
onClick = onClick,
modifier = modifier,
modifier = modifier.defaultMinSize(minHeight = 48.dp),
enabled = enabled,
colors = ButtonDefaults.buttonColors(),
content = content,
@@ -64,14 +110,437 @@ fun ABoxButton(
}
@Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(modifier = modifier)
fun ABoxIconButton(
key: ABoxIconKey,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
require(contentDescription.isNotBlank()) {
"ABoxIconButton contentDescription must not be blank"
}
IconButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp),
) {
ABoxIcon(key = key, contentDescription = contentDescription)
}
}
/**
* Circular user avatar displaying the initial character of username or a default person icon.
* Styled with an accented profile ring.
*/
@Composable
fun ABoxSwitch(
checked: Boolean,
onCheckedChange: ((Boolean) -> Unit)?,
contentDescription: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
require(contentDescription.isNotBlank()) {
"ABoxSwitch contentDescription must not be blank"
}
Box(
modifier = modifier
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.toggleable(
value = checked,
enabled = enabled && onCheckedChange != null,
role = Role.Switch,
onValueChange = { onCheckedChange?.invoke(it) },
)
.semantics {
this.contentDescription = contentDescription
},
contentAlignment = Alignment.Center,
) {
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.clearAndSetSemantics { },
)
}
}
@Composable
fun ABoxFilterChip(
selected: Boolean,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
leadingIcon: (@Composable () -> Unit)? = null,
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label) },
modifier = modifier.defaultMinSize(minHeight = 48.dp),
leadingIcon = leadingIcon,
colors = FilterChipDefaults.filterChipColors(),
)
}
@Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
val reducedMotion = LocalABoxReducedMotion.current
if (reducedMotion) {
CircularProgressIndicator(
progress = { 0.5f },
modifier = modifier,
)
} else {
CircularProgressIndicator(modifier = modifier)
}
}
@Composable
fun ABoxLoadingState(
contentDescription: String,
modifier: Modifier = Modifier,
) {
require(contentDescription.isNotBlank()) {
"ABoxLoadingState contentDescription must not be blank"
}
val reducedMotion = LocalABoxReducedMotion.current
Column(
modifier = modifier
.fillMaxWidth()
.semantics {
this.contentDescription = contentDescription
stateDescription = if (reducedMotion) "静态加载" else "动态加载"
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxLoadingIndicator(modifier = Modifier.size(32.dp))
Text(text = contentDescription)
}
}
enum class ABoxStatus {
ONLINE,
OFFLINE,
PENDING,
WARNING,
ERROR,
}
@Composable
fun ABoxStatusBanner(
state: ABoxStatus,
contentDescription: String,
modifier: Modifier = Modifier,
message: String = contentDescription,
) {
require(contentDescription.isNotBlank()) {
"ABoxStatusBanner contentDescription must not be blank"
}
val colors = LocalABoxColors.current
val (iconKey, color) = when (state) {
ABoxStatus.ONLINE -> ABoxIconKey.ONLINE to colors.success
ABoxStatus.OFFLINE -> ABoxIconKey.OFFLINE to colors.offline
ABoxStatus.PENDING -> ABoxIconKey.PENDING to colors.pending
ABoxStatus.WARNING -> ABoxIconKey.WARNING to colors.warning
ABoxStatus.ERROR -> ABoxIconKey.ERROR to colors.error
}
Surface(
modifier = modifier
.fillMaxWidth()
.semantics { this.contentDescription = contentDescription },
color = color.copy(alpha = 0.14f),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ABoxIcon(key = iconKey, contentDescription = "${contentDescription}图标", modifier = Modifier.size(22.dp))
Text(text = message, color = color, style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
fun ABoxEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxIcon(ABoxIconKey.DEVICES, contentDescription = title, modifier = Modifier.size(40.dp))
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium)
action?.invoke()
}
}
@Composable
fun ABoxErrorState(
title: String,
description: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxIcon(ABoxIconKey.ERROR, contentDescription = title, modifier = Modifier.size(40.dp))
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium)
if (onRetry != null) {
ABoxButton(onClick = onRetry) { Text("重试") }
}
}
}
@Composable
fun ABoxCard(
title: String,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
leadingContent: (@Composable () -> Unit)? = null,
quickAction: ABoxCardQuickAction? = null,
content: (@Composable ColumnScope.() -> Unit)? = null,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(LocalABoxShapes.current.mediumCornerRadius),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingContent?.let {
Box(modifier = Modifier.padding(end = 12.dp), contentAlignment = Alignment.Center) {
it()
}
}
Column(
modifier = Modifier
.weight(1f)
.then(
if (onClick != null) {
Modifier
.clickable(
role = Role.Button,
onClickLabel = "打开$title",
onClick = onClick,
)
.semantics { role = Role.Button }
} else {
Modifier
},
)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
content?.invoke(this)
}
if (quickAction != null) {
Spacer(Modifier.width(8.dp))
ABoxIconButton(
key = quickAction.key,
contentDescription = quickAction.contentDescription,
onClick = quickAction.onClick,
)
}
}
}
}
data class ABoxCardQuickAction(
val key: ABoxIconKey,
val contentDescription: String,
val onClick: () -> Unit,
)
@Composable
fun ABoxDialog(
visible: Boolean,
title: String,
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
confirmText: String = "确定",
dismissText: String = "取消",
content: @Composable () -> Unit,
) {
if (visible) {
AlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
title = { Text(title) },
text = content,
confirmButton = {
ABoxButton(onClick = onConfirm) { Text(confirmText) }
},
dismissButton = {
TextButton(onClick = onDismissRequest) { Text(dismissText) }
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ABoxBottomSheet(
visible: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
if (visible) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
content = content,
)
}
}
}
@Composable
fun ABoxPreferenceGroup(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp),
)
content()
}
}
data class ABoxNavigationItem(
val key: ABoxIconKey,
val label: String,
val contentDescription: String = label,
)
@Composable
fun ABoxNavigationSuite(
items: List<ABoxNavigationItem>,
selectedKey: ABoxIconKey,
onItemSelected: (ABoxIconKey) -> Unit,
modifier: Modifier = Modifier,
) {
ABoxNavigationBar(modifier = modifier) {
items.forEach { item ->
ABoxNavigationBarItem(
selected = item.key == selectedKey,
onClick = { onItemSelected(item.key) },
icon = {
ABoxIcon(
key = item.key,
contentDescription = item.contentDescription,
)
},
label = { Text(item.label) },
)
}
}
}
@Composable
fun ABoxDeviceDetailScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null,
icon: ABoxIconKey = ABoxIconKey.UNKNOWN_DEVICE,
action: (@Composable () -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = onBack,
)
Surface(
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier.padding(start = 4.dp),
) {
ABoxIcon(
key = icon,
contentDescription = subtitle ?: title,
modifier = Modifier.padding(9.dp).size(24.dp),
)
}
Column(
modifier = Modifier.weight(1f).padding(start = 12.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
action?.invoke()
}
},
content = content,
)
}
/** Circular user avatar displaying the initial character of username or a default person icon. */
@Composable
fun ABoxUserAvatar(
name: String?,
@@ -83,73 +552,73 @@ fun ABoxUserAvatar(
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
shape = CircleShape,
)
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.clickable(onClickLabel = contentDescription, onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (initial != null) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer)
.border(
width = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
shape = CircleShape,
),
)
} else {
Icon(
imageVector = Icons.Default.Person,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(size * 0.6f),
)
contentAlignment = Alignment.Center,
) {
if (initial != null) {
Text(
text = initial,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
} else {
MaterialIcon(
imageVector = ABoxIconMapperForAccount,
contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(size * 0.6f),
)
}
}
}
}
/**
* Material 3 pill top bar containing leading icon, title/provider label,
* an optional '+' action button, and user avatar.
*/
private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle
/** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */
@Composable
fun ABoxTopPillBar(
title: String,
onAvatarClick: () -> Unit,
modifier: Modifier = Modifier,
userName: String? = null,
leadingIcon: ImageVector = Icons.Default.LocationOn,
leadingIcon: ImageVector = Icons.Outlined.Bolt,
onAddClick: (() -> Unit)? = null,
addActionContentDescription: String? = "Add",
avatarContentDescription: String? = "Account",
addActionContentDescription: String? = "添加",
avatarContentDescription: String? = "账户",
) {
Surface(
modifier = modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(28.dp),
modifier = modifier.fillMaxWidth().height(64.dp),
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
MaterialIcon(
imageVector = leadingIcon,
contentDescription = null,
contentDescription = title,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp),
modifier = Modifier.padding(start = 8.dp).size(22.dp),
)
Text(
text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@@ -158,20 +627,13 @@ fun ABoxTopPillBar(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (onAddClick != null) {
IconButton(
ABoxIconButton(
key = ABoxIconKey.ADD,
contentDescription = addActionContentDescription ?: "添加",
onClick = onAddClick,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = addActionContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
)
}
ABoxUserAvatar(
name = userName,
onClick = onAvatarClick,
@@ -182,9 +644,6 @@ fun ABoxTopPillBar(
}
}
/**
* ABox standard Material 3 Navigation Bar with pill indicator styling.
*/
@Composable
fun ABoxNavigationBar(
modifier: Modifier = Modifier,
@@ -198,9 +657,6 @@ fun ABoxNavigationBar(
)
}
/**
* ABox standard Navigation Bar Item.
*/
@Composable
fun RowScope.ABoxNavigationBarItem(
selected: Boolean,
@@ -226,3 +682,16 @@ fun RowScope.ABoxNavigationBarItem(
),
)
}
@Composable
fun ABoxBackLayer(
onBack: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
content: @Composable () -> Unit,
) {
BackHandler(enabled = enabled, onBack = onBack)
Box(modifier = modifier.fillMaxSize()) {
content()
}
}
@@ -2,10 +2,11 @@ package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.designsystem.tokens.ABoxShapeTokens
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
val ABoxShapes = Shapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
fun ABoxShapes(tokens: ABoxShapeTokens = ABoxThemeDefaults.material3Shapes) = Shapes(
small = RoundedCornerShape(tokens.smallCornerRadius),
medium = RoundedCornerShape(tokens.mediumCornerRadius),
large = RoundedCornerShape(tokens.largeCornerRadius),
)
@@ -1,41 +1,205 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import android.os.Build
import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import com.flagship.abox.manager.designsystem.renderer.ABoxRendererAdapters
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.UiPreferences
private val LightColorScheme = lightColorScheme(
primary = Color(0xFF006780),
onPrimary = Color.White,
primaryContainer = Color(0xFFB8EAFF),
onPrimaryContainer = Color(0xFF001F29),
secondary = Color(0xFF4D616A),
surface = Color(0xFFF8FAFC),
background = Color(0xFFF8FAFC),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF5DD5F8),
onPrimary = Color(0xFF003544),
primaryContainer = Color(0xFF004D61),
onPrimaryContainer = Color(0xFFB8EAFF),
secondary = Color(0xFFB4CBD4),
surface = Color(0xFF101416),
background = Color(0xFF101416),
)
val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
val LocalABoxSpacing = staticCompositionLocalOf { ABoxSpacing() }
val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography }
val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes }
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
val LocalABoxReducedMotion = staticCompositionLocalOf { false }
val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD }
val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false }
val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false }
@Composable
fun ABoxTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
preferences: UiPreferences,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
typography = ABoxTypography,
shapes = ABoxShapes,
val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme()
val context = LocalContext.current
val isDarkTheme = when (preferences.themeMode) {
AppThemeMode.SYSTEM -> systemInDarkTheme
AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true
}
val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val dynamicColorScheme = if (isDarkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
dynamicColorScheme.toABoxColorTokens()
} else {
null
}
ABoxThemeContent(
preferences = preferences,
systemInDarkTheme = systemInDarkTheme,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = dynamicTokens,
reducedMotion = systemAnimatorDurationScale(context) == 0f,
content = content,
)
}
@Composable
internal fun ABoxThemeContent(
preferences: UiPreferences,
systemInDarkTheme: Boolean,
sdkInt: Int,
dynamicColorTokens: ABoxColorTokens?,
reducedMotion: Boolean,
content: @Composable () -> Unit,
) {
val resolution = ABoxThemeResolver.resolve(
preferences = preferences,
systemInDarkTheme = systemInDarkTheme,
sdkInt = sdkInt,
dynamicColorTokens = dynamicColorTokens,
)
val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
val density = LocalDensity.current
val scaledDensity = Density(
density = density.density * (preferences.interfaceScale.percent / 100f),
fontScale = density.fontScale,
)
CompositionLocalProvider(
LocalDensity provides scaledDensity,
LocalABoxColors provides resolution.colorTokens,
LocalABoxSpacing provides ABoxSpacing(),
LocalABoxTypography provides ABoxTypography,
LocalABoxShapes provides resolution.shapeTokens,
LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides reducedMotion,
LocalABoxBlurEnabled provides resolution.blurEnabled,
LocalABoxNavigationPresentation provides preferences.navigationPresentation,
LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled,
LocalABoxPredictiveBackEnabled provides false,
) {
renderer.Render(
resolution = resolution,
content = content,
)
}
}
private fun systemAnimatorDurationScale(context: android.content.Context): Float =
runCatching {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
}.getOrDefault(1f)
@Composable
fun ABoxTheme(
darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
ABoxTheme(
preferences = UiPreferences(
themeMode = if (darkTheme) AppThemeMode.DARK else AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
content = content,
)
}
internal fun materialColorScheme(resolution: ABoxThemeResolution): ColorScheme {
val colors = resolution.colorTokens
val onPrimary = if (resolution.isDarkTheme) Color(0xFF003544) else Color.White
val onSurface = if (resolution.isDarkTheme) Color(0xFFE0E3E5) else Color(0xFF191C1E)
val onSurfaceVariant = if (resolution.isDarkTheme) Color(0xFFC0C8CC) else Color(0xFF40484C)
val primaryContainer = colors.primary.copy(alpha = if (resolution.isDarkTheme) 0.35f else 0.20f)
val secondaryContainer = colors.surfaceContainer
return if (resolution.isDarkTheme) {
darkColorScheme(
primary = colors.primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = colors.primary,
secondary = colors.pending,
onSecondary = Color(0xFF3F2E00),
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSurface,
tertiary = colors.success,
onTertiary = Color(0xFF00391E),
surface = colors.surface,
onSurface = onSurface,
surfaceVariant = colors.surfaceContainer,
onSurfaceVariant = onSurfaceVariant,
background = colors.surface,
onBackground = onSurface,
outline = colors.outline,
error = colors.error,
onError = Color(0xFF690005),
)
} else {
lightColorScheme(
primary = colors.primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = Color(0xFF001F29),
secondary = colors.pending,
onSecondary = Color.White,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSurface,
tertiary = colors.success,
onTertiary = Color.White,
surface = colors.surface,
onSurface = onSurface,
surfaceVariant = colors.surfaceContainer,
onSurfaceVariant = onSurfaceVariant,
background = colors.surface,
onBackground = onSurface,
outline = colors.outline,
error = colors.error,
onError = Color.White,
)
}
}
private fun ColorScheme.toABoxColorTokens() = ABoxColorTokens(
primary = primary,
surface = surface,
surfaceContainer = surfaceContainer,
outline = outline,
success = tertiary,
warning = secondary,
error = error,
offline = outline,
pending = secondary,
deviceSocket = primary,
deviceSensor = tertiary,
deviceInfrared = secondary,
)
@@ -2,4 +2,5 @@ package com.flagship.abox.manager.designsystem
import androidx.compose.material3.Typography
/** Default typography supplied through [LocalABoxTypography]. */
val ABoxTypography = Typography()
@@ -0,0 +1,129 @@
package com.flagship.abox.manager.designsystem.icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Devices
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Palette
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material.icons.outlined.Tune
import androidx.compose.material.icons.outlined.Vibration
import androidx.compose.material.icons.outlined.ViewCarousel
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Power
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Sensors
import androidx.compose.material.icons.outlined.Thermostat
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material.icons.outlined.WifiOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon as MaterialIcon
/** Stable semantic identifiers for every icon exposed by the ABox design system. */
enum class ABoxIconKey {
BACK,
CLOSE,
ADD,
EDIT,
REFRESH,
ACCOUNT,
PROVIDER,
DEVICES,
SCENES,
ACTIVITY,
SOCKET,
SENSOR,
INFRARED,
POWER,
TEMPERATURE,
HUMIDITY,
ONLINE,
OFFLINE,
PENDING,
WARNING,
ERROR,
UNKNOWN_DEVICE,
SETTINGS,
THEME,
EFFECTS,
FEEDBACK,
SCALE,
}
/** Design-system-owned device categories; this module does not depend on domain models. */
enum class ABoxDeviceType {
SOCKET,
SENSOR,
INFRARED,
POWER,
UNKNOWN,
}
object ABoxIconMapper {
fun forDeviceType(type: ABoxDeviceType?): ABoxIconKey = when (type) {
ABoxDeviceType.SOCKET -> ABoxIconKey.SOCKET
ABoxDeviceType.SENSOR -> ABoxIconKey.SENSOR
ABoxDeviceType.INFRARED -> ABoxIconKey.INFRARED
ABoxDeviceType.POWER -> ABoxIconKey.POWER
null, ABoxDeviceType.UNKNOWN -> ABoxIconKey.UNKNOWN_DEVICE
}
internal fun vectorFor(key: ABoxIconKey): ImageVector = when (key) {
ABoxIconKey.BACK -> Icons.AutoMirrored.Outlined.ArrowBack
ABoxIconKey.CLOSE -> Icons.Outlined.Close
ABoxIconKey.ADD -> Icons.Outlined.Add
ABoxIconKey.EDIT -> Icons.Outlined.Edit
ABoxIconKey.REFRESH -> Icons.Outlined.Refresh
ABoxIconKey.ACCOUNT -> Icons.Outlined.AccountCircle
ABoxIconKey.PROVIDER -> Icons.Outlined.Bolt
ABoxIconKey.DEVICES -> Icons.Outlined.Devices
ABoxIconKey.SCENES -> Icons.Outlined.Layers
ABoxIconKey.ACTIVITY -> Icons.Outlined.History
ABoxIconKey.SOCKET -> Icons.Outlined.Power
ABoxIconKey.SENSOR -> Icons.Outlined.Sensors
ABoxIconKey.INFRARED -> Icons.Outlined.Bolt
ABoxIconKey.POWER -> Icons.Outlined.PowerSettingsNew
ABoxIconKey.TEMPERATURE -> Icons.Outlined.Thermostat
ABoxIconKey.HUMIDITY -> Icons.Outlined.WaterDrop
ABoxIconKey.ONLINE -> Icons.Outlined.Wifi
ABoxIconKey.OFFLINE -> Icons.Outlined.WifiOff
ABoxIconKey.PENDING -> Icons.Outlined.Schedule
ABoxIconKey.WARNING -> Icons.Outlined.Warning
ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline
ABoxIconKey.UNKNOWN_DEVICE -> Icons.AutoMirrored.Outlined.HelpOutline
ABoxIconKey.SETTINGS -> Icons.Outlined.Settings
ABoxIconKey.THEME -> Icons.Outlined.Palette
ABoxIconKey.EFFECTS -> Icons.Outlined.ViewCarousel
ABoxIconKey.FEEDBACK -> Icons.Outlined.Vibration
ABoxIconKey.SCALE -> Icons.Outlined.Tune
}
}
@Composable
fun ABoxIcon(
key: ABoxIconKey,
contentDescription: String,
modifier: Modifier = Modifier,
) {
require(contentDescription.isNotBlank()) {
"ABoxIcon contentDescription must not be blank"
}
MaterialIcon(
imageVector = ABoxIconMapper.vectorFor(key),
contentDescription = contentDescription,
modifier = modifier,
)
}
@@ -0,0 +1,17 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import com.flagship.abox.manager.preferences.DesignRenderer
internal interface ABoxRendererAdapter {
@Composable
fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit)
}
internal object ABoxRendererAdapters {
fun forRenderer(renderer: DesignRenderer): ABoxRendererAdapter = when (renderer) {
DesignRenderer.MATERIAL3 -> Material3RendererAdapter
DesignRenderer.MIUIX -> MiuixRendererAdapter
}
}
@@ -0,0 +1,23 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.ABoxShapes
import com.flagship.abox.manager.designsystem.ABoxTypography
import com.flagship.abox.manager.designsystem.materialColorScheme
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
internal object Material3RendererAdapter : ABoxRendererAdapter {
@Composable
override fun Render(
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = materialColorScheme(resolution),
typography = ABoxTypography,
shapes = ABoxShapes(resolution.shapeTokens),
content = content,
)
}
}

Some files were not shown because too many files have changed in this diff Show More