Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1238b82b4a | ||
|
|
0144ea992c | ||
|
|
32c7d4f8cc | ||
|
|
ad0dd2cd5b | ||
|
|
387ecacef5 | ||
|
|
4d4dd2893e | ||
|
|
86b7e5849b |
@@ -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(
|
override suspend fun getReading(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.kapt)
|
alias(libs.plugins.kotlin.kapt)
|
||||||
alias(libs.plugins.hilt.android)
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.compose.screenshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.flagship.abox.manager"
|
namespace = "com.flagship.abox.manager"
|
||||||
compileSdk = 34
|
compileSdk = 37
|
||||||
|
|
||||||
|
experimentalProperties["android.experimental.enableScreenshotTest"] = true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.flagship.abox.manager"
|
applicationId = "com.flagship.abox.manager"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "1.0.0"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
@@ -33,19 +39,11 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
@@ -57,6 +55,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
|
||||||
kapt {
|
kapt {
|
||||||
correctErrorTypes = true
|
correctErrorTypes = true
|
||||||
}
|
}
|
||||||
@@ -64,8 +66,12 @@ kapt {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":android:core:designsystem"))
|
implementation(project(":android:core:designsystem"))
|
||||||
|
implementation(project(":android:core:preferences"))
|
||||||
|
implementation(project(":android:core:devicecatalog"))
|
||||||
implementation(project(":android:core:session"))
|
implementation(project(":android:core:session"))
|
||||||
implementation(project(":android:feature:infrared"))
|
implementation(project(":android:feature:infrared"))
|
||||||
|
implementation(project(":android:feature:automation"))
|
||||||
|
implementation(libs.androidx.hilt.work)
|
||||||
implementation(project(":absdk-bridge"))
|
implementation(project(":absdk-bridge"))
|
||||||
implementation(project(":runtime:simulation"))
|
implementation(project(":runtime:simulation"))
|
||||||
debugImplementation(project(":testing:fakes"))
|
debugImplementation(project(":testing:fakes"))
|
||||||
@@ -95,4 +101,5 @@ dependencies {
|
|||||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
debugImplementation(libs.compose.ui.test.manifest)
|
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,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()
|
|
||||||
}
|
|
||||||
@@ -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.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:name=".ABoxManagerApplication"
|
android:name=".ABoxManagerApplication"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
android:theme="@style/Theme.ABoxManager">
|
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
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true">
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
package com.flagship.abox.manager
|
package com.flagship.abox.manager
|
||||||
|
|
||||||
import android.app.Application
|
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 dagger.hilt.android.HiltAndroidApp
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@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
|
package com.flagship.abox.manager
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.flagship.abox.manager.ui.ABoxManagerApp
|
import com.flagship.abox.manager.ui.ABoxManagerApp
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
|
private val notificationPermissionLauncher =
|
||||||
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
ABoxManagerApp()
|
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.absdk.bridge.AbsdkBridge
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
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.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
|
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationSocketGateway
|
import com.flagship.abox.manager.simulation.SimulationSocketGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
|
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.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -22,6 +26,18 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object DeviceGatewayModule {
|
object DeviceGatewayModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDeviceCatalog(
|
||||||
|
profileStore: ProviderProfileStore,
|
||||||
|
configured: RoomConfiguredDeviceRepository,
|
||||||
|
simulation: SimulationDeviceCatalog,
|
||||||
|
): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
|
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ internal class RoutingSocketGateway(
|
|||||||
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
|
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(
|
override suspend fun setPower(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package com.flagship.abox.manager.di
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DefaultSceneExecutor
|
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.InfraredGateway
|
||||||
import com.flagship.abox.manager.domain.SceneExecutor
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
import com.flagship.abox.manager.domain.SceneRepository
|
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.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
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.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -19,19 +21,21 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object SceneIntegrationModule {
|
object SceneIntegrationModule {
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideSceneRepository(): SceneRepository = InMemorySceneRepository()
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSceneExecutor(
|
fun provideSceneExecutor(
|
||||||
socketGateway: SocketGateway,
|
socketGateway: SocketGateway,
|
||||||
infraredGateway: InfraredGateway,
|
infraredGateway: InfraredGateway,
|
||||||
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||||
): SceneExecutor = DefaultSceneExecutor(
|
notificationGateway: NotificationGateway,
|
||||||
|
activityWriter: ActivityEventWriter,
|
||||||
|
): SceneExecutor = RecordingSceneExecutor(
|
||||||
|
delegate = DefaultSceneExecutor(
|
||||||
socketGateway = socketGateway,
|
socketGateway = socketGateway,
|
||||||
infraredGateway = infraredGateway,
|
infraredGateway = infraredGateway,
|
||||||
sendVirtualRemoteAction = sendVirtualRemoteAction,
|
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,56 +7,62 @@ import androidx.compose.foundation.layout.safeDrawing
|
|||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
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.ABoxLoadingIndicator
|
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.designsystem.ABoxBackLayer
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.Username
|
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.devicecontrollist.DeviceListScreen
|
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
import com.flagship.abox.manager.ui.navigation.deviceDetailGraph
|
import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
|
||||||
import com.flagship.abox.manager.ui.navigation.navigateToSocket
|
import com.flagship.abox.manager.ui.about.AboutScreen
|
||||||
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
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.provider.ProviderSelectionRoute
|
||||||
import com.flagship.abox.manager.ui.scene.SceneListScreen
|
|
||||||
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.session.AppSessionUiState
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
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
|
@Composable
|
||||||
fun ABoxManagerApp(
|
fun ABoxManagerApp(
|
||||||
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
||||||
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
||||||
loginViewModel: LoginViewModel = hiltViewModel(),
|
loginViewModel: LoginViewModel = hiltViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
val actionError = actionState.errorMessage?.asString()
|
||||||
val screenKey = sessionState.screenKey()
|
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) {
|
LaunchedEffect(screenKey) {
|
||||||
sessionActionsViewModel.dismissError()
|
sessionActionsViewModel.dismissError()
|
||||||
if (sessionState !is AppSessionUiState.Login) {
|
if (sessionState !is AppSessionUiState.Login) {
|
||||||
loginViewModel.clearSensitiveState()
|
loginViewModel.clearSensitiveState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ABoxTheme {
|
|
||||||
|
ABoxTheme(preferences = settingsState.preferences) {
|
||||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val state = sessionState) {
|
when (val state = sessionState) {
|
||||||
AppSessionUiState.Loading -> Box(
|
AppSessionUiState.Loading -> Box(
|
||||||
@@ -71,7 +77,7 @@ fun ABoxManagerApp(
|
|||||||
is AppSessionUiState.Login -> LoginRoute(
|
is AppSessionUiState.Login -> LoginRoute(
|
||||||
profile = state.profile,
|
profile = state.profile,
|
||||||
bootstrapError = state.bootstrapError,
|
bootstrapError = state.bootstrapError,
|
||||||
actionError = actionState.errorMessage,
|
actionError = actionError,
|
||||||
actionInProgress = actionState.inProgress,
|
actionInProgress = actionState.inProgress,
|
||||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
viewModel = loginViewModel,
|
viewModel = loginViewModel,
|
||||||
@@ -79,9 +85,15 @@ fun ABoxManagerApp(
|
|||||||
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
|
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
|
||||||
username = state.username,
|
username = state.username,
|
||||||
profile = state.profile,
|
profile = state.profile,
|
||||||
actionInProgress = actionState.inProgress,
|
|
||||||
errorMessage = actionState.errorMessage,
|
|
||||||
onLogout = sessionActionsViewModel::logout,
|
onLogout = sessionActionsViewModel::logout,
|
||||||
|
onSessionAction = sessionActionsViewModel::dispatch,
|
||||||
|
showAccountDialog = showAccountDialog,
|
||||||
|
showSettings = showSettings,
|
||||||
|
showAbout = showAbout,
|
||||||
|
settingsExperienceState = settingsExperienceState,
|
||||||
|
onShowAccountDialogChange = { showAccountDialog = it },
|
||||||
|
onShowSettingsChange = { showSettings = it },
|
||||||
|
onShowAboutChange = { showAbout = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,105 +104,70 @@ fun ABoxManagerApp(
|
|||||||
private fun AuthenticatedNavHost(
|
private fun AuthenticatedNavHost(
|
||||||
username: Username,
|
username: Username,
|
||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
actionInProgress: Boolean,
|
|
||||||
errorMessage: String?,
|
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onSessionAction: (SessionUiAction) -> Unit,
|
||||||
|
showAccountDialog: Boolean,
|
||||||
|
showSettings: Boolean,
|
||||||
|
showAbout: Boolean,
|
||||||
|
settingsExperienceState: SettingsExperienceState,
|
||||||
|
onShowAccountDialogChange: (Boolean) -> Unit,
|
||||||
|
onShowSettingsChange: (Boolean) -> Unit,
|
||||||
|
onShowAboutChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
Box(Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
AuthenticatedNavigation(
|
||||||
navController = navController,
|
username = username,
|
||||||
startDestination = "home",
|
profile = profile,
|
||||||
|
onLogout = onLogout,
|
||||||
|
onProviderClick = { onShowAccountDialogChange(true) },
|
||||||
|
onAvatarClick = { onShowAccountDialogChange(true) },
|
||||||
|
)
|
||||||
|
if (showSettings) {
|
||||||
|
ABoxBackLayer(
|
||||||
|
onBack = { onShowSettingsChange(false) },
|
||||||
|
enabled = settingsExperienceState.isHome,
|
||||||
) {
|
) {
|
||||||
composable("home") {
|
SettingsRoute(
|
||||||
MainAppShell(
|
experienceState = settingsExperienceState,
|
||||||
|
onExit = { onShowSettingsChange(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (showAbout) {
|
||||||
|
ABoxBackLayer(onBack = { onShowAboutChange(false) }) {
|
||||||
|
AboutScreen(onBack = { onShowAboutChange(false) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAccountDialog) {
|
||||||
|
AccountProviderDialog(
|
||||||
userName = username.value,
|
userName = username.value,
|
||||||
providerName = profile.displayName,
|
providerName = profile.displayName,
|
||||||
onLogout = onLogout,
|
onDismissRequest = { onShowAccountDialogChange(false) },
|
||||||
actionInProgress = actionInProgress,
|
|
||||||
errorMessage = errorMessage,
|
|
||||||
devicesSlot = {
|
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
|
||||||
DeviceListScreen(
|
|
||||||
onSocketClick = {
|
|
||||||
navController.navigateToSocket(
|
|
||||||
profileId = profile.id,
|
|
||||||
deviceName = DeviceName("Zigbee插座控制器"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onTemperatureHumidityClick = {
|
|
||||||
navController.navigateToTemperatureHumidity(
|
|
||||||
profileId = profile.id,
|
|
||||||
deviceName = DeviceName("温湿度传感器"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onInfraredClick = {
|
|
||||||
navController.navigate("infrared_hub/$encodedProfileId")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scenesSlot = {
|
|
||||||
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)) },
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
deviceDetailGraph(
|
|
||||||
onBackClick = navController::popBackStack,
|
|
||||||
)
|
|
||||||
sceneEditorDestination(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSaved = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
sceneExecutionDestination(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
composable(
|
|
||||||
route = "infrared_hub/{profileId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
|
||||||
InfraredHubRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onOpenRemote = { remoteId ->
|
|
||||||
val encodedRemoteId = android.net.Uri.encode(remoteId)
|
|
||||||
navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId")
|
|
||||||
},
|
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
navController.navigate("infrared_settings/$encodedProfileId")
|
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)
|
||||||
},
|
},
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
|
||||||
route = "infrared_panel/{profileId}/{remoteId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
navArgument("remoteId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
RemotePanelRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = "infrared_settings/{profileId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
InfraredControlRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AppSessionUiState.screenKey(): String = when (this) {
|
private fun AppSessionUiState.screenKey(): String = when (this) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,13 +13,6 @@ object DomainErrorMapper {
|
|||||||
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
|
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
|
||||||
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
|
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
|
||||||
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
|
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
|
||||||
is DomainError.Unknown -> {
|
is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown)
|
||||||
val detail = error.detail
|
|
||||||
if (!detail.isNullOrBlank()) {
|
|
||||||
UiText.DynamicString(detail)
|
|
||||||
} else {
|
|
||||||
UiText.StringResource(R.string.error_unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
package com.flagship.abox.manager.ui.navigation
|
package com.flagship.abox.manager.ui.navigation
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val STARTUP = "startup"
|
const val STARTUP = "startup"
|
||||||
const val PROVIDER_SELECTION = "provider_selection"
|
const val PROVIDER_SELECTION = "provider_selection"
|
||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
|
|
||||||
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
|
|
||||||
const val AUTHENTICATED = "authenticated"
|
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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.FlowRowScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -81,12 +84,11 @@ import com.flagship.abox.manager.domain.SceneIcon
|
|||||||
import com.flagship.abox.manager.domain.SceneId
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
import com.flagship.abox.manager.domain.SceneTrigger
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||||
|
|
||||||
private const val DEFAULT_SOCKET_DEVICE_NAME = "Zigbee插座控制器"
|
|
||||||
private const val DEFAULT_TH_DEVICE_NAME = "温湿度传感器"
|
|
||||||
private const val DEFAULT_IR_DEVICE_NAME = "红外控制器"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -216,93 +218,6 @@ private fun SceneEditorContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
item(key = "icon_color_card") {
|
|
||||||
OutlinedCard(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
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), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text(
|
|
||||||
stringResource(R.string.scene_section_other),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
listOf(
|
|
||||||
SceneIcon.HOME to "Home",
|
|
||||||
SceneIcon.NIGHT to "Night",
|
|
||||||
SceneIcon.AWAY to "Away",
|
|
||||||
SceneIcon.MOVIE to "Movie",
|
|
||||||
SceneIcon.CUSTOM to "Custom",
|
|
||||||
).forEach { (icon, name) ->
|
|
||||||
val isSelected = state.icon == icon
|
|
||||||
Surface(
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
|
||||||
modifier = Modifier
|
|
||||||
.size(52.dp)
|
|
||||||
.clickable { viewModel.updateIcon(icon) },
|
|
||||||
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Icon(
|
|
||||||
imageVector = when (icon) {
|
|
||||||
SceneIcon.HOME -> Icons.Outlined.CheckCircle
|
|
||||||
SceneIcon.NIGHT -> Icons.Outlined.Schedule
|
|
||||||
SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled
|
|
||||||
SceneIcon.MOVIE -> Icons.Outlined.PlayCircle
|
|
||||||
else -> Icons.Outlined.Settings
|
|
||||||
},
|
|
||||||
contentDescription = name,
|
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(26.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
listOf(
|
|
||||||
"#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50",
|
|
||||||
).forEach { hex ->
|
|
||||||
val isSelected = state.colorHex.equals(hex, ignoreCase = true)
|
|
||||||
val parsedColor = try {
|
|
||||||
Color(android.graphics.Color.parseColor(hex))
|
|
||||||
} catch (e: Exception) {
|
|
||||||
MaterialTheme.colorScheme.primary
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(36.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(parsedColor)
|
|
||||||
.clickable { viewModel.updateColor(hex) },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isSelected) {
|
|
||||||
Icon(
|
|
||||||
Icons.Filled.Check,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.type == AutomationType.AUTOMATIC) {
|
if (state.type == AutomationType.AUTOMATIC) {
|
||||||
item(key = "triggers_card") {
|
item(key = "triggers_card") {
|
||||||
OutlinedCard(
|
OutlinedCard(
|
||||||
@@ -505,7 +420,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL,
|
selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL,
|
||||||
onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) },
|
onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) },
|
||||||
@@ -521,7 +436,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR,
|
selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR,
|
||||||
onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) },
|
onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) },
|
||||||
@@ -550,6 +465,93 @@ private fun SceneEditorContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
item(key = "icon_color_card") {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
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), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.scene_section_other),
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
SceneIcon.HOME to "Home",
|
||||||
|
SceneIcon.NIGHT to "Night",
|
||||||
|
SceneIcon.AWAY to "Away",
|
||||||
|
SceneIcon.MOVIE to "Movie",
|
||||||
|
SceneIcon.CUSTOM to "Custom",
|
||||||
|
).forEach { (icon, name) ->
|
||||||
|
val isSelected = state.icon == icon
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier
|
||||||
|
.size(52.dp)
|
||||||
|
.clickable { viewModel.updateIcon(icon) },
|
||||||
|
border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Icon(
|
||||||
|
imageVector = when (icon) {
|
||||||
|
SceneIcon.HOME -> Icons.Outlined.CheckCircle
|
||||||
|
SceneIcon.NIGHT -> Icons.Outlined.Schedule
|
||||||
|
SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled
|
||||||
|
SceneIcon.MOVIE -> Icons.Outlined.PlayCircle
|
||||||
|
else -> Icons.Outlined.Settings
|
||||||
|
},
|
||||||
|
contentDescription = name,
|
||||||
|
tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(26.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
listOf(
|
||||||
|
"#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50",
|
||||||
|
).forEach { hex ->
|
||||||
|
val isSelected = state.colorHex.equals(hex, ignoreCase = true)
|
||||||
|
val parsedColor = try {
|
||||||
|
Color(android.graphics.Color.parseColor(hex))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
MaterialTheme.colorScheme.primary
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(parsedColor)
|
||||||
|
.clickable { viewModel.updateColor(hex) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item(key = "save_section") {
|
item(key = "save_section") {
|
||||||
state.errorMessage?.let {
|
state.errorMessage?.let {
|
||||||
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium)
|
||||||
@@ -572,6 +574,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
if (showTriggerDialog) {
|
if (showTriggerDialog) {
|
||||||
AddTriggerDialog(
|
AddTriggerDialog(
|
||||||
|
availableDevices = state.availableDevices,
|
||||||
onDismiss = { showTriggerDialog = false },
|
onDismiss = { showTriggerDialog = false },
|
||||||
onAdd = { triggerType, devName, time, temp, hum, op ->
|
onAdd = { triggerType, devName, time, temp, hum, op ->
|
||||||
when (triggerType) {
|
when (triggerType) {
|
||||||
@@ -586,6 +589,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
if (showConditionDialog) {
|
if (showConditionDialog) {
|
||||||
AddConditionDialog(
|
AddConditionDialog(
|
||||||
|
availableDevices = state.availableDevices,
|
||||||
onDismiss = { showConditionDialog = false },
|
onDismiss = { showConditionDialog = false },
|
||||||
onAddTemp = { devName, op, temp ->
|
onAddTemp = { devName, op, temp ->
|
||||||
viewModel.addTemperatureCondition(devName, op, temp)
|
viewModel.addTemperatureCondition(devName, op, temp)
|
||||||
@@ -609,6 +613,7 @@ private fun SceneEditorContent(
|
|||||||
if (showActionDialog) {
|
if (showActionDialog) {
|
||||||
AddActionDialog(
|
AddActionDialog(
|
||||||
availableRemotes = state.availableRemotes,
|
availableRemotes = state.availableRemotes,
|
||||||
|
availableDevices = state.availableDevices,
|
||||||
onDismiss = { showActionDialog = false },
|
onDismiss = { showActionDialog = false },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onAddSocket = { name, power ->
|
onAddSocket = { name, power ->
|
||||||
@@ -745,7 +750,7 @@ private fun ActionItemRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SceneTrigger.describe(): String = when (this) {
|
private fun SceneTrigger.describe(): String = when (this) {
|
||||||
is SceneTrigger.Time -> "⏰ $timeOfDay"
|
is SceneTrigger.Time -> "定时 $timeOfDay"
|
||||||
is SceneTrigger.Temperature -> stringResource(R.string.scene_trigger_desc_temp, operator.symbol, thresholdCelsius.toInt().toString())
|
is SceneTrigger.Temperature -> stringResource(R.string.scene_trigger_desc_temp, operator.symbol, thresholdCelsius.toInt().toString())
|
||||||
is SceneTrigger.Humidity -> stringResource(R.string.scene_trigger_desc_humidity, operator.symbol, thresholdPercent.toInt().toString())
|
is SceneTrigger.Humidity -> stringResource(R.string.scene_trigger_desc_humidity, operator.symbol, thresholdPercent.toInt().toString())
|
||||||
is SceneTrigger.DeviceState -> stringResource(R.string.scene_trigger_desc_device, deviceName.value, expectedState)
|
is SceneTrigger.DeviceState -> stringResource(R.string.scene_trigger_desc_device, deviceName.value, expectedState)
|
||||||
@@ -768,13 +773,28 @@ private fun SceneAction.describe(): String = when (this) {
|
|||||||
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
|
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SceneChoiceFlow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable FlowRowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddTriggerDialog(
|
private fun AddTriggerDialog(
|
||||||
|
availableDevices: List<DeviceDescriptor>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit,
|
onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit,
|
||||||
) {
|
) {
|
||||||
var selectedType by remember { mutableIntStateOf(0) }
|
var selectedType by remember { mutableIntStateOf(0) }
|
||||||
var deviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
var deviceName by remember { mutableStateOf("") }
|
||||||
var timeInput by remember { mutableStateOf("07:30") }
|
var timeInput by remember { mutableStateOf("07:30") }
|
||||||
var tempThreshold by remember { mutableDoubleStateOf(28.0) }
|
var tempThreshold by remember { mutableDoubleStateOf(28.0) }
|
||||||
var humThreshold by remember { mutableDoubleStateOf(40.0) }
|
var humThreshold by remember { mutableDoubleStateOf(40.0) }
|
||||||
@@ -782,14 +802,18 @@ private fun AddTriggerDialog(
|
|||||||
|
|
||||||
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
|
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
|
||||||
val isTimeValid = timeRegex.matches(timeInput.trim())
|
val isTimeValid = timeRegex.matches(timeInput.trim())
|
||||||
val isDeviceValid = selectedType == 0 || deviceName.isNotBlank()
|
val matchingDevices = availableDevices.filter {
|
||||||
|
it.type == if (selectedType == 1 || selectedType == 2) DeviceType.TEMPERATURE_HUMIDITY else DeviceType.SOCKET
|
||||||
|
}
|
||||||
|
val isDeviceValid = selectedType == 0 && isTimeValid || selectedType != 0 &&
|
||||||
|
matchingDevices.any { it.name.value == deviceName }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) },
|
title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) })
|
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) })
|
||||||
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
||||||
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
||||||
@@ -809,14 +833,17 @@ private fun AddTriggerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
1, 2 -> {
|
1, 2 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = deviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { deviceName = it },
|
matchingDevices.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(
|
||||||
singleLine = true,
|
selected = deviceName == device.name.value,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = { deviceName = device.name.value },
|
||||||
|
label = { Text(device.name.value) },
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
}
|
||||||
|
}
|
||||||
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||||
}
|
}
|
||||||
@@ -848,6 +875,7 @@ private fun AddTriggerDialog(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddConditionDialog(
|
private fun AddConditionDialog(
|
||||||
|
availableDevices: List<DeviceDescriptor>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||||
onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||||
@@ -855,9 +883,9 @@ private fun AddConditionDialog(
|
|||||||
onAddTimeRange: (String, String) -> Unit,
|
onAddTimeRange: (String, String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var selectedType by remember { mutableIntStateOf(0) }
|
var selectedType by remember { mutableIntStateOf(0) }
|
||||||
var tempDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
var tempDeviceName by remember { mutableStateOf("") }
|
||||||
var humDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
var humDeviceName by remember { mutableStateOf("") }
|
||||||
var socketDeviceName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) }
|
var socketDeviceName by remember { mutableStateOf("") }
|
||||||
var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) }
|
var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) }
|
||||||
var tempVal by remember { mutableDoubleStateOf(26.0) }
|
var tempVal by remember { mutableDoubleStateOf(26.0) }
|
||||||
var humVal by remember { mutableDoubleStateOf(50.0) }
|
var humVal by remember { mutableDoubleStateOf(50.0) }
|
||||||
@@ -870,10 +898,13 @@ private fun AddConditionDialog(
|
|||||||
val isEndTimeValid = timeRegex.matches(endTime.trim())
|
val isEndTimeValid = timeRegex.matches(endTime.trim())
|
||||||
val isTimeRangeValid = isStartTimeValid && isEndTimeValid
|
val isTimeRangeValid = isStartTimeValid && isEndTimeValid
|
||||||
|
|
||||||
|
val sensors = availableDevices.filter { it.type == DeviceType.TEMPERATURE_HUMIDITY }
|
||||||
|
val sockets = availableDevices.filter { it.type == DeviceType.SOCKET }
|
||||||
|
|
||||||
val isDeviceValid = when (selectedType) {
|
val isDeviceValid = when (selectedType) {
|
||||||
0 -> tempDeviceName.isNotBlank()
|
0 -> sensors.any { it.name.value == tempDeviceName }
|
||||||
1 -> humDeviceName.isNotBlank()
|
1 -> sensors.any { it.name.value == humDeviceName }
|
||||||
2 -> socketDeviceName.isNotBlank()
|
2 -> sockets.any { it.name.value == socketDeviceName }
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,7 +913,7 @@ private fun AddConditionDialog(
|
|||||||
title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) },
|
title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
||||||
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
||||||
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
||||||
@@ -890,14 +921,13 @@ private fun AddConditionDialog(
|
|||||||
}
|
}
|
||||||
when (selectedType) {
|
when (selectedType) {
|
||||||
0 -> {
|
0 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = tempDeviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { tempDeviceName = it },
|
sensors.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||||
singleLine = true,
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
)
|
SceneChoiceFlow {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||||
}
|
}
|
||||||
@@ -905,14 +935,13 @@ private fun AddConditionDialog(
|
|||||||
Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f)
|
Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f)
|
||||||
}
|
}
|
||||||
1 -> {
|
1 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = humDeviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { humDeviceName = it },
|
sensors.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||||
singleLine = true,
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
)
|
SceneChoiceFlow {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||||
}
|
}
|
||||||
@@ -920,15 +949,14 @@ private fun AddConditionDialog(
|
|||||||
Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f)
|
Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f)
|
||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = socketDeviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { socketDeviceName = it },
|
sockets.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||||
singleLine = true,
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
)
|
|
||||||
Text(stringResource(R.string.scene_target_socket_state))
|
Text(stringResource(R.string.scene_target_socket_state))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||||
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
||||||
}
|
}
|
||||||
@@ -985,6 +1013,7 @@ private fun AddConditionDialog(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AddActionDialog(
|
private fun AddActionDialog(
|
||||||
availableRemotes: List<VirtualRemote>,
|
availableRemotes: List<VirtualRemote>,
|
||||||
|
availableDevices: List<com.flagship.abox.manager.domain.DeviceDescriptor>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
viewModel: SceneEditorViewModel,
|
viewModel: SceneEditorViewModel,
|
||||||
onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit,
|
onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit,
|
||||||
@@ -997,7 +1026,7 @@ private fun AddActionDialog(
|
|||||||
val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title)
|
val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title)
|
||||||
val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg)
|
val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg)
|
||||||
|
|
||||||
var socketName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) }
|
var socketName by remember { mutableStateOf(availableDevices.firstOrNull { it.type == DeviceType.SOCKET }?.name?.value.orEmpty()) }
|
||||||
var socketPower by remember { mutableStateOf(SocketPowerState.ON) }
|
var socketPower by remember { mutableStateOf(SocketPowerState.ON) }
|
||||||
|
|
||||||
var selectedRemoteIndex by remember { mutableIntStateOf(0) }
|
var selectedRemoteIndex by remember { mutableIntStateOf(0) }
|
||||||
@@ -1032,7 +1061,7 @@ private fun AddActionDialog(
|
|||||||
title = { Text(stringResource(R.string.scene_dialog_add_action_title)) },
|
title = { Text(stringResource(R.string.scene_dialog_add_action_title)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
||||||
FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) })
|
FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) })
|
||||||
FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) })
|
FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) })
|
||||||
@@ -1040,15 +1069,18 @@ private fun AddActionDialog(
|
|||||||
}
|
}
|
||||||
when (actionType) {
|
when (actionType) {
|
||||||
0 -> {
|
0 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_select_socket))
|
||||||
value = socketName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { socketName = it },
|
availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_select_socket)) },
|
FilterChip(
|
||||||
singleLine = true,
|
selected = socketName == device.name.value,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = { socketName = device.name.value },
|
||||||
|
label = { Text(device.name.value) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(stringResource(R.string.scene_socket_power_state))
|
Text(stringResource(R.string.scene_socket_power_state))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||||
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
||||||
}
|
}
|
||||||
@@ -1056,10 +1088,9 @@ private fun AddActionDialog(
|
|||||||
1 -> {
|
1 -> {
|
||||||
if (availableRemotes.isEmpty()) {
|
if (availableRemotes.isEmpty()) {
|
||||||
Text(stringResource(R.string.scene_no_virtual_remotes))
|
Text(stringResource(R.string.scene_no_virtual_remotes))
|
||||||
OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_slot_label)) }, singleLine = true)
|
|
||||||
} else {
|
} else {
|
||||||
Text(stringResource(R.string.scene_select_virtual_remote))
|
Text(stringResource(R.string.scene_select_virtual_remote))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
availableRemotes.forEachIndexed { idx, remote ->
|
availableRemotes.forEachIndexed { idx, remote ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selectedRemoteIndex == idx,
|
selected = selectedRemoteIndex == idx,
|
||||||
@@ -1070,7 +1101,7 @@ private fun AddActionDialog(
|
|||||||
}
|
}
|
||||||
Text(stringResource(R.string.scene_select_remote_button))
|
Text(stringResource(R.string.scene_select_remote_button))
|
||||||
if (keyBindings.isNotEmpty()) {
|
if (keyBindings.isNotEmpty()) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
keyBindings.take(4).forEach { binding ->
|
keyBindings.take(4).forEach { binding ->
|
||||||
val label = binding.displayName.ifBlank { binding.buttonId }
|
val label = binding.displayName.ifBlank { binding.buttonId }
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -1106,8 +1137,8 @@ private fun AddActionDialog(
|
|||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
val canConfirm = when (actionType) {
|
val canConfirm = when (actionType) {
|
||||||
0 -> socketName.isNotBlank()
|
0 -> availableDevices.any { it.type == DeviceType.SOCKET && it.name.value == socketName }
|
||||||
1 -> selectedButtonId.isNotBlank()
|
1 -> availableRemotes.isNotEmpty() && selectedButtonId.isNotBlank()
|
||||||
3 -> notifyTitle.isNotBlank()
|
3 -> notifyTitle.isNotBlank()
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
@@ -1119,8 +1150,6 @@ private fun AddActionDialog(
|
|||||||
val remote = availableRemotes.getOrNull(selectedRemoteIndex)
|
val remote = availableRemotes.getOrNull(selectedRemoteIndex)
|
||||||
if (remote != null) {
|
if (remote != null) {
|
||||||
onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName)
|
onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName)
|
||||||
} else {
|
|
||||||
onAddRemote("default-ir", DEFAULT_IR_DEVICE_NAME, selectedButtonId, selectedButtonName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2 -> onAddDelay(delaySeconds)
|
2 -> onAddDelay(delaySeconds)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import com.flagship.abox.manager.domain.AutomationType
|
|||||||
import com.flagship.abox.manager.domain.ComparisonOperator
|
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||||
import com.flagship.abox.manager.domain.ConcurrencyMode
|
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
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.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ExecutionStrategy
|
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
@@ -25,6 +28,7 @@ import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
|||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -50,6 +54,7 @@ data class SceneEditorState(
|
|||||||
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
||||||
val enableLogging: Boolean = true,
|
val enableLogging: Boolean = true,
|
||||||
val availableRemotes: List<VirtualRemote> = emptyList(),
|
val availableRemotes: List<VirtualRemote> = emptyList(),
|
||||||
|
val availableDevices: List<DeviceDescriptor> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
@@ -64,6 +69,8 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
private val sceneRepository: SceneRepository,
|
private val sceneRepository: SceneRepository,
|
||||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||||
private val profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val deviceCatalog: DeviceCatalog,
|
||||||
|
private val automationScheduler: AutomationScheduler? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(SceneEditorState())
|
private val _state = MutableStateFlow(SceneEditorState())
|
||||||
@@ -74,6 +81,23 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
observeAvailableRemotes()
|
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() {
|
private fun observeAvailableRemotes() {
|
||||||
@@ -191,11 +215,13 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
|
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
|
||||||
|
val parsedState = runCatching { SocketPowerState.valueOf(expectedState.trim().uppercase()) }.getOrNull()
|
||||||
|
?: return
|
||||||
val trigger = SceneTrigger.DeviceState(
|
val trigger = SceneTrigger.DeviceState(
|
||||||
id = SceneTriggerId(UUID.randomUUID().toString()),
|
id = SceneTriggerId(UUID.randomUUID().toString()),
|
||||||
deviceName = DeviceName(deviceName),
|
deviceName = DeviceName(deviceName),
|
||||||
expectedState = expectedState,
|
expectedState = parsedState,
|
||||||
label = "$deviceName 状态变为 $expectedState",
|
label = "$deviceName 状态变为 ${parsedState.name}",
|
||||||
)
|
)
|
||||||
_state.update { it.copy(triggers = it.triggers + trigger) }
|
_state.update { it.copy(triggers = it.triggers + trigger) }
|
||||||
}
|
}
|
||||||
@@ -371,7 +397,10 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
_state.update { current ->
|
_state.update { current ->
|
||||||
when (result) {
|
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(
|
is DomainResult.Failure -> current.copy(
|
||||||
isSaving = false,
|
isSaving = false,
|
||||||
errorMessage = result.error.detail ?: "Save failed",
|
errorMessage = result.error.detail ?: "Save failed",
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import com.flagship.abox.manager.domain.Scene
|
|||||||
import com.flagship.abox.manager.domain.SceneExecutionResult
|
import com.flagship.abox.manager.domain.SceneExecutionResult
|
||||||
import com.flagship.abox.manager.domain.SceneExecutor
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
import com.flagship.abox.manager.domain.SceneId
|
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 com.flagship.abox.manager.domain.SceneRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -15,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
sealed interface SceneExecutionUiState {
|
sealed interface SceneExecutionUiState {
|
||||||
data object Loading : SceneExecutionUiState
|
data object Loading : SceneExecutionUiState
|
||||||
@@ -71,11 +75,19 @@ class SceneExecutionViewModel @Inject constructor(
|
|||||||
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
|
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
when (val result = sceneExecutor.execute(scene)) {
|
sceneExecutor.execute(
|
||||||
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
|
scene = scene,
|
||||||
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
|
executionId = SceneExecutionId("manual-${scene.id.value}"),
|
||||||
result.error.detail ?: "Execution failed",
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
@@ -28,10 +29,11 @@ import androidx.compose.material.icons.outlined.Bolt
|
|||||||
import androidx.compose.material.icons.outlined.Schedule
|
import androidx.compose.material.icons.outlined.Schedule
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -49,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -68,7 +71,10 @@ import com.flagship.abox.manager.domain.Scene
|
|||||||
import com.flagship.abox.manager.domain.SceneColor
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
import com.flagship.abox.manager.domain.SceneIcon
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
import com.flagship.abox.manager.domain.SceneId
|
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.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -84,50 +90,59 @@ fun SceneListScreen(
|
|||||||
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
|
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
|
||||||
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||||
var showFabMenu by remember { mutableStateOf(false) }
|
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
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
viewModel.userMessage.collectLatest { msg ->
|
viewModel.userMessage.collectLatest { msg ->
|
||||||
if (msg == "executed") {
|
val messageRes = if (msg == "executed") R.string.scene_toast_executed else R.string.scene_toast_failed
|
||||||
Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, context.getString(messageRes), Toast.LENGTH_SHORT).show()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
Box {
|
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = { showFabMenu = true },
|
onClick = { showFabMenu = true },
|
||||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
expanded = fabExpanded,
|
||||||
text = { Text(stringResource(R.string.scene_create_action)) },
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = stringResource(R.string.scene_create_action),
|
||||||
)
|
)
|
||||||
DropdownMenu(
|
|
||||||
expanded = showFabMenu,
|
|
||||||
onDismissRequest = { showFabMenu = false },
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.scene_create_manual)) },
|
|
||||||
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
showFabMenu = false
|
|
||||||
onAddManual()
|
|
||||||
},
|
},
|
||||||
|
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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.scene_create_automatic)) },
|
|
||||||
leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
showFabMenu = false
|
|
||||||
onAddAutomatic()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 16.dp,
|
start = 16.dp,
|
||||||
@@ -211,6 +226,68 @@ fun SceneListScreen(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
stringResource(R.string.scene_create_sheet_description),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
SceneCreationOption(
|
||||||
|
icon = ABoxIconKey.SCENES,
|
||||||
|
title = stringResource(R.string.scene_create_manual),
|
||||||
|
description = stringResource(R.string.scene_create_manual_description),
|
||||||
|
onClick = {
|
||||||
|
showFabMenu = false
|
||||||
|
onAddManual()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
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
|
@Composable
|
||||||
@@ -250,9 +327,10 @@ private fun SceneCard(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
ABoxIcon(
|
||||||
text = scene.icon.displayEmoji(),
|
key = scene.icon.toABoxIconKey(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
contentDescription = scene.name,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@@ -323,7 +401,6 @@ private fun SceneCard(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
if (scene.type == AutomationType.MANUAL) {
|
|
||||||
FilledTonalButton(
|
FilledTonalButton(
|
||||||
onClick = onQuickExecute,
|
onClick = onQuickExecute,
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||||
@@ -335,7 +412,6 @@ private fun SceneCard(
|
|||||||
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
|
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(4.dp))
|
Spacer(Modifier.width(4.dp))
|
||||||
}
|
|
||||||
IconButton(onClick = onEdit, modifier = Modifier.size(32.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)
|
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
@@ -375,10 +451,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
ABoxIcon(ABoxIconKey.SCENES, contentDescription = "暂无情景", modifier = Modifier.size(48.dp))
|
||||||
text = "⚡",
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.scene_empty_title),
|
text = stringResource(R.string.scene_empty_title),
|
||||||
@@ -396,19 +469,19 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SceneIcon.displayEmoji(): String = when (this) {
|
fun SceneIcon.toABoxIconKey(): ABoxIconKey = when (this) {
|
||||||
SceneIcon.HOME -> "🏠"
|
SceneIcon.HOME -> ABoxIconKey.DEVICES
|
||||||
SceneIcon.OFFICE -> "💼"
|
SceneIcon.OFFICE -> ABoxIconKey.ACTIVITY
|
||||||
SceneIcon.NIGHT -> "🌙"
|
SceneIcon.NIGHT -> ABoxIconKey.PENDING
|
||||||
SceneIcon.MOVIE -> "🎬"
|
SceneIcon.MOVIE -> ABoxIconKey.SCENES
|
||||||
SceneIcon.AWAY -> "🚶"
|
SceneIcon.AWAY -> ABoxIconKey.OFFLINE
|
||||||
SceneIcon.READING -> "📖"
|
SceneIcon.READING -> ABoxIconKey.DEVICES
|
||||||
SceneIcon.DINING -> "🍽️"
|
SceneIcon.DINING -> ABoxIconKey.DEVICES
|
||||||
SceneIcon.GAMING -> "🎮"
|
SceneIcon.GAMING -> ABoxIconKey.SCENES
|
||||||
SceneIcon.THERMOSTAT -> "🌡️"
|
SceneIcon.THERMOSTAT -> ABoxIconKey.TEMPERATURE
|
||||||
SceneIcon.LIGHT -> "💡"
|
SceneIcon.LIGHT -> ABoxIconKey.POWER
|
||||||
SceneIcon.CLEAN -> "🧹"
|
SceneIcon.CLEAN -> ABoxIconKey.REFRESH
|
||||||
SceneIcon.CUSTOM -> "✨"
|
SceneIcon.CUSTOM -> ABoxIconKey.UNKNOWN_DEVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun SceneColor.toColorOrNull(): Color? {
|
internal fun SceneColor.toColorOrNull(): Color? {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.flagship.abox.manager.domain.Scene
|
|||||||
import com.flagship.abox.manager.domain.SceneExecutor
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
import com.flagship.abox.manager.domain.SceneId
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
import com.flagship.abox.manager.domain.SceneRepository
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.automation.runtime.AutomationScheduler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -38,6 +39,7 @@ class SceneListViewModel @Inject constructor(
|
|||||||
private val sceneRepository: SceneRepository,
|
private val sceneRepository: SceneRepository,
|
||||||
private val sceneExecutor: SceneExecutor,
|
private val sceneExecutor: SceneExecutor,
|
||||||
private val profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val automationScheduler: AutomationScheduler? = null,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
|
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
|
||||||
@@ -84,21 +86,29 @@ class SceneListViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val profile = profileStore.activeProfile() ?: return@launch
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
val updated = scene.copy(enabled = !scene.enabled)
|
val updated = scene.copy(enabled = !scene.enabled)
|
||||||
sceneRepository.updateScene(profile.id, updated)
|
if (sceneRepository.updateScene(profile.id, updated) is DomainResult.Success) {
|
||||||
|
automationScheduler?.reconcile(profile.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun executeQuick(scene: Scene) {
|
fun executeQuick(scene: Scene) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
sceneExecutor.execute(scene)
|
when (val result = sceneExecutor.execute(scene)) {
|
||||||
_userMessage.emit("executed")
|
is DomainResult.Success -> {
|
||||||
|
_userMessage.emit(if (result.value.failedCount == 0) "executed" else "failed")
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> _userMessage.emit("failed")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteScene(sceneId: SceneId) {
|
fun deleteScene(sceneId: SceneId) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val profile = profileStore.activeProfile() ?: return@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
|
_pendingDelete.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor(
|
|||||||
private val _uiState = MutableStateFlow(SessionActionsUiState())
|
private val _uiState = MutableStateFlow(SessionActionsUiState())
|
||||||
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
|
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 logout() = runAction(coordinator::logout)
|
||||||
|
|
||||||
fun switchProvider() = runAction(coordinator::switchProvider)
|
fun switchProvider() = onAction(SessionUiAction.SwitchProvider)
|
||||||
|
|
||||||
fun dismissError() {
|
fun dismissError() {
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = null)
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
@@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor(
|
|||||||
_uiState.value = SessionActionsUiState(inProgress = true)
|
_uiState.value = SessionActionsUiState(inProgress = true)
|
||||||
val result = action()
|
val result = action()
|
||||||
_uiState.value = SessionActionsUiState(
|
_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(
|
data class SessionActionsUiState(
|
||||||
val inProgress: Boolean = false,
|
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,179 +1,253 @@
|
|||||||
package com.flagship.abox.manager.ui.shell
|
package com.flagship.abox.manager.ui.shell
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
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.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.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 androidx.compose.ui.unit.dp
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
||||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
|
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.ui.devicecontrollist.DeviceListScreen
|
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
|
||||||
|
|
||||||
/**
|
/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
|
||||||
* 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)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainAppShell(
|
fun MainAppShell(
|
||||||
|
currentTab: MainTab,
|
||||||
|
windowSizeClass: ABoxWindowSizeClass,
|
||||||
userName: String,
|
userName: String,
|
||||||
providerName: String,
|
providerName: String,
|
||||||
onLogout: () -> Unit,
|
onTabClick: (MainTab) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
onProviderClick: () -> Unit,
|
||||||
initialTab: MainTab = MainTab.DEVICES,
|
onAvatarClick: () -> Unit,
|
||||||
onTabSelected: (MainTab) -> Unit = {},
|
showNavigationChrome: Boolean = true,
|
||||||
onAddClick: (() -> Unit)? = null,
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
actionInProgress: Boolean = false,
|
|
||||||
errorMessage: String? = null,
|
|
||||||
devicesSlot: @Composable () -> Unit = { DeviceListScreen() },
|
|
||||||
scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
|
|
||||||
activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
|
|
||||||
) {
|
) {
|
||||||
var activeTab by rememberSaveable { mutableStateOf(initialTab) }
|
val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
|
||||||
var showAccountSheet by remember { mutableStateOf(false) }
|
val renderer = LocalABoxRenderer.current
|
||||||
val tabStateHolder = rememberSaveableStateHolder()
|
val floating = renderer == DesignRenderer.MIUIX &&
|
||||||
|
LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
if (showNavigationChrome) {
|
||||||
|
ProviderTopBar(
|
||||||
|
currentTab = currentTab,
|
||||||
|
providerName = providerName,
|
||||||
|
userName = userName,
|
||||||
|
onProviderClick = onProviderClick,
|
||||||
|
onAvatarClick = onAvatarClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (showNavigationChrome && !useRail) {
|
||||||
|
MainBottomBar(currentTab, onTabClick, renderer, floating)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
if (showNavigationChrome && useRail) {
|
||||||
|
MainNavigationRail(
|
||||||
|
currentTab = currentTab,
|
||||||
|
onTabClick = onTabClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
) {
|
||||||
|
content(innerPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainBottomBar(
|
||||||
|
currentTab: MainTab,
|
||||||
|
onTabClick: (MainTab) -> Unit,
|
||||||
|
renderer: DesignRenderer,
|
||||||
|
floating: Boolean,
|
||||||
|
) {
|
||||||
|
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(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
ABoxTopPillBar(
|
Surface(
|
||||||
title = stringResource(R.string.top_bar_prompt_format, providerName),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
userName = userName,
|
shape = RoundedCornerShape(32.dp),
|
||||||
onAvatarClick = { showAccountSheet = true },
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
onAddClick = onAddClick,
|
tonalElevation = 2.dp,
|
||||||
addActionContentDescription = stringResource(R.string.action_add),
|
) {
|
||||||
avatarContentDescription = stringResource(R.string.action_account),
|
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(
|
||||||
bottomBar = {
|
text = stringResource(currentTab.labelRes),
|
||||||
ABoxNavigationBar {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
MainTab.values().forEach { tab ->
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
val isSelected = (tab == activeTab)
|
maxLines = 1,
|
||||||
ABoxNavigationBarItem(
|
overflow = TextOverflow.Ellipsis,
|
||||||
selected = isSelected,
|
modifier = Modifier
|
||||||
onClick = {
|
.weight(1f)
|
||||||
activeTab = tab
|
.padding(horizontal = 8.dp),
|
||||||
onTabSelected(tab)
|
)
|
||||||
},
|
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,
|
||||||
|
) {
|
||||||
|
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 = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon,
|
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
|
||||||
contentDescription = stringResource(tab.labelRes),
|
contentDescription = stringResource(tab.labelRes),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = {
|
label = { Text(stringResource(tab.labelRes)) },
|
||||||
Text(text = stringResource(tab.labelRes))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
Crossfade(
|
|
||||||
targetState = activeTab,
|
|
||||||
label = "MainTabCrossfade",
|
|
||||||
) { tab ->
|
|
||||||
tabStateHolder.SaveableStateProvider(tab) {
|
|
||||||
when (tab) {
|
|
||||||
MainTab.DEVICES -> devicesSlot()
|
|
||||||
MainTab.SCENES -> scenesSlot()
|
|
||||||
MainTab.ACTIVITY -> activitySlot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAccountSheet) {
|
|
||||||
AccountProviderBottomSheet(
|
|
||||||
userName = userName,
|
|
||||||
providerName = providerName,
|
|
||||||
onDismissRequest = { showAccountSheet = false },
|
|
||||||
onLogout = onLogout,
|
|
||||||
actionInProgress = actionInProgress,
|
|
||||||
errorMessage = errorMessage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import androidx.compose.material.icons.outlined.ViewAgenda
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
|
|
||||||
/**
|
/** Main destinations available while authenticated. */
|
||||||
* Main bottom navigation tabs for the authenticated application shell.
|
|
||||||
*/
|
|
||||||
enum class MainTab(
|
enum class MainTab(
|
||||||
@StringRes val labelRes: Int,
|
@StringRes val labelRes: Int,
|
||||||
val selectedIcon: ImageVector,
|
val selectedIcon: ImageVector,
|
||||||
@@ -24,18 +22,30 @@ enum class MainTab(
|
|||||||
labelRes = R.string.tab_devices,
|
labelRes = R.string.tab_devices,
|
||||||
selectedIcon = Icons.Filled.Home,
|
selectedIcon = Icons.Filled.Home,
|
||||||
unselectedIcon = Icons.Outlined.Home,
|
unselectedIcon = Icons.Outlined.Home,
|
||||||
route = "shell/devices",
|
route = "tab/devices",
|
||||||
),
|
),
|
||||||
SCENES(
|
SCENES(
|
||||||
labelRes = R.string.tab_scenes,
|
labelRes = R.string.tab_scenes,
|
||||||
selectedIcon = Icons.Filled.AutoAwesome,
|
selectedIcon = Icons.Filled.AutoAwesome,
|
||||||
unselectedIcon = Icons.Outlined.AutoAwesome,
|
unselectedIcon = Icons.Outlined.AutoAwesome,
|
||||||
route = "shell/scenes",
|
route = "tab/scenes",
|
||||||
),
|
),
|
||||||
ACTIVITY(
|
ACTIVITY(
|
||||||
labelRes = R.string.tab_activity,
|
labelRes = R.string.tab_activity,
|
||||||
selectedIcon = Icons.Filled.ViewAgenda,
|
selectedIcon = Icons.Filled.ViewAgenda,
|
||||||
unselectedIcon = Icons.Outlined.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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.socket
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -12,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -23,7 +23,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -38,6 +37,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
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.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -47,12 +48,17 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
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.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.DeviceName
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
import java.text.SimpleDateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SocketRoute(
|
fun SocketRoute(
|
||||||
@@ -79,38 +85,29 @@ fun SocketScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
val configuration = LocalConfiguration.current
|
||||||
|
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||||
Scaffold(
|
ABoxDeviceDetailScaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
title = state.deviceName?.value ?: stringResource(R.string.socket_title),
|
||||||
TopAppBar(
|
onBack = onBackClick,
|
||||||
title = {
|
subtitle = stringResource(R.string.socket_detail_subtitle),
|
||||||
Text(
|
icon = ABoxIconKey.SOCKET,
|
||||||
text = state.deviceName?.value ?: stringResource(R.string.socket_title),
|
action = {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
Row {
|
||||||
|
ABoxIconButton(
|
||||||
|
key = ABoxIconKey.EDIT,
|
||||||
|
contentDescription = stringResource(R.string.socket_name_settings),
|
||||||
|
onClick = { showDeviceNameDialog = true },
|
||||||
|
enabled = !state.isOperating,
|
||||||
)
|
)
|
||||||
},
|
ABoxIconButton(
|
||||||
navigationIcon = {
|
key = ABoxIconKey.REFRESH,
|
||||||
IconButton(onClick = onBackClick) {
|
contentDescription = stringResource(R.string.action_refresh),
|
||||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = { showEditNameDialog = true },
|
|
||||||
enabled = !state.isLoading && !state.isOperating,
|
|
||||||
) {
|
|
||||||
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { onEvent(SocketUiEvent.Refresh) },
|
onClick = { onEvent(SocketUiEvent.Refresh) },
|
||||||
enabled = !state.isLoading && !state.isOperating,
|
enabled = !state.isLoading && !state.isOperating,
|
||||||
) {
|
|
||||||
Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
@@ -136,20 +133,28 @@ fun SocketScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().height(64.dp)) {
|
||||||
|
SocketOperationBanner(operation = state.operation)
|
||||||
|
}
|
||||||
|
|
||||||
// Central Status Card
|
// Central Status Card
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 16.dp),
|
.padding(top = 8.dp, bottom = 18.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = if (state.isPowerOn) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(32.dp),
|
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
@@ -157,13 +162,16 @@ fun SocketScreen(
|
|||||||
powerState = state.powerState,
|
powerState = state.powerState,
|
||||||
isOffline = state.isOffline,
|
isOffline = state.isOffline,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
|
isOperating = state.isOperating,
|
||||||
|
enabled = state.isActionEnabled,
|
||||||
|
onClick = { onEvent(SocketUiEvent.TogglePower) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.socket_status_label),
|
text = stringResource(R.string.socket_status_label),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,7 +182,7 @@ fun SocketScreen(
|
|||||||
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
||||||
else -> stringResource(R.string.socket_status_unknown)
|
else -> stringResource(R.string.socket_status_unknown)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = when {
|
color = when {
|
||||||
state.isOffline -> MaterialTheme.colorScheme.error
|
state.isOffline -> MaterialTheme.colorScheme.error
|
||||||
@@ -185,7 +193,9 @@ fun SocketScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
state.lastConfirmedTimeMillis?.let { timeMillis ->
|
state.lastConfirmedTimeMillis?.let { timeMillis ->
|
||||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
val formattedTime = remember(timeMillis, configuration) {
|
||||||
|
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.socket_confirmed_at, formattedTime),
|
text = stringResource(R.string.socket_confirmed_at, formattedTime),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -196,100 +206,68 @@ fun SocketScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// Main Toggle Action Button
|
|
||||||
ABoxButton(
|
|
||||||
onClick = { onEvent(SocketUiEvent.TogglePower) },
|
|
||||||
enabled = state.isActionEnabled,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(56.dp),
|
|
||||||
) {
|
|
||||||
if (state.isOperating) {
|
|
||||||
ABoxLoadingIndicator(modifier = Modifier.size(24.dp))
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(stringResource(R.string.socket_operating_label))
|
|
||||||
} else {
|
|
||||||
Text(
|
Text(
|
||||||
text = if (state.isPowerOn) {
|
text = stringResource(R.string.socket_confirmation_hint),
|
||||||
stringResource(R.string.socket_action_turn_off)
|
style = MaterialTheme.typography.bodySmall,
|
||||||
} else {
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
stringResource(R.string.socket_action_turn_on)
|
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)))
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
@Composable
|
||||||
|
private fun DeviceNameDialog(
|
||||||
// Dedicated Segmented Action Buttons
|
currentName: String,
|
||||||
Row(
|
title: String,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
description: String,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
) {
|
) {
|
||||||
OutlinedButton(
|
var value by remember(currentName) { mutableStateOf(currentName) }
|
||||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) },
|
|
||||||
enabled = state.isActionEnabled && state.powerState != SocketPowerState.ON,
|
|
||||||
modifier = Modifier.weight(1f).height(48.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.socket_action_turn_on))
|
|
||||||
}
|
|
||||||
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) },
|
|
||||||
enabled = state.isActionEnabled && state.powerState != SocketPowerState.OFF,
|
|
||||||
modifier = Modifier.weight(1f).height(48.dp),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.socket_action_turn_off))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showEditNameDialog) {
|
|
||||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showEditNameDialog = false },
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
title = { Text(title) },
|
||||||
text = {
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(description, style = MaterialTheme.typography.bodySmall)
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = inputName,
|
value = value,
|
||||||
onValueChange = { inputName = it },
|
onValueChange = { value = it },
|
||||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
label = { Text(stringResource(R.string.gateway_device_name)) },
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
|
||||||
onClick = {
|
|
||||||
val trimmed = inputName.trim()
|
|
||||||
if (trimmed.isNotBlank()) {
|
|
||||||
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
|
||||||
}
|
|
||||||
showEditNameDialog = false
|
|
||||||
},
|
|
||||||
enabled = inputName.isNotBlank(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showEditNameDialog = false }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun PowerIndicatorBadge(
|
private fun PowerIndicatorBadge(
|
||||||
powerState: SocketPowerState?,
|
powerState: SocketPowerState?,
|
||||||
isOffline: Boolean,
|
isOffline: Boolean,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
isOperating: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val indicatorColor = when {
|
val indicatorColor = when {
|
||||||
@@ -301,21 +279,71 @@ private fun PowerIndicatorBadge(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(96.dp)
|
.size(160.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(indicatorColor.copy(alpha = 0.15f))
|
.background(indicatorColor.copy(alpha = 0.15f))
|
||||||
.border(3.dp, indicatorColor, CircleShape),
|
.border(4.dp, indicatorColor, CircleShape)
|
||||||
|
.clickable(enabled = enabled, onClickLabel = "切换插座电源", onClick = onClick)
|
||||||
|
.testTag("socket_power_dial"),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
ABoxLoadingIndicator(modifier = Modifier.size(36.dp))
|
if (isLoading || isOperating) {
|
||||||
|
ABoxLoadingIndicator(modifier = Modifier.size(44.dp))
|
||||||
} else {
|
} else {
|
||||||
Text(
|
ABoxIcon(
|
||||||
text = if (powerState == SocketPowerState.ON) "⚡" else "○",
|
key = ABoxIconKey.POWER,
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭",
|
||||||
color = indicatorColor,
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +405,11 @@ private fun ErrorBanner(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onDismiss) {
|
ABoxIconButton(
|
||||||
Text("✕")
|
key = ABoxIconKey.CLOSE,
|
||||||
}
|
contentDescription = "关闭错误提示",
|
||||||
|
onClick = onDismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,27 @@ data class SocketUiState(
|
|||||||
val isOffline: Boolean = false,
|
val isOffline: Boolean = false,
|
||||||
val errorMessage: UiText? = null,
|
val errorMessage: UiText? = null,
|
||||||
val lastConfirmedTimeMillis: Long? = null,
|
val lastConfirmedTimeMillis: Long? = null,
|
||||||
|
val operation: SocketOperationState = SocketOperationState.IdleConfirmed,
|
||||||
) {
|
) {
|
||||||
val isActionEnabled: Boolean
|
val isActionEnabled: Boolean
|
||||||
get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null
|
get() = !isLoading && operation !is SocketOperationState.PendingCommand &&
|
||||||
|
operation !is SocketOperationState.RefreshingConfirmation &&
|
||||||
|
operation !is SocketOperationState.AwaitingConfirmation &&
|
||||||
|
!isOffline && profileId != null && deviceName != null
|
||||||
|
|
||||||
val isPowerOn: Boolean
|
val isPowerOn: Boolean
|
||||||
get() = powerState == SocketPowerState.ON
|
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 {
|
sealed interface SocketUiEvent {
|
||||||
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
|
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
|
||||||
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ import com.flagship.abox.manager.domain.DomainResult
|
|||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -23,6 +28,8 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SocketViewModel @Inject constructor(
|
class SocketViewModel @Inject constructor(
|
||||||
private val socketGateway: SocketGateway,
|
private val socketGateway: SocketGateway,
|
||||||
|
private val configuredDeviceRepository: ConfiguredDeviceRepository,
|
||||||
|
private val activityWriter: ActivityEventWriter,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -56,6 +63,7 @@ class SocketViewModel @Inject constructor(
|
|||||||
it.copy(
|
it.copy(
|
||||||
profileId = event.profileId,
|
profileId = event.profileId,
|
||||||
deviceName = event.deviceName,
|
deviceName = event.deviceName,
|
||||||
|
operation = SocketOperationState.Unavailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadStatus()
|
loadStatus()
|
||||||
@@ -63,17 +71,20 @@ class SocketViewModel @Inject constructor(
|
|||||||
|
|
||||||
is SocketUiEvent.ChangeDeviceName -> {
|
is SocketUiEvent.ChangeDeviceName -> {
|
||||||
val trimmed = event.deviceName.value.trim()
|
val trimmed = event.deviceName.value.trim()
|
||||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
val current = _state.value
|
||||||
_state.update {
|
val profileId = current.profileId
|
||||||
it.copy(
|
val oldName = current.deviceName
|
||||||
deviceName = DeviceName(trimmed),
|
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
|
||||||
powerState = null,
|
viewModelScope.launch {
|
||||||
errorMessage = null,
|
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
|
||||||
lastConfirmedTimeMillis = null,
|
is DomainResult.Success -> {
|
||||||
)
|
_state.update { it.copy(deviceName = DeviceName(trimmed), powerState = null, errorMessage = null, lastConfirmedTimeMillis = null) }
|
||||||
}
|
|
||||||
loadStatus()
|
loadStatus()
|
||||||
}
|
}
|
||||||
|
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SocketUiEvent.Refresh -> {
|
SocketUiEvent.Refresh -> {
|
||||||
@@ -115,6 +126,7 @@ class SocketViewModel @Inject constructor(
|
|||||||
isOffline = false,
|
isOffline = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||||
|
operation = SocketOperationState.IdleConfirmed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +138,7 @@ class SocketViewModel @Inject constructor(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
isOffline = isOffline,
|
isOffline = isOffline,
|
||||||
errorMessage = DomainErrorMapper.map(result.error),
|
errorMessage = DomainErrorMapper.map(result.error),
|
||||||
|
operation = SocketOperationState.Unavailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,36 +152,83 @@ class SocketViewModel @Inject constructor(
|
|||||||
val deviceName = current.deviceName ?: return
|
val deviceName = current.deviceName ?: return
|
||||||
|
|
||||||
// Prevent duplicate action if currently operating, loading or offline
|
// Prevent duplicate action if currently operating, loading or offline
|
||||||
if (current.isOperating || current.isLoading || current.isOffline) {
|
if (current.isOperating || current.isLoading || current.isOffline || !current.isActionEnabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isOperating = true, errorMessage = null) }
|
when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) {
|
||||||
when (val result = socketGateway.setPower(profileId, deviceName, targetState)) {
|
|
||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
|
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
powerState = result.value.powerState,
|
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,
|
isOperating = false,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
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 -> {
|
is DomainResult.Failure -> {
|
||||||
|
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.FAILED, result.error.externalCode)
|
||||||
val isOffline = result.error is DomainError.DeviceOffline
|
val isOffline = result.error is DomainError.DeviceOffline
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isOperating = false,
|
isOperating = false,
|
||||||
isOffline = isOffline,
|
isOffline = isOffline,
|
||||||
errorMessage = DomainErrorMapper.map(result.error),
|
errorMessage = DomainErrorMapper.map(result.error),
|
||||||
|
operation = SocketOperationState.Failed(targetState),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CONFIRMATION_ATTEMPTS = 4
|
||||||
|
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -39,11 +41,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
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.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 com.flagship.abox.manager.domain.DeviceName
|
||||||
import java.text.SimpleDateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TemperatureHumidityRoute(
|
fun TemperatureHumidityRoute(
|
||||||
@@ -70,37 +75,20 @@ fun TemperatureHumidityScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
val configuration = LocalConfiguration.current
|
||||||
|
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||||
Scaffold(
|
ABoxDeviceDetailScaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
title = state.deviceName?.value ?: stringResource(R.string.th_title),
|
||||||
TopAppBar(
|
onBack = onBackClick,
|
||||||
title = {
|
subtitle = stringResource(R.string.device_sensor_subtitle),
|
||||||
Text(
|
icon = ABoxIconKey.SENSOR,
|
||||||
text = state.deviceName?.value ?: stringResource(R.string.th_title),
|
action = {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
ABoxIconButton(
|
||||||
)
|
key = ABoxIconKey.EDIT,
|
||||||
},
|
contentDescription = stringResource(R.string.sensor_name_settings),
|
||||||
navigationIcon = {
|
onClick = { showDeviceNameDialog = true },
|
||||||
IconButton(onClick = onBackClick) {
|
|
||||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = { showEditNameDialog = true },
|
|
||||||
enabled = !state.isLoading && !state.isRefreshing,
|
enabled = !state.isLoading && !state.isRefreshing,
|
||||||
) {
|
|
||||||
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
|
|
||||||
enabled = !state.isLoading && !state.isRefreshing,
|
|
||||||
) {
|
|
||||||
Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
@@ -151,31 +139,41 @@ fun TemperatureHumidityScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Telemetry Cards Row / Column
|
// 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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 12.dp),
|
.padding(bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
TelemetryMetricCard(
|
TelemetryMetricCard(
|
||||||
title = stringResource(R.string.th_temperature_label),
|
title = stringResource(R.string.th_temperature_label),
|
||||||
value = state.temperature ?: "--",
|
value = state.temperature,
|
||||||
unit = stringResource(R.string.th_unit_celsius),
|
unit = stringResource(R.string.th_unit_celsius),
|
||||||
icon = "🌡️",
|
icon = ABoxIconKey.TEMPERATURE,
|
||||||
|
testTag = "sensor_metric_temperature",
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
TelemetryMetricCard(
|
TelemetryMetricCard(
|
||||||
title = stringResource(R.string.th_humidity_label),
|
title = stringResource(R.string.th_humidity_label),
|
||||||
value = state.humidity ?: "--",
|
value = state.humidity,
|
||||||
unit = stringResource(R.string.th_unit_percent),
|
unit = stringResource(R.string.th_unit_percent),
|
||||||
icon = "💧",
|
icon = ABoxIconKey.HUMIDITY,
|
||||||
|
testTag = "sensor_metric_humidity",
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.lastUpdatedTimeMillis?.let { timeMillis ->
|
state.lastUpdatedTimeMillis?.let { timeMillis ->
|
||||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
val formattedTime = remember(timeMillis, configuration) {
|
||||||
|
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_last_updated, formattedTime),
|
text = stringResource(R.string.th_last_updated, formattedTime),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -189,7 +187,7 @@ fun TemperatureHumidityScreen(
|
|||||||
|
|
||||||
// Read-Only Informational Card
|
// Read-Only Informational Card
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().testTag("sensor_monitoring_notice"),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
),
|
),
|
||||||
@@ -201,13 +199,13 @@ fun TemperatureHumidityScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_read_only_notice),
|
text = stringResource(R.string.sensor_monitoring_title),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_read_only_description),
|
text = stringResource(R.string.sensor_monitoring_description),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
@@ -215,7 +213,7 @@ fun TemperatureHumidityScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Refresh Button
|
// Refresh Button
|
||||||
ABoxButton(
|
ABoxButton(
|
||||||
@@ -223,7 +221,7 @@ fun TemperatureHumidityScreen(
|
|||||||
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
|
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(52.dp),
|
.height(56.dp),
|
||||||
) {
|
) {
|
||||||
if (state.isRefreshing || state.isLoading) {
|
if (state.isRefreshing || state.isLoading) {
|
||||||
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
|
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
|
||||||
@@ -239,53 +237,59 @@ fun TemperatureHumidityScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEditNameDialog) {
|
if (showDeviceNameDialog) {
|
||||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
SensorDeviceNameDialog(
|
||||||
AlertDialog(
|
currentName = state.deviceName?.value.orEmpty(),
|
||||||
onDismissRequest = { showEditNameDialog = false },
|
onDismiss = { showDeviceNameDialog = false },
|
||||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
onConfirm = { name ->
|
||||||
text = {
|
showDeviceNameDialog = false
|
||||||
OutlinedTextField(
|
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(name)))
|
||||||
value = inputName,
|
},
|
||||||
onValueChange = { inputName = it },
|
|
||||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
|
||||||
onClick = {
|
|
||||||
val trimmed = inputName.trim()
|
|
||||||
if (trimmed.isNotBlank()) {
|
|
||||||
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
|
||||||
}
|
|
||||||
showEditNameDialog = false
|
|
||||||
},
|
|
||||||
enabled = inputName.isNotBlank(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showEditNameDialog = false }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelemetryMetricCard(
|
private fun TelemetryMetricCard(
|
||||||
title: String,
|
title: String,
|
||||||
value: String,
|
value: String?,
|
||||||
unit: String,
|
unit: String,
|
||||||
icon: String,
|
icon: ABoxIconKey,
|
||||||
|
testTag: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
if (value == null) return
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier.testTag(testTag),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -298,10 +302,7 @@ private fun TelemetryMetricCard(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
ABoxIcon(icon, contentDescription = title, modifier = Modifier.size(28.dp))
|
||||||
text = icon,
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
@@ -323,7 +324,6 @@ private fun TelemetryMetricCard(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
if (value != "--") {
|
|
||||||
Text(
|
Text(
|
||||||
text = " $unit",
|
text = " $unit",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
@@ -334,7 +334,6 @@ private fun TelemetryMetricCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoDataCard(
|
private fun NoDataCard(
|
||||||
@@ -355,7 +354,11 @@ private fun NoDataCard(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("📭", style = MaterialTheme.typography.displaySmall)
|
ABoxIcon(
|
||||||
|
key = ABoxIconKey.SENSOR,
|
||||||
|
contentDescription = "暂无传感器数据",
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_no_data_title),
|
text = stringResource(R.string.th_no_data_title),
|
||||||
@@ -434,9 +437,11 @@ private fun ErrorBanner(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onDismiss) {
|
ABoxIconButton(
|
||||||
Text("✕")
|
key = ABoxIconKey.CLOSE,
|
||||||
}
|
contentDescription = "关闭错误提示",
|
||||||
|
onClick = onDismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ data class TemperatureHumidityUiState(
|
|||||||
val errorMessage: UiText? = null,
|
val errorMessage: UiText? = null,
|
||||||
val lastUpdatedTimeMillis: Long? = null,
|
val lastUpdatedTimeMillis: Long? = null,
|
||||||
) {
|
) {
|
||||||
val formattedTemperature: String
|
val formattedTemperature: String?
|
||||||
get() = if (temperature.isNullOrBlank()) "--" else "$temperature °C"
|
get() = temperature?.takeIf { it.isNotBlank() }?.let { "$it °C" }
|
||||||
|
|
||||||
val formattedHumidity: String
|
val formattedHumidity: String?
|
||||||
get() = if (humidity.isNullOrBlank()) "--" else "$humidity %"
|
get() = humidity?.takeIf { it.isNotBlank() }?.let { "$it %" }
|
||||||
|
|
||||||
val hasValidData: Boolean
|
val hasValidData: Boolean
|
||||||
get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank()
|
get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank()
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import com.flagship.abox.manager.domain.DomainError
|
|||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -22,6 +26,8 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TemperatureHumidityViewModel @Inject constructor(
|
class TemperatureHumidityViewModel @Inject constructor(
|
||||||
private val thGateway: TemperatureHumidityGateway,
|
private val thGateway: TemperatureHumidityGateway,
|
||||||
|
private val configuredDeviceRepository: ConfiguredDeviceRepository,
|
||||||
|
private val activityWriter: ActivityEventWriter,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -62,19 +68,20 @@ class TemperatureHumidityViewModel @Inject constructor(
|
|||||||
|
|
||||||
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
|
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
|
||||||
val trimmed = event.deviceName.value.trim()
|
val trimmed = event.deviceName.value.trim()
|
||||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
val current = _state.value
|
||||||
_state.update {
|
val profileId = current.profileId
|
||||||
it.copy(
|
val oldName = current.deviceName
|
||||||
deviceName = DeviceName(trimmed),
|
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
|
||||||
temperature = null,
|
viewModelScope.launch {
|
||||||
humidity = null,
|
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
|
||||||
hasNoData = false,
|
is DomainResult.Success -> {
|
||||||
errorMessage = null,
|
_state.update { it.copy(deviceName = DeviceName(trimmed), temperature = null, humidity = null, hasNoData = false, errorMessage = null, lastUpdatedTimeMillis = null) }
|
||||||
lastUpdatedTimeMillis = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
loadReading(isRefresh = false)
|
loadReading(isRefresh = false)
|
||||||
}
|
}
|
||||||
|
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TemperatureHumidityUiEvent.Refresh -> {
|
TemperatureHumidityUiEvent.Refresh -> {
|
||||||
@@ -100,12 +107,13 @@ class TemperatureHumidityViewModel @Inject constructor(
|
|||||||
|
|
||||||
when (val result = thGateway.getReading(profileId, deviceName)) {
|
when (val result = thGateway.getReading(profileId, deviceName)) {
|
||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
|
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.SUCCEEDED)
|
||||||
val reading = result.value
|
val reading = result.value
|
||||||
val isBlankData = reading.temperature.isBlank() && reading.humidity.isBlank()
|
val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
temperature = reading.temperature.takeIf { t -> t.isNotBlank() },
|
temperature = reading.temperature?.takeIf { t -> t.isNotBlank() },
|
||||||
humidity = reading.humidity.takeIf { h -> h.isNotBlank() },
|
humidity = reading.humidity?.takeIf { h -> h.isNotBlank() },
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
@@ -117,6 +125,7 @@ class TemperatureHumidityViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is DomainResult.Failure -> {
|
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 isOffline = result.error is DomainError.DeviceOffline
|
||||||
val hasNoData = result.error is DomainError.NoData
|
val hasNoData = result.error is DomainError.NoData
|
||||||
_state.update {
|
_state.update {
|
||||||
|
|||||||
|
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>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ABox Manager</string>
|
<string name="app_name">ABox 家庭</string>
|
||||||
<string name="provider_selection_title">选择提供方</string>
|
<string name="provider_selection_title">选择提供方</string>
|
||||||
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
|
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
|
||||||
<string name="provider_abox_name">ABox</string>
|
<string name="provider_abox_name">ABox</string>
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
|
<string name="scene_empty_message">点击右下角按钮,建立一键指令或带触发器的自动指令。</string>
|
||||||
<string name="scene_action_run_now">立即执行</string>
|
<string name="scene_action_run_now">立即执行</string>
|
||||||
<string name="scene_toast_executed">指令已触发执行</string>
|
<string name="scene_toast_executed">指令已触发执行</string>
|
||||||
|
<string name="scene_toast_failed">指令执行失败</string>
|
||||||
|
|
||||||
<!-- Scene: delete dialog -->
|
<!-- Scene: delete dialog -->
|
||||||
<string name="scene_delete_dialog_title">删除指令</string>
|
<string name="scene_delete_dialog_title">删除指令</string>
|
||||||
@@ -82,9 +83,9 @@
|
|||||||
<string name="scene_section_other">其他选项与外观</string>
|
<string name="scene_section_other">其他选项与外观</string>
|
||||||
|
|
||||||
<!-- Add Buttons -->
|
<!-- Add Buttons -->
|
||||||
<string name="scene_add_trigger">+ 新增触发器</string>
|
<string name="scene_add_trigger">新增触发器</string>
|
||||||
<string name="scene_add_condition">+ 新增条件</string>
|
<string name="scene_add_condition">新增条件</string>
|
||||||
<string name="scene_add_action_btn">+ 新增动作</string>
|
<string name="scene_add_action_btn">新增动作</string>
|
||||||
<string name="scene_add_action">添加</string>
|
<string name="scene_add_action">添加</string>
|
||||||
<string name="scene_save_action">保存指令</string>
|
<string name="scene_save_action">保存指令</string>
|
||||||
|
|
||||||
@@ -104,10 +105,10 @@
|
|||||||
|
|
||||||
<!-- Trigger Types & Dialog -->
|
<!-- Trigger Types & Dialog -->
|
||||||
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
|
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
|
||||||
<string name="scene_trigger_type_time">⏰ 定时时间触发</string>
|
<string name="scene_trigger_type_time">定时时间触发</string>
|
||||||
<string name="scene_trigger_type_temp">🌡️ 环境温度触发</string>
|
<string name="scene_trigger_type_temp">环境温度触发</string>
|
||||||
<string name="scene_trigger_type_humidity">💧 环境湿度触发</string>
|
<string name="scene_trigger_type_humidity">环境湿度触发</string>
|
||||||
<string name="scene_trigger_type_device">🔌 插座状态变更触发</string>
|
<string name="scene_trigger_type_device">插座状态变更触发</string>
|
||||||
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
|
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
|
||||||
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
|
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
|
||||||
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
|
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
|
||||||
@@ -118,19 +119,19 @@
|
|||||||
|
|
||||||
<!-- Condition Types & Dialog -->
|
<!-- Condition Types & Dialog -->
|
||||||
<string name="scene_dialog_add_condition_title">选择限制条件</string>
|
<string name="scene_dialog_add_condition_title">选择限制条件</string>
|
||||||
<string name="scene_condition_type_temp">🌡️ 环境温度范围</string>
|
<string name="scene_condition_type_temp">环境温度范围</string>
|
||||||
<string name="scene_condition_type_humidity">💧 环境湿度范围</string>
|
<string name="scene_condition_type_humidity">环境湿度范围</string>
|
||||||
<string name="scene_condition_type_socket">🔌 插座当前电源状态</string>
|
<string name="scene_condition_type_socket">插座当前电源状态</string>
|
||||||
<string name="scene_condition_type_time_range">🕒 生效时间区间</string>
|
<string name="scene_condition_type_time_range">生效时间区间</string>
|
||||||
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
|
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
|
||||||
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
|
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
|
||||||
|
|
||||||
<!-- Action Types & Dialog -->
|
<!-- Action Types & Dialog -->
|
||||||
<string name="scene_dialog_add_action_title">选择执行动作</string>
|
<string name="scene_dialog_add_action_title">选择执行动作</string>
|
||||||
<string name="scene_action_type_socket">🔌 智能插座控制</string>
|
<string name="scene_action_type_socket">智能插座控制</string>
|
||||||
<string name="scene_action_type_virtual_remote">📺 红外遥控按键</string>
|
<string name="scene_action_type_virtual_remote">红外遥控按键</string>
|
||||||
<string name="scene_action_type_delay">⏱️ 延时等待 (缓冲)</string>
|
<string name="scene_action_type_delay">延时等待(缓冲)</string>
|
||||||
<string name="scene_action_type_notification">🔔 发送系统提示</string>
|
<string name="scene_action_type_notification">发送系统提示</string>
|
||||||
<string name="scene_select_socket">选择目标插座</string>
|
<string name="scene_select_socket">选择目标插座</string>
|
||||||
<string name="scene_socket_power_state">设定电源状态</string>
|
<string name="scene_socket_power_state">设定电源状态</string>
|
||||||
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
|
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
|
||||||
@@ -144,8 +145,8 @@
|
|||||||
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
|
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
|
||||||
<string name="scene_action_remote">遥控【%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_infrared">红外【%1$s】: 槽位 %2$s</string>
|
||||||
<string name="scene_action_delay">⏱️ 等待 %1$d 秒</string>
|
<string name="scene_action_delay">等待 %1$d 秒</string>
|
||||||
<string name="scene_action_notification">🔔 提示: %1$s</string>
|
<string name="scene_action_notification">提示:%1$s</string>
|
||||||
|
|
||||||
<!-- Scene: execution -->
|
<!-- Scene: execution -->
|
||||||
<string name="scene_execution_title">执行指令</string>
|
<string name="scene_execution_title">执行指令</string>
|
||||||
@@ -164,13 +165,13 @@
|
|||||||
<!-- 校验与弹窗选项/占位文案 -->
|
<!-- 校验与弹窗选项/占位文案 -->
|
||||||
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</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_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
|
||||||
<string name="scene_chip_time">⏰ 定时</string>
|
<string name="scene_chip_time">定时</string>
|
||||||
<string name="scene_chip_temp">🌡️ 温度</string>
|
<string name="scene_chip_temp">温度</string>
|
||||||
<string name="scene_chip_humidity">💧 湿度</string>
|
<string name="scene_chip_humidity">湿度</string>
|
||||||
<string name="scene_chip_socket">🔌 插座</string>
|
<string name="scene_chip_socket">插座</string>
|
||||||
<string name="scene_chip_remote">📺 遥控</string>
|
<string name="scene_chip_remote">遥控</string>
|
||||||
<string name="scene_chip_delay">⏱️ 延时</string>
|
<string name="scene_chip_delay">延时</string>
|
||||||
<string name="scene_chip_notify">🔔 提示</string>
|
<string name="scene_chip_notify">提示</string>
|
||||||
<string name="scene_chip_above">> 高于</string>
|
<string name="scene_chip_above">> 高于</string>
|
||||||
<string name="scene_chip_below">< 低于</string>
|
<string name="scene_chip_below">< 低于</string>
|
||||||
<string name="scene_chip_power_on">开启</string>
|
<string name="scene_chip_power_on">开启</string>
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
|
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
|
||||||
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
|
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
|
||||||
<string name="scene_empty_actions_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_triggers_and_actions">%1$d 个触发器 · %2$d 个动作</string>
|
||||||
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
|
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
|
||||||
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
|
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
|
||||||
<string name="scene_exec_rerun">重新执行</string>
|
<string name="scene_exec_rerun">重新执行</string>
|
||||||
@@ -222,8 +223,6 @@
|
|||||||
<string name="socket_status_offline">已离线</string>
|
<string name="socket_status_offline">已离线</string>
|
||||||
<string name="socket_status_unknown">未知状态</string>
|
<string name="socket_status_unknown">未知状态</string>
|
||||||
<string name="socket_action_toggle">切换电源</string>
|
<string name="socket_action_toggle">切换电源</string>
|
||||||
<string name="socket_action_turn_on">开启插座</string>
|
|
||||||
<string name="socket_action_turn_off">关闭插座</string>
|
|
||||||
<string name="socket_operating_label">正在切换状态…</string>
|
<string name="socket_operating_label">正在切换状态…</string>
|
||||||
<string name="socket_confirmed_at">已于 %1$s 确认状态</string>
|
<string name="socket_confirmed_at">已于 %1$s 确认状态</string>
|
||||||
|
|
||||||
@@ -259,4 +258,147 @@
|
|||||||
<string name="error_operation_failed">操作执行失败,请重试。</string>
|
<string name="error_operation_failed">操作执行失败,请重试。</string>
|
||||||
<string name="error_no_data">暂无可用数据。</string>
|
<string name="error_no_data">暂无可用数据。</string>
|
||||||
<string name="error_unknown">发生未知错误。</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>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<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_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_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
|
||||||
<string name="provider_abox_name">ABox</string>
|
<string name="provider_abox_name">ABox</string>
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
<string name="scene_empty_message">Tap the button below to create instant commands or trigger-based automatic rules.</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_action_run_now">Run Now</string>
|
||||||
<string name="scene_toast_executed">Command triggered</string>
|
<string name="scene_toast_executed">Command triggered</string>
|
||||||
|
<string name="scene_toast_failed">Command execution failed</string>
|
||||||
|
|
||||||
<!-- Scene: delete dialog -->
|
<!-- Scene: delete dialog -->
|
||||||
<string name="scene_delete_dialog_title">Delete Command</string>
|
<string name="scene_delete_dialog_title">Delete Command</string>
|
||||||
@@ -82,9 +83,9 @@
|
|||||||
<string name="scene_section_other">Other Options & Appearance</string>
|
<string name="scene_section_other">Other Options & Appearance</string>
|
||||||
|
|
||||||
<!-- Add Buttons -->
|
<!-- Add Buttons -->
|
||||||
<string name="scene_add_trigger">+ Add Trigger</string>
|
<string name="scene_add_trigger">Add Trigger</string>
|
||||||
<string name="scene_add_condition">+ Add Condition</string>
|
<string name="scene_add_condition">Add Condition</string>
|
||||||
<string name="scene_add_action_btn">+ Add Action</string>
|
<string name="scene_add_action_btn">Add Action</string>
|
||||||
<string name="scene_add_action">Add</string>
|
<string name="scene_add_action">Add</string>
|
||||||
<string name="scene_save_action">Save Command</string>
|
<string name="scene_save_action">Save Command</string>
|
||||||
|
|
||||||
@@ -104,10 +105,10 @@
|
|||||||
|
|
||||||
<!-- Trigger Types & Dialog -->
|
<!-- Trigger Types & Dialog -->
|
||||||
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
|
<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_time">Time / Schedule Trigger</string>
|
||||||
<string name="scene_trigger_type_temp">🌡️ Temperature Threshold</string>
|
<string name="scene_trigger_type_temp">Temperature Threshold</string>
|
||||||
<string name="scene_trigger_type_humidity">💧 Humidity 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_type_device">Socket State Changed</string>
|
||||||
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</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_temp_threshold">Temperature Threshold (°C)</string>
|
||||||
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
|
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
|
||||||
@@ -118,19 +119,19 @@
|
|||||||
|
|
||||||
<!-- Condition Types & Dialog -->
|
<!-- Condition Types & Dialog -->
|
||||||
<string name="scene_dialog_add_condition_title">Select Condition</string>
|
<string name="scene_dialog_add_condition_title">Select Condition</string>
|
||||||
<string name="scene_condition_type_temp">🌡️ Temperature Range</string>
|
<string name="scene_condition_type_temp">Temperature Range</string>
|
||||||
<string name="scene_condition_type_humidity">💧 Humidity 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_socket">Current Socket Power</string>
|
||||||
<string name="scene_condition_type_time_range">🕒 Active Time Range</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_start_time">Start Time (e.g. 22:00)</string>
|
||||||
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
|
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
|
||||||
|
|
||||||
<!-- Action Types & Dialog -->
|
<!-- Action Types & Dialog -->
|
||||||
<string name="scene_dialog_add_action_title">Select Action</string>
|
<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_socket">Smart Socket Control</string>
|
||||||
<string name="scene_action_type_virtual_remote">📺 Infrared Remote Button</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_delay">Delay / Buffer Timer</string>
|
||||||
<string name="scene_action_type_notification">🔔 System Notification</string>
|
<string name="scene_action_type_notification">System Notification</string>
|
||||||
<string name="scene_select_socket">Select Target Socket</string>
|
<string name="scene_select_socket">Select Target Socket</string>
|
||||||
<string name="scene_socket_power_state">Target Power State</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_virtual_remote">Select Virtual Remote</string>
|
||||||
@@ -144,8 +145,8 @@
|
|||||||
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
|
<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_remote">Remote [%1$s]: Send %2$s</string>
|
||||||
<string name="scene_action_infrared">Infrared [%1$s]: Slot %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_delay">Delay %1$d seconds</string>
|
||||||
<string name="scene_action_notification">🔔 Notify: %1$s</string>
|
<string name="scene_action_notification">Notify: %1$s</string>
|
||||||
|
|
||||||
<!-- Scene: execution -->
|
<!-- Scene: execution -->
|
||||||
<string name="scene_execution_title">Execute Command</string>
|
<string name="scene_execution_title">Execute Command</string>
|
||||||
@@ -164,13 +165,13 @@
|
|||||||
<!-- Validation & dialog chips / placeholders -->
|
<!-- Validation & dialog chips / placeholders -->
|
||||||
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
|
<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_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_time">Schedule</string>
|
||||||
<string name="scene_chip_temp">🌡️ Temperature</string>
|
<string name="scene_chip_temp">Temperature</string>
|
||||||
<string name="scene_chip_humidity">💧 Humidity</string>
|
<string name="scene_chip_humidity">Humidity</string>
|
||||||
<string name="scene_chip_socket">🔌 Socket</string>
|
<string name="scene_chip_socket">Socket</string>
|
||||||
<string name="scene_chip_remote">📺 Remote</string>
|
<string name="scene_chip_remote">Remote</string>
|
||||||
<string name="scene_chip_delay">⏱️ Delay</string>
|
<string name="scene_chip_delay">Delay</string>
|
||||||
<string name="scene_chip_notify">🔔 Notify</string>
|
<string name="scene_chip_notify">Notify</string>
|
||||||
<string name="scene_chip_above">> Above</string>
|
<string name="scene_chip_above">> Above</string>
|
||||||
<string name="scene_chip_below">< Below</string>
|
<string name="scene_chip_below">< Below</string>
|
||||||
<string name="scene_chip_power_on">Turn ON</string>
|
<string name="scene_chip_power_on">Turn ON</string>
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
<string name="scene_empty_triggers_hint">No triggers configured. Tap + below to add.</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_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_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_triggers_and_actions">%1$d triggers · %2$d actions</string>
|
||||||
<string name="scene_summary_actions_only">%1$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_ready_hint">Tap the button below to run immediately</string>
|
||||||
<string name="scene_exec_rerun">Re-run</string>
|
<string name="scene_exec_rerun">Re-run</string>
|
||||||
@@ -222,8 +223,6 @@
|
|||||||
<string name="socket_status_offline">Offline</string>
|
<string name="socket_status_offline">Offline</string>
|
||||||
<string name="socket_status_unknown">Unknown</string>
|
<string name="socket_status_unknown">Unknown</string>
|
||||||
<string name="socket_action_toggle">Toggle Power</string>
|
<string name="socket_action_toggle">Toggle Power</string>
|
||||||
<string name="socket_action_turn_on">Turn ON</string>
|
|
||||||
<string name="socket_action_turn_off">Turn OFF</string>
|
|
||||||
<string name="socket_operating_label">Updating socket state…</string>
|
<string name="socket_operating_label">Updating socket state…</string>
|
||||||
<string name="socket_confirmed_at">Confirmed at %1$s</string>
|
<string name="socket_confirmed_at">Confirmed at %1$s</string>
|
||||||
|
|
||||||
@@ -259,4 +258,147 @@
|
|||||||
<string name="error_operation_failed">Operation failed. Please try again.</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_no_data">No sensor data available.</string>
|
||||||
<string name="error_unknown">An unexpected error occurred.</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>
|
</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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import com.flagship.abox.manager.domain.SocketPowerState
|
|||||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
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 com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
@@ -39,6 +40,7 @@ class SceneEditorViewModelTest {
|
|||||||
private val profileStore = FakeProviderProfileStore()
|
private val profileStore = FakeProviderProfileStore()
|
||||||
private val sceneRepository = FakeSceneRepository()
|
private val sceneRepository = FakeSceneRepository()
|
||||||
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
||||||
|
private val deviceCatalog = FakeDeviceCatalog()
|
||||||
private val profileId = ProviderProfileId("local-experience")
|
private val profileId = ProviderProfileId("local-experience")
|
||||||
|
|
||||||
private val existingScene = Scene(
|
private val existingScene = Scene(
|
||||||
@@ -66,7 +68,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun initializeWithNullPreparesNewScene() = runTest {
|
fun initializeWithNullPreparesNewScene() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.MANUAL)
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
|
|
||||||
val state = viewModel.state.value
|
val state = viewModel.state.value
|
||||||
@@ -79,7 +81,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun initializeWithExistingSceneLoadsState() = runTest {
|
fun initializeWithExistingSceneLoadsState() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(existingScene.id)
|
viewModel.initialize(existingScene.id)
|
||||||
|
|
||||||
// Immediately binds sceneId and enters loading state
|
// Immediately binds sceneId and enters loading state
|
||||||
@@ -101,7 +103,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun addingAndReorderingActionsUpdatesState() = runTest {
|
fun addingAndReorderingActionsUpdatesState() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.MANUAL)
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
viewModel.updateName("My New Scene")
|
viewModel.updateName("My New Scene")
|
||||||
|
|
||||||
@@ -129,7 +131,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
|
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||||
viewModel.updateName("Auto Rule")
|
viewModel.updateName("Auto Rule")
|
||||||
|
|
||||||
@@ -153,7 +155,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun saveNewScenePersistsToRepository() = runTest {
|
fun saveNewScenePersistsToRepository() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.MANUAL)
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
viewModel.updateName("Brand New Scene")
|
viewModel.updateName("Brand New Scene")
|
||||||
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
||||||
@@ -170,7 +172,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||||
viewModel.updateName("Auto Without Trigger")
|
viewModel.updateName("Auto Without Trigger")
|
||||||
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||||
@@ -188,7 +190,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(existingScene.id)
|
viewModel.initialize(existingScene.id)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
@Test
|
||||||
fun `all main tabs have distinct non-empty routes and valid label resources`() {
|
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)
|
assertEquals(3, tabs.size)
|
||||||
|
|
||||||
val routes = tabs.map { it.route }
|
val routes = tabs.map { it.route }
|
||||||
|
|||||||
@@ -157,6 +157,32 @@ class SocketViewModelTest {
|
|||||||
assertNotNull(state.errorMessage)
|
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
|
@Test
|
||||||
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
||||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ class TemperatureHumidityViewModelTest {
|
|||||||
assertTrue(state.hasNoData)
|
assertTrue(state.hasNoData)
|
||||||
assertNull(state.temperature)
|
assertNull(state.temperature)
|
||||||
assertNull(state.humidity)
|
assertNull(state.humidity)
|
||||||
assertEquals("--", state.formattedTemperature)
|
assertNull(state.formattedTemperature)
|
||||||
assertEquals("--", state.formattedHumidity)
|
assertNull(state.formattedHumidity)
|
||||||
assertFalse(state.hasValidData)
|
assertFalse(state.hasValidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,4 +190,16 @@ class TemperatureHumidityViewModelTest {
|
|||||||
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
|
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
|
||||||
assertNull(viewModel.state.value.errorMessage)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.compose.screenshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.flagship.abox.manager.designsystem"
|
namespace = "com.flagship.abox.manager.designsystem"
|
||||||
compileSdk = 34
|
compileSdk = 37
|
||||||
|
|
||||||
|
experimentalProperties["android.experimental.enableScreenshotTest"] = true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
@@ -15,25 +21,32 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(project(":android:core:preferences"))
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.material.icons.extended)
|
implementation(libs.compose.material.icons.extended)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.miuix.ui.android)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
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
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
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.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
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.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.outlined.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.LocationOn
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
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.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
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.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
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.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
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
|
@Composable
|
||||||
fun ABoxSurface(
|
fun ABoxSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) = ABoxAppSurface(modifier = modifier, content = content)
|
||||||
Surface(modifier = modifier, content = content)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxButton(
|
fun ABoxButton(
|
||||||
@@ -56,7 +102,7 @@ fun ABoxButton(
|
|||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier.defaultMinSize(minHeight = 48.dp),
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
colors = ButtonDefaults.buttonColors(),
|
colors = ButtonDefaults.buttonColors(),
|
||||||
content = content,
|
content = content,
|
||||||
@@ -64,14 +110,437 @@ fun ABoxButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
|
fun ABoxIconButton(
|
||||||
CircularProgressIndicator(modifier = modifier)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Circular user avatar displaying the initial character of username or a default person icon.
|
fun ABoxSwitch(
|
||||||
* Styled with an accented profile ring.
|
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
|
@Composable
|
||||||
fun ABoxUserAvatar(
|
fun ABoxUserAvatar(
|
||||||
name: String?,
|
name: String?,
|
||||||
@@ -83,6 +552,12 @@ fun ABoxUserAvatar(
|
|||||||
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
|
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
||||||
|
.clickable(onClickLabel = contentDescription, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
@@ -90,8 +565,7 @@ fun ABoxUserAvatar(
|
|||||||
width = 2.dp,
|
width = 2.dp,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
)
|
),
|
||||||
.clickable(onClickLabel = contentDescription, onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (initial != null) {
|
if (initial != null) {
|
||||||
@@ -103,8 +577,8 @@ fun ABoxUserAvatar(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
MaterialIcon(
|
||||||
imageVector = Icons.Default.Person,
|
imageVector = ABoxIconMapperForAccount,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.size(size * 0.6f),
|
modifier = Modifier.size(size * 0.6f),
|
||||||
@@ -112,44 +586,39 @@ fun ABoxUserAvatar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle
|
||||||
* Material 3 pill top bar containing leading icon, title/provider label,
|
|
||||||
* an optional '+' action button, and user avatar.
|
/** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxTopPillBar(
|
fun ABoxTopPillBar(
|
||||||
title: String,
|
title: String,
|
||||||
onAvatarClick: () -> Unit,
|
onAvatarClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
userName: String? = null,
|
userName: String? = null,
|
||||||
leadingIcon: ImageVector = Icons.Default.LocationOn,
|
leadingIcon: ImageVector = Icons.Outlined.Bolt,
|
||||||
onAddClick: (() -> Unit)? = null,
|
onAddClick: (() -> Unit)? = null,
|
||||||
addActionContentDescription: String? = "Add",
|
addActionContentDescription: String? = "添加",
|
||||||
avatarContentDescription: String? = "Account",
|
avatarContentDescription: String? = "账户",
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth().height(64.dp),
|
||||||
.fillMaxWidth()
|
shape = RoundedCornerShape(32.dp),
|
||||||
.height(56.dp),
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
tonalElevation = 2.dp,
|
tonalElevation = 2.dp,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
MaterialIcon(
|
||||||
imageVector = leadingIcon,
|
imageVector = leadingIcon,
|
||||||
contentDescription = null,
|
contentDescription = title,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.padding(start = 8.dp).size(22.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
@@ -158,20 +627,13 @@ fun ABoxTopPillBar(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (onAddClick != null) {
|
if (onAddClick != null) {
|
||||||
IconButton(
|
ABoxIconButton(
|
||||||
|
key = ABoxIconKey.ADD,
|
||||||
|
contentDescription = addActionContentDescription ?: "添加",
|
||||||
onClick = onAddClick,
|
onClick = onAddClick,
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
contentDescription = addActionContentDescription,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ABoxUserAvatar(
|
ABoxUserAvatar(
|
||||||
name = userName,
|
name = userName,
|
||||||
onClick = onAvatarClick,
|
onClick = onAvatarClick,
|
||||||
@@ -182,9 +644,6 @@ fun ABoxTopPillBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ABox standard Material 3 Navigation Bar with pill indicator styling.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxNavigationBar(
|
fun ABoxNavigationBar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -198,9 +657,6 @@ fun ABoxNavigationBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ABox standard Navigation Bar Item.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RowScope.ABoxNavigationBarItem(
|
fun RowScope.ABoxNavigationBarItem(
|
||||||
selected: Boolean,
|
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.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Shapes
|
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(
|
fun ABoxShapes(tokens: ABoxShapeTokens = ABoxThemeDefaults.material3Shapes) = Shapes(
|
||||||
small = RoundedCornerShape(8.dp),
|
small = RoundedCornerShape(tokens.smallCornerRadius),
|
||||||
medium = RoundedCornerShape(16.dp),
|
medium = RoundedCornerShape(tokens.mediumCornerRadius),
|
||||||
large = RoundedCornerShape(24.dp),
|
large = RoundedCornerShape(tokens.largeCornerRadius),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,41 +1,205 @@
|
|||||||
package com.flagship.abox.manager.designsystem
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import android.os.Build
|
||||||
import androidx.compose.material3.MaterialTheme
|
import android.provider.Settings
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
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.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(
|
val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
|
||||||
primary = Color(0xFF006780),
|
val LocalABoxSpacing = staticCompositionLocalOf { ABoxSpacing() }
|
||||||
onPrimary = Color.White,
|
val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography }
|
||||||
primaryContainer = Color(0xFFB8EAFF),
|
val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes }
|
||||||
onPrimaryContainer = Color(0xFF001F29),
|
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
|
||||||
secondary = Color(0xFF4D616A),
|
val LocalABoxReducedMotion = staticCompositionLocalOf { false }
|
||||||
surface = Color(0xFFF8FAFC),
|
val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
|
||||||
background = Color(0xFFF8FAFC),
|
val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD }
|
||||||
)
|
val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false }
|
||||||
|
val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false }
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxTheme(
|
fun ABoxTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
preferences: UiPreferences,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
MaterialTheme(
|
val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme()
|
||||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
val context = LocalContext.current
|
||||||
typography = ABoxTypography,
|
val isDarkTheme = when (preferences.themeMode) {
|
||||||
shapes = ABoxShapes,
|
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,
|
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
|
import androidx.compose.material3.Typography
|
||||||
|
|
||||||
|
/** Default typography supplied through [LocalABoxTypography]. */
|
||||||
val ABoxTypography = Typography()
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.renderer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.darkColorScheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.lightColorScheme
|
||||||
|
|
||||||
|
internal object MiuixRendererAdapter : ABoxRendererAdapter {
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
resolution: ABoxThemeResolution,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme()
|
||||||
|
MiuixTheme(colors = colors) {
|
||||||
|
androidx.compose.material3.MaterialTheme(
|
||||||
|
colorScheme = com.flagship.abox.manager.designsystem.materialColorScheme(resolution),
|
||||||
|
typography = com.flagship.abox.manager.designsystem.ABoxTypography,
|
||||||
|
shapes = com.flagship.abox.manager.designsystem.ABoxShapes(resolution.shapeTokens),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.tokens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.AccentPreference
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStandard
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStyle
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxColorTokens(
|
||||||
|
val primary: Color,
|
||||||
|
val surface: Color,
|
||||||
|
val surfaceContainer: Color,
|
||||||
|
val outline: Color,
|
||||||
|
val success: Color,
|
||||||
|
val warning: Color,
|
||||||
|
val error: Color,
|
||||||
|
val offline: Color,
|
||||||
|
val pending: Color,
|
||||||
|
val deviceSocket: Color,
|
||||||
|
val deviceSensor: Color,
|
||||||
|
val deviceInfrared: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxSpacing(
|
||||||
|
val dp4: Dp = 4.dp,
|
||||||
|
val dp8: Dp = 8.dp,
|
||||||
|
val dp12: Dp = 12.dp,
|
||||||
|
val dp16: Dp = 16.dp,
|
||||||
|
val dp20: Dp = 20.dp,
|
||||||
|
val dp24: Dp = 24.dp,
|
||||||
|
val dp32: Dp = 32.dp,
|
||||||
|
val minimumTouchTarget: Dp = 48.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxComponentState(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val pending: Boolean = false,
|
||||||
|
val offline: Boolean = false,
|
||||||
|
val dangerous: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxShapeTokens(
|
||||||
|
val smallCornerRadius: Dp,
|
||||||
|
val mediumCornerRadius: Dp,
|
||||||
|
val largeCornerRadius: Dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxThemeResolution(
|
||||||
|
val isDarkTheme: Boolean,
|
||||||
|
val colorTokens: ABoxColorTokens,
|
||||||
|
val shapeTokens: ABoxShapeTokens,
|
||||||
|
val blurEnabled: Boolean,
|
||||||
|
val renderer: DesignRenderer,
|
||||||
|
)
|
||||||
|
|
||||||
|
object ABoxThemeDefaults {
|
||||||
|
val lightColors = ABoxColorTokens(
|
||||||
|
primary = Color(0xFF006780),
|
||||||
|
surface = Color(0xFFF8FAFC),
|
||||||
|
surfaceContainer = Color(0xFFE2E8F0),
|
||||||
|
outline = Color(0xFF70797D),
|
||||||
|
success = Color(0xFF1B7F4B),
|
||||||
|
warning = Color(0xFF8A5800),
|
||||||
|
error = Color(0xFFBA1A1A),
|
||||||
|
offline = Color(0xFF5F6B72),
|
||||||
|
pending = Color(0xFF8A5800),
|
||||||
|
deviceSocket = Color(0xFF006E90),
|
||||||
|
deviceSensor = Color(0xFF476B23),
|
||||||
|
deviceInfrared = Color(0xFF77536F),
|
||||||
|
)
|
||||||
|
val darkColors = ABoxColorTokens(
|
||||||
|
primary = Color(0xFF5DD5F8),
|
||||||
|
surface = Color(0xFF101416),
|
||||||
|
surfaceContainer = Color(0xFF1F292D),
|
||||||
|
outline = Color(0xFF8A969B),
|
||||||
|
success = Color(0xFF7EDFA7),
|
||||||
|
warning = Color(0xFFFFB95B),
|
||||||
|
error = Color(0xFFFFB4AB),
|
||||||
|
offline = Color(0xFFBFC9CD),
|
||||||
|
pending = Color(0xFFFFB95B),
|
||||||
|
deviceSocket = Color(0xFF5DD5F8),
|
||||||
|
deviceSensor = Color(0xFFAFD67C),
|
||||||
|
deviceInfrared = Color(0xFFE6B5D6),
|
||||||
|
)
|
||||||
|
val material3Shapes = ABoxShapeTokens(
|
||||||
|
smallCornerRadius = 8.dp,
|
||||||
|
mediumCornerRadius = 16.dp,
|
||||||
|
largeCornerRadius = 24.dp,
|
||||||
|
)
|
||||||
|
val miuixShapes = ABoxShapeTokens(
|
||||||
|
smallCornerRadius = 12.dp,
|
||||||
|
mediumCornerRadius = 20.dp,
|
||||||
|
largeCornerRadius = 28.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
object ABoxThemeResolver {
|
||||||
|
fun resolve(
|
||||||
|
preferences: UiPreferences,
|
||||||
|
systemInDarkTheme: Boolean,
|
||||||
|
sdkInt: Int,
|
||||||
|
dynamicColorTokens: ABoxColorTokens?,
|
||||||
|
): ABoxThemeResolution {
|
||||||
|
val isDarkTheme = when (preferences.themeMode) {
|
||||||
|
AppThemeMode.SYSTEM -> systemInDarkTheme
|
||||||
|
AppThemeMode.LIGHT -> false
|
||||||
|
AppThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
val brandTokens = if (isDarkTheme) {
|
||||||
|
ABoxThemeDefaults.darkColors
|
||||||
|
} else {
|
||||||
|
ABoxThemeDefaults.lightColors
|
||||||
|
}
|
||||||
|
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
|
||||||
|
dynamicColorTokens
|
||||||
|
} else {
|
||||||
|
ABoxAccentPalette.resolve(
|
||||||
|
base = brandTokens,
|
||||||
|
accent = preferences.accentPreference,
|
||||||
|
style = preferences.colorStyle,
|
||||||
|
standard = preferences.colorStandard,
|
||||||
|
dark = isDarkTheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val shapeTokens = when (preferences.renderer) {
|
||||||
|
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
|
||||||
|
DesignRenderer.MIUIX -> ABoxThemeDefaults.miuixShapes
|
||||||
|
}
|
||||||
|
|
||||||
|
return ABoxThemeResolution(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
colorTokens = colorTokens,
|
||||||
|
shapeTokens = shapeTokens,
|
||||||
|
blurEnabled = preferences.blurEnabled && preferences.renderer == DesignRenderer.MIUIX,
|
||||||
|
renderer = preferences.renderer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object ABoxAccentPalette {
|
||||||
|
fun preview(
|
||||||
|
accent: AccentPreference,
|
||||||
|
style: ColorStyle,
|
||||||
|
standard: ColorStandard,
|
||||||
|
dark: Boolean,
|
||||||
|
): Color = resolve(
|
||||||
|
base = if (dark) ABoxThemeDefaults.darkColors else ABoxThemeDefaults.lightColors,
|
||||||
|
accent = accent,
|
||||||
|
style = style,
|
||||||
|
standard = standard,
|
||||||
|
dark = dark,
|
||||||
|
).primary
|
||||||
|
|
||||||
|
fun resolve(
|
||||||
|
base: ABoxColorTokens,
|
||||||
|
accent: AccentPreference,
|
||||||
|
style: ColorStyle,
|
||||||
|
standard: ColorStandard,
|
||||||
|
dark: Boolean,
|
||||||
|
): ABoxColorTokens {
|
||||||
|
val source = source(accent, dark)
|
||||||
|
val primary = when (style) {
|
||||||
|
ColorStyle.TONAL_SPOT -> lerp(source, base.surface, if (dark) 0.08f else 0.12f)
|
||||||
|
ColorStyle.EXPRESSIVE -> expressive(accent, dark)
|
||||||
|
ColorStyle.FIDELITY -> source
|
||||||
|
}
|
||||||
|
val deviceSensor = when (style) {
|
||||||
|
ColorStyle.EXPRESSIVE -> expressiveSensor(accent, dark)
|
||||||
|
ColorStyle.FIDELITY -> lerp(source, base.success, 0.38f)
|
||||||
|
ColorStyle.TONAL_SPOT -> base.deviceSensor
|
||||||
|
}
|
||||||
|
val deviceInfrared = when (style) {
|
||||||
|
ColorStyle.EXPRESSIVE -> expressiveInfrared(accent, dark)
|
||||||
|
ColorStyle.FIDELITY -> lerp(source, base.deviceInfrared, 0.46f)
|
||||||
|
ColorStyle.TONAL_SPOT -> base.deviceInfrared
|
||||||
|
}
|
||||||
|
val styled = base.copy(
|
||||||
|
primary = primary,
|
||||||
|
deviceSocket = primary,
|
||||||
|
deviceSensor = deviceSensor,
|
||||||
|
deviceInfrared = deviceInfrared,
|
||||||
|
)
|
||||||
|
return when (standard) {
|
||||||
|
ColorStandard.SPEC_2021 -> styled
|
||||||
|
ColorStandard.SPEC_2025 -> styled.copy(
|
||||||
|
surface = if (dark) Color(0xFF0C1114) else Color(0xFFFAF9FC),
|
||||||
|
surfaceContainer = if (dark) Color(0xFF242C30) else Color(0xFFE9E8EE),
|
||||||
|
outline = if (dark) Color(0xFFA7B2B7) else Color(0xFF596267),
|
||||||
|
primary = lerp(primary, if (dark) Color.White else Color.Black, 0.08f),
|
||||||
|
deviceSocket = lerp(primary, if (dark) Color.White else Color.Black, 0.08f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun source(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF4F6FA9)
|
||||||
|
AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF2F7D57)
|
||||||
|
AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFFA66700)
|
||||||
|
AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA94C68)
|
||||||
|
AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF7659A8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expressive(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE -> if (dark) Color(0xFF72D6FF) else Color(0xFF006B91)
|
||||||
|
AccentPreference.GREEN -> if (dark) Color(0xFF69E6B1) else Color(0xFF007A54)
|
||||||
|
AccentPreference.AMBER -> if (dark) Color(0xFFFFC24B) else Color(0xFF9A5C00)
|
||||||
|
AccentPreference.ROSE -> if (dark) Color(0xFFFFA8C4) else Color(0xFFB12F62)
|
||||||
|
AccentPreference.VIOLET -> if (dark) Color(0xFFC9B5FF) else Color(0xFF7048B8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expressiveSensor(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE, AccentPreference.VIOLET -> if (dark) Color(0xFF78DFA2) else Color(0xFF26764A)
|
||||||
|
AccentPreference.GREEN, AccentPreference.AMBER -> if (dark) Color(0xFF79D6F3) else Color(0xFF006985)
|
||||||
|
AccentPreference.ROSE -> if (dark) Color(0xFFFFC45F) else Color(0xFF8B5B00)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expressiveInfrared(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE, AccentPreference.GREEN -> if (dark) Color(0xFFD1B8FF) else Color(0xFF7453A5)
|
||||||
|
AccentPreference.AMBER, AccentPreference.ROSE -> if (dark) Color(0xFF9FCBFF) else Color(0xFF38639A)
|
||||||
|
AccentPreference.VIOLET -> if (dark) Color(0xFFFFB0C7) else Color(0xFFA43E62)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.android.tools.screenshot.PreviewTest
|
||||||
|
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
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "M3 light", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMaterialLight() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "M3 dark", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMaterialDark() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.DARK)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Miuix light", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMiuixLight() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Miuix dark", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMiuixDark() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.DARK)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Large font", widthDp = 360, heightDp = 760, fontScale = 1.3f)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotLargeFont() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Blur fallback", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotBlurFallback() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT, blurEnabled = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ABoxScreenshotFixture(
|
||||||
|
renderer: DesignRenderer,
|
||||||
|
themeMode: AppThemeMode,
|
||||||
|
blurEnabled: Boolean = false,
|
||||||
|
) {
|
||||||
|
ABoxTheme(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
renderer = renderer,
|
||||||
|
themeMode = themeMode,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
blurEnabled = blurEnabled,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ABoxAppSurface(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ABoxTopPillBar(
|
||||||
|
title = "我的设备",
|
||||||
|
onAvatarClick = {},
|
||||||
|
userName = "小明",
|
||||||
|
onAddClick = {},
|
||||||
|
addActionContentDescription = "添加设备",
|
||||||
|
avatarContentDescription = "账户",
|
||||||
|
)
|
||||||
|
ABoxCard(
|
||||||
|
title = "客厅插座",
|
||||||
|
subtitle = "在线 · 正常",
|
||||||
|
onClick = {},
|
||||||
|
quickAction = ABoxCardQuickAction(
|
||||||
|
key = ABoxIconKey.REFRESH,
|
||||||
|
contentDescription = "刷新设备",
|
||||||
|
onClick = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.PENDING,
|
||||||
|
contentDescription = "正在处理",
|
||||||
|
)
|
||||||
|
ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.OFFLINE,
|
||||||
|
contentDescription = "设备离线",
|
||||||
|
)
|
||||||
|
ABoxLoadingState(contentDescription = "正在加载")
|
||||||
|
ABoxEmptyState(
|
||||||
|
title = "暂无设备",
|
||||||
|
description = "添加设备后会显示在这里。",
|
||||||
|
)
|
||||||
|
ABoxErrorState(
|
||||||
|
title = "加载失败",
|
||||||
|
description = "请稍后重试。",
|
||||||
|
onRetry = {},
|
||||||
|
)
|
||||||
|
ABoxFilterChip(
|
||||||
|
selected = true,
|
||||||
|
label = "全部",
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
|
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.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun DesignSystemSmokePreview() {
|
||||||
|
ABoxTheme(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
renderer = DesignRenderer.MATERIAL3,
|
||||||
|
themeMode = AppThemeMode.LIGHT,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("截图基线")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |