From 94e3e394afc37bc463f1153ecc4bc993b7f984e6 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 06:24:16 +0800 Subject: [PATCH 01/40] build: modernize Android Compose toolchain --- android/app/build.gradle.kts | 19 +++++------ android/core/designsystem/build.gradle.kts | 15 ++++---- android/core/session/build.gradle.kts | 11 +++--- .../manager/session/CredentialStoreTest.kt | 3 ++ .../session/ProviderProfileStoreTest.kt | 3 ++ android/feature/infrared/build.gradle.kts | 15 ++++---- build.gradle.kts | 2 +- gradle.properties | 3 ++ gradle/libs.versions.toml | 34 +++++++++++++------ gradle/wrapper/gradle-wrapper.properties | 4 +-- 10 files changed, 65 insertions(+), 44 deletions(-) rename android/core/session/src/{test => androidTest}/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt (97%) rename android/core/session/src/{test => androidTest}/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt (92%) diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 464ffd4..f34df9c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,18 +1,21 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.hilt.android) } android { namespace = "com.flagship.abox.manager" - compileSdk = 34 + compileSdk = 37 defaultConfig { applicationId = "com.flagship.abox.manager" minSdk = 26 - targetSdk = 34 + targetSdk = 36 versionCode = 1 versionName = "0.1.0" @@ -33,19 +36,11 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } - kotlinOptions { - jvmTarget = "17" - } - packaging { resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" @@ -57,6 +52,10 @@ android { } } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + kapt { correctErrorTypes = true } diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index 18a7dfe..5148944 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -1,11 +1,14 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) } android { namespace = "com.flagship.abox.manager.designsystem" - compileSdk = 34 + compileSdk = 37 defaultConfig { minSdk = 26 @@ -15,18 +18,14 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} - kotlinOptions { - jvmTarget = "17" - } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } dependencies { diff --git a/android/core/session/build.gradle.kts b/android/core/session/build.gradle.kts index 2fb4697..d0e15e2 100644 --- a/android/core/session/build.gradle.kts +++ b/android/core/session/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) @@ -5,7 +7,7 @@ plugins { android { namespace = "com.flagship.abox.manager.session" - compileSdk = 34 + compileSdk = 37 defaultConfig { minSdk = 26 @@ -16,10 +18,10 @@ android { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} - kotlinOptions { - jvmTarget = "17" - } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } dependencies { @@ -31,4 +33,5 @@ dependencies { testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.kotlinx.coroutines.test) } diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt similarity index 97% rename from android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt rename to android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt index b912d27..829689d 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/CredentialStoreTest.kt @@ -1,6 +1,7 @@ package com.flagship.abox.manager.session import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.LoginCredentials import com.flagship.abox.manager.domain.Password @@ -13,7 +14,9 @@ import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class CredentialStoreTest { @Test fun storesClearsAndIsolatesCredentials() = runTest { diff --git a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt similarity index 92% rename from android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt rename to android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt index 8616cd5..f32d788 100644 --- a/android/core/session/src/test/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt +++ b/android/core/session/src/androidTest/kotlin/com/flagship/abox/manager/session/ProviderProfileStoreTest.kt @@ -1,6 +1,7 @@ package com.flagship.abox.manager.session import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.LoginPreferences import com.flagship.abox.manager.domain.ProviderProfile @@ -13,7 +14,9 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Test +import org.junit.runner.RunWith +@RunWith(AndroidJUnit4::class) class ProviderProfileStoreTest { @Test fun persistsActiveProfileAndLoginPreferences() = runTest { diff --git a/android/feature/infrared/build.gradle.kts b/android/feature/infrared/build.gradle.kts index d088f6b..2bf2ebb 100644 --- a/android/feature/infrared/build.gradle.kts +++ b/android/feature/infrared/build.gradle.kts @@ -1,13 +1,16 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.hilt.android) } android { namespace = "com.flagship.abox.manager.infrared" - compileSdk = 34 + compileSdk = 37 defaultConfig { minSdk = 26 @@ -18,18 +21,14 @@ android { compose = true } - composeOptions { - kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get() - } - compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } +} - kotlinOptions { - jvmTarget = "17" - } +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } } kapt { diff --git a/build.gradle.kts b/build.gradle.kts index 46c8d89..1db8c35 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,8 +2,8 @@ plugins { alias(libs.plugins.android.application) apply false alias(libs.plugins.android.library) apply false alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.hilt.android) apply false } - diff --git a/gradle.properties b/gradle.properties index dd2a6d4..1cd37dd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,3 +4,6 @@ android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official android.overridePathCheck=true +android.builtInKotlin=false +android.newDsl=false +kotlin.jvm.target.validation.mode=warning diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7aa0738..45965b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,15 +1,20 @@ [versions] -agp = "8.7.3" -kotlin = "1.9.24" -composeBom = "2024.06.00" -composeCompiler = "1.5.14" +agp = "9.3.2" +kotlin = "2.4.0" +composeBom = "2026.08.00" coroutines = "1.8.1" -hilt = "2.51.1" -navigationCompose = "2.7.7" -activityCompose = "1.9.0" -lifecycle = "2.8.2" -room = "2.6.1" -datastore = "1.1.1" +hilt = "2.60.1" +hiltNavigationCompose = "1.4.0" +navigationCompose = "2.9.8" +activityCompose = "1.13.0" +lifecycle = "2.11.0" +room = "2.8.4" +datastore = "1.2.1" +work = "2.11.2" +adaptive = "1.3.0" +adaptiveNavigationSuite = "1.4.0" +window = "1.5.1" +composeScreenshot = "0.0.1-alpha16" junit = "4.13.2" androidxJunit = "1.2.1" espresso = "3.6.1" @@ -20,7 +25,12 @@ androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-ru androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" } -androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version = "1.2.0" } +androidx-hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hiltNavigationCompose" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltNavigationCompose" } +androidx-work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "work" } +androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive", version.ref = "adaptive" } +androidx-compose-material3-adaptive-navigation-suite = { module = "androidx.compose.material3:material3-adaptive-navigation-suite", version.ref = "adaptiveNavigationSuite" } +androidx-window = { module = "androidx.window:window", version.ref = "window" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" } compose-ui = { module = "androidx.compose.ui:ui" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } @@ -47,6 +57,8 @@ espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } hilt-android = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } +compose-screenshot = { id = "com.android.compose.screenshot", version.ref = "composeScreenshot" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 496c514..a351597 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 -validateDistributionUrl=false +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -- 2.54.0 From b39b3f95c7da2e1fc29be791a4ef36294b38dc53 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 07:04:31 +0800 Subject: [PATCH 02/40] test: add Compose screenshot baseline --- android/app/build.gradle.kts | 4 ++++ .../abox/manager/ui/AppScreenshotSmokeTest.kt | 14 ++++++++++++++ .../AppSmokePreview_0.png | Bin 0 -> 4626 bytes android/core/designsystem/build.gradle.kts | 4 ++++ .../manager/designsystem/ScreenshotSmokeTest.kt | 13 +++++++++++++ .../DesignSystemSmokePreview_0.png | Bin 0 -> 3836 bytes build.gradle.kts | 1 + gradle.properties | 3 ++- gradle/libs.versions.toml | 1 + 9 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt create mode 100644 android/app/src/screenshotTestDebug/reference/com/flagship/abox/manager/ui/AppScreenshotSmokeTestKt/AppSmokePreview_0.png create mode 100644 android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt create mode 100644 android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ScreenshotSmokeTestKt/DesignSystemSmokePreview_0.png diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f34df9c..aa9f30a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -6,12 +6,15 @@ plugins { alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.hilt.android) + alias(libs.plugins.compose.screenshot) } android { namespace = "com.flagship.abox.manager" compileSdk = 37 + experimentalProperties["android.experimental.enableScreenshotTest"] = true + defaultConfig { applicationId = "com.flagship.abox.manager" minSdk = 26 @@ -94,4 +97,5 @@ dependencies { androidTestImplementation(libs.compose.ui.test.junit4) debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) + screenshotTestImplementation(libs.compose.screenshot.test) } diff --git a/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt new file mode 100644 index 0000000..e5ab6c5 --- /dev/null +++ b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt @@ -0,0 +1,14 @@ +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 + +@PreviewTest +@Preview +@Composable +fun AppSmokePreview() { + ABoxTheme(darkTheme = false) { Text("ABox Manager") } +} diff --git a/android/app/src/screenshotTestDebug/reference/com/flagship/abox/manager/ui/AppScreenshotSmokeTestKt/AppSmokePreview_0.png b/android/app/src/screenshotTestDebug/reference/com/flagship/abox/manager/ui/AppScreenshotSmokeTestKt/AppSmokePreview_0.png new file mode 100644 index 0000000000000000000000000000000000000000..067f3edf6abccbe86f8ad5a89bf43a889b923388 GIT binary patch literal 4626 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
    pP+7OneY8K`m*>ge3`=q7hN_rGfIo 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 literal 0 HcmV?d00001 diff --git a/build.gradle.kts b/build.gradle.kts index 1db8c35..3f079d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,4 +6,5 @@ plugins { alias(libs.plugins.kotlin.jvm) apply false alias(libs.plugins.kotlin.kapt) apply false alias(libs.plugins.hilt.android) apply false + alias(libs.plugins.compose.screenshot) apply false } diff --git a/gradle.properties b/gradle.properties index 1cd37dd..db68989 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,5 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 +android.experimental.enableScreenshotTest=true org.gradle.configuration-cache=false android.useAndroidX=true android.nonTransitiveRClass=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 45965b6..11f7d0e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -39,6 +39,7 @@ compose-material3 = { module = "androidx.compose.material3:material3" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } +compose-screenshot-test = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } -- 2.54.0 From 79b47fc354d49b15c5169759e8dd03704b717cc2 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 07:32:26 +0800 Subject: [PATCH 03/40] feat: persist global UI preferences --- android/app/build.gradle.kts | 1 + android/core/preferences/build.gradle.kts | 32 ++++ .../DataStoreUiPreferencesRepository.kt | 82 ++++++++++ .../abox/manager/preferences/UiPreferences.kt | 35 +++++ .../preferences/UiPreferencesRepository.kt | 16 ++ .../DataStoreUiPreferencesRepositoryTest.kt | 145 ++++++++++++++++++ settings.gradle.kts | 1 + 7 files changed, 312 insertions(+) create mode 100644 android/core/preferences/build.gradle.kts create mode 100644 android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt create mode 100644 android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt create mode 100644 android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt create mode 100644 android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index aa9f30a..9fce481 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -66,6 +66,7 @@ kapt { dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) + implementation(project(":android:core:preferences")) implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) implementation(project(":absdk-bridge")) diff --git a/android/core/preferences/build.gradle.kts b/android/core/preferences/build.gradle.kts new file mode 100644 index 0000000..4888cea --- /dev/null +++ b/android/core/preferences/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "com.flagship.abox.manager.preferences" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(JvmTarget.JVM_17) } +} + +dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.datastore.preferences) + + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt new file mode 100644 index 0000000..cffed9c --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt @@ -0,0 +1,82 @@ +package com.flagship.abox.manager.preferences + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.MutablePreferences +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map + +private const val UI_PREFERENCES_DATA_STORE_NAME = "ui_preferences" + +private val Context.uiPreferencesDataStore: DataStore by preferencesDataStore( + name = UI_PREFERENCES_DATA_STORE_NAME, + corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() }, +) + +class DataStoreUiPreferencesRepository internal constructor( + private val dataStore: DataStore, +) : UiPreferencesRepository { + constructor(context: Context) : this(context.uiPreferencesDataStore) + override fun observe(): Flow = dataStore.data + .map(::preferencesFrom) + .catch { cause -> + if (cause is CancellationException) throw cause + emit(UiPreferences()) + } + + override suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult = try { + dataStore.edit { storedPreferences -> + storedPreferences.putAll(transform(preferencesFrom(storedPreferences))) + } + UiPreferencesWriteResult.Success + } catch (cause: CancellationException) { + throw cause + } catch (_: IOException) { + UiPreferencesWriteResult.Failure + } + + private fun preferencesFrom(preferences: Preferences): UiPreferences = UiPreferences( + renderer = preferences[RENDERER]?.enumOrDefault(DesignRenderer.MATERIAL3) + ?: DesignRenderer.MATERIAL3, + themeMode = preferences[THEME_MODE]?.enumOrDefault(AppThemeMode.SYSTEM) + ?: AppThemeMode.SYSTEM, + dynamicColorEnabled = preferences[DYNAMIC_COLOR] ?: true, + blurEnabled = preferences[BLUR] ?: false, + navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD) + ?: NavigationPresentation.STANDARD, + hapticsEnabled = preferences[HAPTICS] ?: true, + ) + + private fun MutablePreferences.putAll(preferences: UiPreferences) { + this[RENDERER] = preferences.renderer.name + this[THEME_MODE] = preferences.themeMode.name + this[DYNAMIC_COLOR] = preferences.dynamicColorEnabled + this[BLUR] = preferences.blurEnabled + this[NAVIGATION] = preferences.navigationPresentation.name + this[HAPTICS] = preferences.hapticsEnabled + } + + private inline fun > String.enumOrDefault(default: T): T = + enumValues().firstOrNull { it.name == this } ?: default + + private companion object { + val RENDERER = stringPreferencesKey("renderer") + val THEME_MODE = stringPreferencesKey("theme_mode") + val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color") + val BLUR = booleanPreferencesKey("blur") + val NAVIGATION = stringPreferencesKey("navigation") + val HAPTICS = booleanPreferencesKey("haptics") + } +} diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt new file mode 100644 index 0000000..429f7c3 --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt @@ -0,0 +1,35 @@ +package com.flagship.abox.manager.preferences + +enum class DesignRenderer { + MATERIAL3, + MIUIX, +} + +enum class AppThemeMode { + SYSTEM, + LIGHT, + DARK, +} + +enum class NavigationPresentation { + STANDARD, + FLOATING, +} + +enum class PreferenceField { + RENDERER, + THEME_MODE, + DYNAMIC_COLOR, + BLUR, + NAVIGATION, + HAPTICS, +} + +data class UiPreferences( + val renderer: DesignRenderer = DesignRenderer.MATERIAL3, + val themeMode: AppThemeMode = AppThemeMode.SYSTEM, + val dynamicColorEnabled: Boolean = true, + val blurEnabled: Boolean = false, + val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD, + val hapticsEnabled: Boolean = true, +) diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt new file mode 100644 index 0000000..12d4309 --- /dev/null +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferencesRepository.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.preferences + +import kotlinx.coroutines.flow.Flow + +sealed interface UiPreferencesWriteResult { + data object Success : UiPreferencesWriteResult + data object Failure : UiPreferencesWriteResult +} + +interface UiPreferencesRepository { + fun observe(): Flow + + suspend fun update( + transform: (UiPreferences) -> UiPreferences, + ): UiPreferencesWriteResult +} diff --git a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt new file mode 100644 index 0000000..25fd0bd --- /dev/null +++ b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt @@ -0,0 +1,145 @@ +package com.flagship.abox.manager.preferences + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import java.io.IOException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class DataStoreUiPreferencesRepositoryTest { + @Test + fun defaultsAreEmitted() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + + assertEquals(UiPreferences(), repository.observe().first()) + } + + @Test + fun eachFieldPersistsIndependently() = runTest { + val dataStore = InMemoryPreferencesDataStore() + val repository = DataStoreUiPreferencesRepository(dataStore) + + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(renderer = DesignRenderer.MIUIX) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(themeMode = AppThemeMode.DARK) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(dynamicColorEnabled = false) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(blurEnabled = true) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(navigationPresentation = NavigationPresentation.FLOATING) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(hapticsEnabled = false) + }) + + assertEquals( + UiPreferences( + renderer = DesignRenderer.MIUIX, + themeMode = AppThemeMode.DARK, + dynamicColorEnabled = false, + blurEnabled = true, + navigationPresentation = NavigationPresentation.FLOATING, + hapticsEnabled = false, + ), + repository.observe().first(), + ) + } + + @Test + fun unknownEnumFallsBackToDefault() = runTest { + val dataStore = InMemoryPreferencesDataStore() + dataStore.edit { preferences -> + preferences[stringPreferencesKey("renderer")] = "RETIRED_STYLE" + } + val repository = DataStoreUiPreferencesRepository(dataStore) + + assertEquals(DesignRenderer.MATERIAL3, repository.observe().first().renderer) + } + + @Test + fun corruptDataFallsBackWithoutThrowing() = runTest { + val repository = DataStoreUiPreferencesRepository(CorruptPreferencesDataStore()) + + assertEquals(UiPreferences(), repository.observe().first()) + } + + @Test + fun updateReturnsFailureWhenStorageWriteFails() = runTest { + val repository = DataStoreUiPreferencesRepository(FailingWritePreferencesDataStore()) + + assertEquals( + UiPreferencesWriteResult.Failure, + repository.update { it.copy(blurEnabled = true) }, + ) + } + + @Test + fun updateRethrowsTransformExceptions() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + val failure = IllegalStateException("invalid transform") + + val actual = runCatching { + repository.update { throw failure } + }.exceptionOrNull() + + assertEquals(failure, actual) + } + + @Test + fun updateRethrowsCancellationException() = runTest { + val cancellation = CancellationException("cancelled") + val repository = DataStoreUiPreferencesRepository( + FailingWritePreferencesDataStore(cancellation), + ) + + val actual = runCatching { + repository.update { it.copy(blurEnabled = true) } + }.exceptionOrNull() + + assertEquals(cancellation, actual) + } + + private class InMemoryPreferencesDataStore : DataStore { + private val state = MutableStateFlow(emptyPreferences()) + + override val data: Flow = state + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = transform(state.value).also(state::value::set) + } + + private class CorruptPreferencesDataStore : DataStore { + override val data: Flow = kotlinx.coroutines.flow.flow { + throw IOException("corrupt preferences") + } + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = error("not used") + } + + private class FailingWritePreferencesDataStore( + private val failure: Throwable = IOException("write failed"), + ) : DataStore { + override val data: Flow = MutableStateFlow(emptyPreferences()) + + override suspend fun updateData( + transform: suspend (t: Preferences) -> Preferences, + ): Preferences = throw failure + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 984f670..8ed9912 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,6 +31,7 @@ include(":runtime:simulation") include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") +include(":android:core:preferences") include(":android:core:session") include(":android:feature:infrared") include(":android:app") -- 2.54.0 From 686176b148719e6eddc18fa18cc0c93de3898366 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 07:39:10 +0800 Subject: [PATCH 04/40] fix: rethrow UI preference transform IO failures --- .../preferences/DataStoreUiPreferencesRepository.kt | 13 ++++++++++++- .../DataStoreUiPreferencesRepositoryTest.kt | 12 ++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt index cffed9c..9127d88 100644 --- a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt @@ -38,9 +38,16 @@ class DataStoreUiPreferencesRepository internal constructor( transform: (UiPreferences) -> UiPreferences, ): UiPreferencesWriteResult = try { dataStore.edit { storedPreferences -> - storedPreferences.putAll(transform(preferencesFrom(storedPreferences))) + val updatedPreferences = try { + transform(preferencesFrom(storedPreferences)) + } catch (cause: Throwable) { + throw TransformFailure(cause) + } + storedPreferences.putAll(updatedPreferences) } UiPreferencesWriteResult.Success + } catch (failure: TransformFailure) { + throw failure.cause } catch (cause: CancellationException) { throw cause } catch (_: IOException) { @@ -71,6 +78,10 @@ class DataStoreUiPreferencesRepository internal constructor( private inline fun > String.enumOrDefault(default: T): T = enumValues().firstOrNull { it.name == this } ?: default + private class TransformFailure( + override val cause: Throwable, + ) : RuntimeException(cause) + private companion object { val RENDERER = stringPreferencesKey("renderer") val THEME_MODE = stringPreferencesKey("theme_mode") diff --git a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt index 25fd0bd..bc71d3e 100644 --- a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt +++ b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt @@ -99,6 +99,18 @@ class DataStoreUiPreferencesRepositoryTest { assertEquals(failure, actual) } + @Test + fun updateRethrowsTransformIOException() = runTest { + val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore()) + val failure = IOException("invalid transform") + + val actual = runCatching { + repository.update { throw failure } + }.exceptionOrNull() + + assertEquals(failure, actual) + } + @Test fun updateRethrowsCancellationException() = runTest { val cancellation = CancellationException("cancelled") -- 2.54.0 From 4055c53362ec3ee92a95e32e20d26cae54b69991 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 09:37:10 +0800 Subject: [PATCH 05/40] feat: add dual-renderer design tokens --- .../abox/manager/ui/AppScreenshotSmokeTest.kt | 13 +- android/core/designsystem/build.gradle.kts | 3 + .../abox/manager/designsystem/Shape.kt | 11 +- .../abox/manager/designsystem/Theme.kt | 169 +++++++++++++++--- .../abox/manager/designsystem/Type.kt | 1 + .../renderer/ABoxRendererAdapter.kt | 20 +++ .../renderer/Material3RendererAdapter.kt | 23 +++ .../renderer/MiuixRendererAdapter.kt | 25 +++ .../manager/designsystem/tokens/ABoxTokens.kt | 139 ++++++++++++++ .../designsystem/ScreenshotSmokeTest.kt | 13 +- .../renderer/ABoxRendererAdapterTest.kt | 19 ++ .../tokens/ABoxThemeResolverTest.kt | 116 ++++++++++++ gradle/libs.versions.toml | 2 + 13 files changed, 521 insertions(+), 33 deletions(-) create mode 100644 android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt create mode 100644 android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt create mode 100644 android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt create mode 100644 android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt create mode 100644 android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapterTest.kt create mode 100644 android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt diff --git a/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt index e5ab6c5..bd5b8aa 100644 --- a/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt +++ b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt @@ -5,10 +5,21 @@ 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(darkTheme = false) { Text("ABox Manager") } + ABoxTheme( + preferences = UiPreferences( + renderer = DesignRenderer.MATERIAL3, + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + ) { + Text("ABox Manager") + } } diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index aa2fb22..d28fb6b 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -32,11 +32,14 @@ kotlin { } dependencies { + implementation(project(":android:core:preferences")) implementation(platform(libs.compose.bom)) implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) + implementation(libs.miuix.ui.android) debugImplementation(libs.compose.ui.tooling) screenshotTestImplementation(libs.compose.screenshot.test) + testImplementation(libs.junit) } diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt index 5bc6176..f19373c 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Shape.kt @@ -2,10 +2,11 @@ package com.flagship.abox.manager.designsystem import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Shapes -import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.designsystem.tokens.ABoxShapeTokens +import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults -val ABoxShapes = Shapes( - small = RoundedCornerShape(8.dp), - medium = RoundedCornerShape(16.dp), - large = RoundedCornerShape(24.dp), +fun ABoxShapes(tokens: ABoxShapeTokens = ABoxThemeDefaults.material3Shapes) = Shapes( + small = RoundedCornerShape(tokens.smallCornerRadius), + medium = RoundedCornerShape(tokens.mediumCornerRadius), + large = RoundedCornerShape(tokens.largeCornerRadius), ) diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index 7d83a34..4f68164 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -1,41 +1,158 @@ package com.flagship.abox.manager.designsystem -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.MaterialTheme +import android.os.Build +import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import 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.UiPreferences -private val LightColorScheme = lightColorScheme( - primary = Color(0xFF006780), - onPrimary = Color.White, - primaryContainer = Color(0xFFB8EAFF), - onPrimaryContainer = Color(0xFF001F29), - secondary = Color(0xFF4D616A), - surface = Color(0xFFF8FAFC), - background = Color(0xFFF8FAFC), -) - -private val DarkColorScheme = darkColorScheme( - primary = Color(0xFF5DD5F8), - onPrimary = Color(0xFF003544), - primaryContainer = Color(0xFF004D61), - onPrimaryContainer = Color(0xFFB8EAFF), - secondary = Color(0xFFB4CBD4), - surface = Color(0xFF101416), - background = Color(0xFF101416), -) +val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors } +val LocalABoxSpacing = staticCompositionLocalOf { ABoxSpacing() } +val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography } +val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes } +val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 } +val LocalABoxReducedMotion = staticCompositionLocalOf { false } @Composable fun ABoxTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + preferences: UiPreferences, content: @Composable () -> Unit, ) { - MaterialTheme( - colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, - typography = ABoxTypography, - shapes = ABoxShapes, + val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme() + val isDarkTheme = when (preferences.themeMode) { + AppThemeMode.SYSTEM -> systemInDarkTheme + AppThemeMode.LIGHT -> false + AppThemeMode.DARK -> true + } + val context = LocalContext.current + 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 + } + val resolution = ABoxThemeResolver.resolve( + preferences = preferences, + systemInDarkTheme = systemInDarkTheme, + sdkInt = Build.VERSION.SDK_INT, + dynamicColorTokens = dynamicTokens, + ) + val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer) + + CompositionLocalProvider( + LocalABoxColors provides resolution.colorTokens, + LocalABoxSpacing provides ABoxSpacing(), + LocalABoxTypography provides ABoxTypography, + LocalABoxShapes provides resolution.shapeTokens, + LocalABoxRenderer provides resolution.renderer, + LocalABoxReducedMotion provides false, + ) { + renderer.Render( + resolution = resolution, + content = content, + ) + } +} + +@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, +) diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt index 0048478..1f4b05a 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Type.kt @@ -2,4 +2,5 @@ package com.flagship.abox.manager.designsystem import androidx.compose.material3.Typography +/** Default typography supplied through [LocalABoxTypography]. */ val ABoxTypography = Typography() diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt new file mode 100644 index 0000000..e129305 --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt @@ -0,0 +1,20 @@ +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 + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt new file mode 100644 index 0000000..9a7a4a7 --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt @@ -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 + +object Material3RendererAdapter : ABoxRendererAdapter { + @Composable + override fun Render( + resolution: ABoxThemeResolution, + content: @Composable () -> Unit, + ) { + MaterialTheme( + colorScheme = materialColorScheme(resolution), + typography = ABoxTypography, + shapes = ABoxShapes(resolution.shapeTokens), + content = content, + ) + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt new file mode 100644 index 0000000..394a3dd --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt @@ -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, + ) { + Material3RendererAdapter.Render( + resolution = resolution, + content = content, + ) + } + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt new file mode 100644 index 0000000..004b947 --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt @@ -0,0 +1,139 @@ +package com.flagship.abox.manager.designsystem.tokens + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.graphics.Color +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.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 { + brandTokens + } + 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, + ) + } +} diff --git a/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt index 49b0538..85219f3 100644 --- a/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt +++ b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ScreenshotSmokeTest.kt @@ -4,10 +4,21 @@ 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(darkTheme = false) { Text("截图基线") } + ABoxTheme( + preferences = UiPreferences( + renderer = DesignRenderer.MATERIAL3, + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + ) { + Text("截图基线") + } } diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapterTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapterTest.kt new file mode 100644 index 0000000..d4dacf6 --- /dev/null +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapterTest.kt @@ -0,0 +1,19 @@ +package com.flagship.abox.manager.designsystem.renderer + +import com.flagship.abox.manager.preferences.DesignRenderer +import org.junit.Assert.assertSame +import org.junit.Test + +class ABoxRendererAdapterTest { + @Test + fun selectsMaterial3AndMiuixAdaptersFromRendererPreference() { + assertSame( + Material3RendererAdapter, + ABoxRendererAdapters.forRenderer(DesignRenderer.MATERIAL3), + ) + assertSame( + MiuixRendererAdapter, + ABoxRendererAdapters.forRenderer(DesignRenderer.MIUIX), + ) + } +} diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt new file mode 100644 index 0000000..e0a23ce --- /dev/null +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt @@ -0,0 +1,116 @@ +package com.flagship.abox.manager.designsystem.tokens + +import androidx.compose.ui.graphics.Color +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.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ABoxThemeResolverTest { + @Test + fun systemLightAndDarkResolveDeterministically() { + val light = ABoxThemeResolver.resolve( + preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM), + systemInDarkTheme = false, + sdkInt = 30, + dynamicColorTokens = null, + ) + val dark = ABoxThemeResolver.resolve( + preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM), + systemInDarkTheme = true, + sdkInt = 30, + dynamicColorTokens = null, + ) + + assertFalse(light.isDarkTheme) + assertEquals(ABoxThemeDefaults.lightColors, light.colorTokens) + assertEquals(ABoxThemeDefaults.darkColors, dark.colorTokens) + } + + @Test + fun dynamicColorDisabledUsesBrandTokens() { + val resolution = ABoxThemeResolver.resolve( + preferences = UiPreferences( + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = dynamicTokens, + ) + + assertEquals(ABoxThemeDefaults.lightColors, resolution.colorTokens) + } + + @Test + fun pre31DynamicColorFallsBackToBrandTokens() { + val resolution = ABoxThemeResolver.resolve( + preferences = UiPreferences( + themeMode = AppThemeMode.DARK, + dynamicColorEnabled = true, + ), + systemInDarkTheme = false, + sdkInt = 30, + dynamicColorTokens = dynamicTokens, + ) + + assertEquals(ABoxThemeDefaults.darkColors, resolution.colorTokens) + } + + @Test + fun blurDisabledResolvesOpaqueSurface() { + val resolution = ABoxThemeResolver.resolve( + preferences = UiPreferences(blurEnabled = false), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = null, + ) + + assertFalse(resolution.blurEnabled) + assertEquals(1f, resolution.colorTokens.surface.alpha) + assertEquals(1f, resolution.colorTokens.surfaceContainer.alpha) + } + + @Test + fun rendererChangesShapeButNotSemanticStateRoles() { + val material = ABoxThemeResolver.resolve( + preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = null, + ) + val miuix = ABoxThemeResolver.resolve( + preferences = UiPreferences(renderer = DesignRenderer.MIUIX), + systemInDarkTheme = false, + sdkInt = 31, + dynamicColorTokens = null, + ) + + assertEquals(material.colorTokens.success, miuix.colorTokens.success) + assertEquals(material.colorTokens.warning, miuix.colorTokens.warning) + assertEquals(material.colorTokens.error, miuix.colorTokens.error) + assertEquals(material.colorTokens.offline, miuix.colorTokens.offline) + assertEquals(material.colorTokens.pending, miuix.colorTokens.pending) + assertNotEquals(material.shapeTokens, miuix.shapeTokens) + } + + private companion object { + val dynamicTokens = ABoxColorTokens( + primary = Color(0xFF111111), + surface = Color(0xFF222222), + surfaceContainer = Color(0xFF333333), + outline = Color(0xFF444444), + success = Color(0xFF555555), + warning = Color(0xFF666666), + error = Color(0xFF777777), + offline = Color(0xFF888888), + pending = Color(0xFF999999), + deviceSocket = Color(0xFFAAAAAA), + deviceSensor = Color(0xFFBBBBBB), + deviceInfrared = Color(0xFFCCCCCC), + ) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 11f7d0e..fda3fcc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ adaptive = "1.3.0" adaptiveNavigationSuite = "1.4.0" window = "1.5.1" composeScreenshot = "0.0.1-alpha16" +miuix = "0.9.3" junit = "4.13.2" androidxJunit = "1.2.1" espresso = "3.6.1" @@ -40,6 +41,7 @@ compose-material-icons-extended = { module = "androidx.compose.material:material compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } compose-screenshot-test = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" } +miuix-ui-android = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.ref = "miuix" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } -- 2.54.0 From cae2ea9ce06c9cd60912d9b1ae1128b14eb71065 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 09:53:09 +0800 Subject: [PATCH 06/40] fix: contain Material renderer adapter --- .../manager/designsystem/renderer/Material3RendererAdapter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt index 9a7a4a7..53a18da 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/Material3RendererAdapter.kt @@ -7,7 +7,7 @@ import com.flagship.abox.manager.designsystem.ABoxTypography import com.flagship.abox.manager.designsystem.materialColorScheme import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution -object Material3RendererAdapter : ABoxRendererAdapter { +internal object Material3RendererAdapter : ABoxRendererAdapter { @Composable override fun Render( resolution: ABoxThemeResolution, -- 2.54.0 From 063a3f8dabe48d4ea14cccedbdda60db2b520cef Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 10:54:12 +0800 Subject: [PATCH 07/40] feat: add semantic ABox UI components --- android/core/designsystem/build.gradle.kts | 6 + .../ABoxComponentSemanticsTest.kt | 139 +++++ .../abox/manager/designsystem/Components.kt | 550 +++++++++++++++--- .../abox/manager/designsystem/Theme.kt | 2 + .../manager/designsystem/icon/ABoxIcon.kt | 114 ++++ .../designsystem/ABoxThemeScreenshotTest.kt | 123 ++++ ...tBlurFallback_Blur fallback_3cad3a9c_0.png | Bin 0 -> 78657 bytes ...eenshotLargeFont_Large font_062df036_0.png | Bin 0 -> 86425 bytes ...eenshotMaterialDark_M3 dark_d09ddd86_0.png | Bin 0 -> 79995 bytes ...nshotMaterialLight_M3 light_0ba11da1_0.png | Bin 0 -> 79963 bytes ...eenshotMiuixDark_Miuix dark_78894dae_0.png | Bin 0 -> 80341 bytes ...nshotMiuixLight_Miuix light_62c07a3f_0.png | Bin 0 -> 80246 bytes .../designsystem/icon/ABoxIconMappingTest.kt | 37 ++ 13 files changed, 900 insertions(+), 71 deletions(-) create mode 100644 android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt create mode 100644 android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt create mode 100644 android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt create mode 100644 android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png create mode 100644 android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png create mode 100644 android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png create mode 100644 android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialLight_M3 light_0ba11da1_0.png create mode 100644 android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png create mode 100644 android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png create mode 100644 android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIconMappingTest.kt diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index d28fb6b..7b06b9b 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -42,4 +42,10 @@ dependencies { 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) } diff --git a/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt new file mode 100644 index 0000000..7451c90 --- /dev/null +++ b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt @@ -0,0 +1,139 @@ +package com.flagship.abox.manager.designsystem + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.testTag +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.semantics.SemanticsProperties +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.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.Rule +import org.junit.Test +import org.junit.runner.RunWith + +import androidx.compose.ui.Modifier +import androidx.compose.material3.Text +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey + + +@RunWith(AndroidJUnit4::class) +class ABoxComponentSemanticsTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun allInteractiveControlsHaveAtLeast48DpBounds() { + composeRule.setContent { + ABoxTheme { + ABoxButton(onClick = {}, modifier = androidx.compose.ui.Modifier.testTag("button")) { + androidx.compose.material3.Text("确定") + } + ABoxIconButton( + key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.ADD, + contentDescription = "添加设备", + onClick = {}, + modifier = androidx.compose.ui.Modifier.testTag("icon"), + ) + ABoxSwitch( + checked = false, + onCheckedChange = {}, + contentDescription = "启用设备", + modifier = androidx.compose.ui.Modifier.testTag("switch"), + ) + } + } + + composeRule.onNodeWithTag("button").assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag("icon").assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag("switch").assertHeightIsAtLeast(48.dp) + } + + @Test + fun cardBodyAndQuickActionExposeIndependentSemanticNodes() { + composeRule.setContent { + ABoxTheme { + ABoxCard( + title = "客厅插座", + subtitle = "在线", + onClick = {}, + quickAction = ABoxCardQuickAction( + key = com.flagship.abox.manager.designsystem.icon.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 reducedMotionExposesStaticLoadingState() { + composeRule.setContent { + CompositionLocalProvider(LocalABoxReducedMotion provides true) { + ABoxLoadingState(contentDescription = "正在加载") + } + } + + val loadingNode = composeRule.onNodeWithContentDescription("正在加载") + .assertExists() + .fetchSemanticsNode() + assertEquals("静态加载", loadingNode.config[SemanticsProperties.StateDescription]) + } + + @Test + fun interactiveIconRequiresNonBlankChineseDescription() { + composeRule.setContent { + ABoxTheme { + ABoxIconButton( + key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.BACK, + contentDescription = "返回", + onClick = {}, + ) + } + } + + composeRule.onNodeWithContentDescription("返回") + .assertContentDescriptionEquals("返回") + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index 8f3fb5a..614e7a6 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -5,47 +5,85 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.LocationOn -import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.outlined.AccountCircle +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.designsystem.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey +import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing +import androidx.compose.material3.Icon as MaterialIcon +@Composable +fun ABoxAppSurface( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val colors = LocalABoxColors.current + val blurEnabled = LocalABoxBlurEnabled.current + Surface( + modifier = modifier, + color = if (blurEnabled) colors.surfaceContainer.copy(alpha = 0.92f) else colors.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + content = content, + ) +} + +/** Compatibility surface retained for existing feature modules. */ @Composable fun ABoxSurface( modifier: Modifier = Modifier, content: @Composable () -> Unit, -) { - Surface(modifier = modifier, content = content) -} +) = ABoxAppSurface(modifier = modifier, content = content) @Composable fun ABoxButton( @@ -56,7 +94,7 @@ fun ABoxButton( ) { Button( onClick = onClick, - modifier = modifier, + modifier = modifier.defaultMinSize(minHeight = 48.dp), enabled = enabled, colors = ButtonDefaults.buttonColors(), content = content, @@ -64,14 +102,397 @@ fun ABoxButton( } @Composable -fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { - CircularProgressIndicator(modifier = modifier) +fun ABoxIconButton( + key: ABoxIconKey, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + require(contentDescription.isNotBlank()) { + "ABoxIconButton contentDescription must not be blank" + } + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp), + ) { + ABoxIcon(key = key, contentDescription = contentDescription) + } } -/** - * Circular user avatar displaying the initial character of username or a default person icon. - * Styled with an accented profile ring. - */ +@Composable +fun ABoxSwitch( + checked: Boolean, + onCheckedChange: ((Boolean) -> Unit)?, + contentDescription: String, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + require(contentDescription.isNotBlank()) { + "ABoxSwitch contentDescription must not be blank" + } + Box( + modifier = modifier + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) + .semantics { + this.contentDescription = contentDescription + }, + contentAlignment = Alignment.Center, + ) { + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + } +} + +@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, + 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, + content: @Composable (PaddingValues) -> Unit, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + ABoxIconButton( + key = ABoxIconKey.BACK, + contentDescription = "返回", + onClick = onBack, + ) + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.padding(start = 8.dp), + ) + } + }, + content = content, + ) +} + +/** Circular user avatar displaying the initial character of username or a default person icon. */ @Composable fun ABoxUserAvatar( name: String?, @@ -83,73 +504,73 @@ fun ABoxUserAvatar( val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase() Box( modifier = modifier - .size(size) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primaryContainer) - .border( - width = 2.dp, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), - shape = CircleShape, - ) + .defaultMinSize(minWidth = 48.dp, minHeight = 48.dp) .clickable(onClickLabel = contentDescription, onClick = onClick), contentAlignment = Alignment.Center, ) { - if (initial != null) { - Text( - text = initial, - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer, + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer) + .border( + width = 2.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), + shape = CircleShape, ), - ) - } else { - Icon( - imageVector = Icons.Default.Person, - contentDescription = contentDescription, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(size * 0.6f), - ) + contentAlignment = Alignment.Center, + ) { + if (initial != null) { + Text( + text = initial, + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } else { + MaterialIcon( + imageVector = ABoxIconMapperForAccount, + contentDescription = contentDescription, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(size * 0.6f), + ) + } } } } -/** - * Material 3 pill top bar containing leading icon, title/provider label, - * an optional '+' action button, and user avatar. - */ +private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle + +/** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */ @Composable fun ABoxTopPillBar( title: String, onAvatarClick: () -> Unit, modifier: Modifier = Modifier, userName: String? = null, - leadingIcon: ImageVector = Icons.Default.LocationOn, + leadingIcon: ImageVector = Icons.Outlined.Bolt, onAddClick: (() -> Unit)? = null, - addActionContentDescription: String? = "Add", - avatarContentDescription: String? = "Account", + addActionContentDescription: String? = "添加", + avatarContentDescription: String? = "账户", ) { Surface( - modifier = modifier - .fillMaxWidth() - .height(56.dp), - shape = RoundedCornerShape(28.dp), + modifier = modifier.fillMaxWidth().height(64.dp), + shape = RoundedCornerShape(32.dp), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), tonalElevation = 2.dp, ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon( + MaterialIcon( imageVector = leadingIcon, - contentDescription = null, + contentDescription = title, tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), + modifier = Modifier.padding(start = 8.dp).size(22.dp), ) - Text( text = title, style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), @@ -158,20 +579,13 @@ fun ABoxTopPillBar( overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) - if (onAddClick != null) { - IconButton( + ABoxIconButton( + key = ABoxIconKey.ADD, + contentDescription = addActionContentDescription ?: "添加", onClick = onAddClick, - modifier = Modifier.size(36.dp), - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = addActionContentDescription, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + ) } - ABoxUserAvatar( name = userName, onClick = onAvatarClick, @@ -182,9 +596,6 @@ fun ABoxTopPillBar( } } -/** - * ABox standard Material 3 Navigation Bar with pill indicator styling. - */ @Composable fun ABoxNavigationBar( modifier: Modifier = Modifier, @@ -198,9 +609,6 @@ fun ABoxNavigationBar( ) } -/** - * ABox standard Navigation Bar Item. - */ @Composable fun RowScope.ABoxNavigationBarItem( selected: Boolean, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index 4f68164..ad6eb22 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -27,6 +27,7 @@ val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography } val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes } val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 } val LocalABoxReducedMotion = staticCompositionLocalOf { false } +val LocalABoxBlurEnabled = staticCompositionLocalOf { false } @Composable fun ABoxTheme( @@ -65,6 +66,7 @@ fun ABoxTheme( LocalABoxShapes provides resolution.shapeTokens, LocalABoxRenderer provides resolution.renderer, LocalABoxReducedMotion provides false, + LocalABoxBlurEnabled provides resolution.blurEnabled, ) { renderer.Render( resolution = resolution, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt new file mode 100644 index 0000000..217b822 --- /dev/null +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt @@ -0,0 +1,114 @@ +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.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, +} + +/** 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 + } +} + +@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, + ) +} diff --git a/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt new file mode 100644 index 0000000..3541208 --- /dev/null +++ b/android/core/designsystem/src/screenshotTest/kotlin/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTest.kt @@ -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 = {}, + ) + } + } + } +} diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotBlurFallback_Blur fallback_3cad3a9c_0.png new file mode 100644 index 0000000000000000000000000000000000000000..70919a972ed817812ada09624512c09740314dc9 GIT binary patch literal 78657 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≈7)it6U4LbwTJ+bD-w^eJ_E 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 F{|k*k-9`Wa literal 0 HcmV?d00001 diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotLargeFont_Large font_062df036_0.png new file mode 100644 index 0000000000000000000000000000000000000000..957bff83bf52f7e777cb4581035816409a643577 GIT binary patch literal 86425 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 literal 0 HcmV?d00001 diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMaterialDark_M3 dark_d09ddd86_0.png new file mode 100644 index 0000000000000000000000000000000000000000..276aac9212c3a43d8ff59e21f9c89b69433547f0 GIT binary patch literal 79995 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 literal 0 HcmV?d00001 diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixLight_Miuix light_62c07a3f_0.png new file mode 100644 index 0000000000000000000000000000000000000000..cb2ea5f59766995da3af7fd31940190d7ef05afb GIT binary patch literal 80246 zcmeGEcUM!}7e5N)u^=iUQdFu^RXPYrw}6OJRJv3{Q>yeDu%LwAq((qRKw2Q6^ngOB z0U{tI5CYN(p|=2`+`Yp&-}}4cc>~WF_x=^e+N;bp=UlU`&m#Pht|sFtj#G4Wbc|Z} z)t}JO9Yxa7(PK`Y09v5^*B9vMUeRf(-!|~GoF~rIvf9;rTiFx-STdtXoiBH=kCo}Z z*dl7LE=AAk$-#1c`c=e{<3~>>cy`~9Nw^*N(es4h)r%+3vy1+$Mo^Y-=<95(d6o3L zZ_9fU&ti!+x^^_ti-)dSFeuTdccb!QdZsbXLp&EdT*+mmx>MKjLqr z{{esb-olQ9zv?HLPlLa(Gkome@3~9rqTugkX_yK4d&~CR|2KiHx)3BbYxhFI%lWOP z#phF6zLR(c->UIYCBMlk>cai0d^Hi`>!#M>{kyHhukR)y)hPQutN{0}GK^uX8@N^X zwkhi~e{i{HCQUsJ$L1Bh+LJHfxcgShK6=p1 zWNiQH_S2AQapRg_K^o`)-C!qygQ`j1q58MSM1XE_ZmSArs&1`cD$0TgcjZ${U?$je z#h}#Olbrs{omgXICco}rIf%$8(fbaHeLo&c3cDWDs#;0LuI8wNBk+6)qjPQVW_EZP zfDUwO?K6}oDvE`ioyO|U%x!00Xtx~$n$=T+GE=Y{JLSP2NZ_<)ZztjAxd#IW;21h} z{cFudu9ASR9XIkH_w}8eWvI*|TL238DnT%d63eA>iMm??zkHpxW3hAF(@BvHX+_|I zS9w>rt%O{nA)PdG&FDIBoB^6Y$disJ@atE;!P16wWoNgBI5Y}@i@DS5ZrnDoml

    v}#&xj^2rmvfRp+beA? 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 Date: Wed, 26 Aug 2026 11:33:18 +0800 Subject: [PATCH 08/40] fix: correct ABox switch semantics and motion --- .../ABoxComponentSemanticsTest.kt | 82 ++++++++++++++----- .../abox/manager/designsystem/Components.kt | 11 ++- .../abox/manager/designsystem/Theme.kt | 36 +++++++- 3 files changed, 106 insertions(+), 23 deletions(-) diff --git a/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt index 7451c90..e5657d7 100644 --- a/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt +++ b/android/core/designsystem/src/androidTest/kotlin/com/flagship/abox/manager/designsystem/ABoxComponentSemanticsTest.kt @@ -1,35 +1,38 @@ package com.flagship.abox.manager.designsystem +import android.os.Build import androidx.activity.ComponentActivity -import androidx.compose.runtime.CompositionLocalProvider +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.semantics.SemanticsProperties +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 -import androidx.compose.ui.Modifier -import androidx.compose.material3.Text -import com.flagship.abox.manager.designsystem.icon.ABoxIconKey - - @RunWith(AndroidJUnit4::class) class ABoxComponentSemanticsTest { @get:Rule @@ -39,27 +42,58 @@ class ABoxComponentSemanticsTest { fun allInteractiveControlsHaveAtLeast48DpBounds() { composeRule.setContent { ABoxTheme { - ABoxButton(onClick = {}, modifier = androidx.compose.ui.Modifier.testTag("button")) { - androidx.compose.material3.Text("确定") + ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) { + Text("确定") } ABoxIconButton( - key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.ADD, + key = ABoxIconKey.ADD, contentDescription = "添加设备", onClick = {}, - modifier = androidx.compose.ui.Modifier.testTag("icon"), + modifier = Modifier.testTag("icon"), ) ABoxSwitch( checked = false, onCheckedChange = {}, contentDescription = "启用设备", - modifier = androidx.compose.ui.Modifier.testTag("switch"), + modifier = Modifier.testTag("switch"), ) } } - composeRule.onNodeWithTag("button").assertHeightIsAtLeast(48.dp) - composeRule.onNodeWithTag("icon").assertHeightIsAtLeast(48.dp) - composeRule.onNodeWithTag("switch").assertHeightIsAtLeast(48.dp) + 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 @@ -71,7 +105,7 @@ class ABoxComponentSemanticsTest { subtitle = "在线", onClick = {}, quickAction = ABoxCardQuickAction( - key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.REFRESH, + key = ABoxIconKey.REFRESH, contentDescription = "刷新设备", onClick = {}, ), @@ -108,9 +142,19 @@ class ABoxComponentSemanticsTest { } @Test - fun reducedMotionExposesStaticLoadingState() { + fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() { composeRule.setContent { - CompositionLocalProvider(LocalABoxReducedMotion provides true) { + ABoxThemeContent( + preferences = UiPreferences( + renderer = DesignRenderer.MATERIAL3, + themeMode = AppThemeMode.LIGHT, + dynamicColorEnabled = false, + ), + systemInDarkTheme = false, + sdkInt = Build.VERSION.SDK_INT, + dynamicColorTokens = null, + reducedMotion = true, + ) { ABoxLoadingState(contentDescription = "正在加载") } } @@ -126,7 +170,7 @@ class ABoxComponentSemanticsTest { composeRule.setContent { ABoxTheme { ABoxIconButton( - key = com.flagship.abox.manager.designsystem.icon.ABoxIconKey.BACK, + key = ABoxIconKey.BACK, contentDescription = "返回", onClick = {}, ) diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index 614e7a6..a47ef34 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -54,6 +54,8 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.stateDescription +import androidx.compose.foundation.selection.toggleable +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp @@ -135,6 +137,12 @@ fun ABoxSwitch( 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 }, @@ -142,8 +150,9 @@ fun ABoxSwitch( ) { Switch( checked = checked, - onCheckedChange = onCheckedChange, + onCheckedChange = null, enabled = enabled, + modifier = Modifier.clearAndSetSemantics { }, ) } } diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index ad6eb22..bed7e71 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -1,6 +1,7 @@ package com.flagship.abox.manager.designsystem import android.os.Build +import android.provider.Settings import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.dynamicDarkColorScheme @@ -35,12 +36,12 @@ fun ABoxTheme( content: @Composable () -> Unit, ) { val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme() + val context = LocalContext.current val isDarkTheme = when (preferences.themeMode) { AppThemeMode.SYSTEM -> systemInDarkTheme AppThemeMode.LIGHT -> false AppThemeMode.DARK -> true } - val context = LocalContext.current val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val dynamicColorScheme = if (isDarkTheme) { dynamicDarkColorScheme(context) @@ -51,11 +52,30 @@ fun ABoxTheme( } else { null } - val resolution = ABoxThemeResolver.resolve( + ABoxThemeContent( preferences = preferences, systemInDarkTheme = systemInDarkTheme, sdkInt = Build.VERSION.SDK_INT, dynamicColorTokens = dynamicTokens, + reducedMotion = systemAnimatorDurationScale(context) == 0f, + content = content, + ) +} + +@Composable +internal fun ABoxThemeContent( + preferences: UiPreferences, + systemInDarkTheme: Boolean, + sdkInt: Int, + dynamicColorTokens: ABoxColorTokens?, + reducedMotion: Boolean, + content: @Composable () -> Unit, +) { + val resolution = ABoxThemeResolver.resolve( + preferences = preferences, + systemInDarkTheme = systemInDarkTheme, + sdkInt = sdkInt, + dynamicColorTokens = dynamicColorTokens, ) val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer) @@ -65,7 +85,7 @@ fun ABoxTheme( LocalABoxTypography provides ABoxTypography, LocalABoxShapes provides resolution.shapeTokens, LocalABoxRenderer provides resolution.renderer, - LocalABoxReducedMotion provides false, + LocalABoxReducedMotion provides reducedMotion, LocalABoxBlurEnabled provides resolution.blurEnabled, ) { renderer.Render( @@ -75,6 +95,16 @@ fun ABoxTheme( } } +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(), -- 2.54.0 From 283f56a3b6e8ba0406fe81ea5a4e6588c30f119a Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 12:10:55 +0800 Subject: [PATCH 09/40] feat: add persistent interface settings --- .../ui/preferences/SettingsScreenTest.kt | 90 +++++++ .../abox/manager/di/UiPreferencesModule.kt | 21 ++ .../abox/manager/ui/ABoxManagerApp.kt | 5 +- .../manager/ui/preferences/SettingsScreen.kt | 227 ++++++++++++++++++ .../ui/preferences/SettingsViewModel.kt | 122 ++++++++++ android/app/src/main/res/values/strings.xml | 28 +++ .../ui/preferences/SettingsViewModelTest.kt | 111 +++++++++ 7 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt new file mode 100644 index 0000000..60a9e50 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt @@ -0,0 +1,90 @@ +package com.flagship.abox.manager.ui.preferences + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.ui.unit.dp +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.test.assertIsSelected +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertWidthIsAtLeast +import androidx.compose.ui.test.assertHeightIsAtLeast +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 com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.UiPreferences +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SettingsScreenTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() { + composeRule.setContent { + ABoxTheme { + SettingsScreen( + state = SettingsUiState( + preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), + loaded = true, + ), + onAction = {}, + ) + } + } + + composeRule.onNodeWithText("界面设置").assertIsDisplayed() + composeRule.onNodeWithText("渲染器").assertIsDisplayed() + composeRule.onNodeWithText("Material 3").assertIsSelected() + composeRule.onNodeWithText("跟随系统").assertIsSelected() + composeRule.onNodeWithText("动态取色").assertIsDisplayed() + composeRule.onNodeWithText("系统辅助功能").assertIsDisplayed() + composeRule.onNodeWithTag("settings-dynamic-color") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag("settings-blur") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + composeRule.onNodeWithTag("settings-haptics") + .assertWidthIsAtLeast(48.dp) + .assertHeightIsAtLeast(48.dp) + } + + @Test + fun switchingRendererKeepsCurrentlyRenderedTestScreenPresent() { + var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3)) + composeRule.setContent { + ABoxTheme(preferences = preferences) { + Column { + Text("当前测试内容") + SettingsScreen( + state = SettingsUiState(preferences = preferences, loaded = true), + onAction = { action -> + if (action is SettingsUiAction.SetRenderer) { + preferences = preferences.copy(renderer = action.value) + } + }, + modifier = Modifier.weight(1f), + ) + } + } + } + + composeRule.onNodeWithText("Miuix").performClick() + composeRule.waitForIdle() + + composeRule.onNodeWithText("当前测试内容").assertIsDisplayed() + composeRule.onNodeWithText("Miuix").assertIsSelected() + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt new file mode 100644 index 0000000..674cde5 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/UiPreferencesModule.kt @@ -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) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 42bda6b..ce67fc0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -32,6 +32,7 @@ import com.flagship.abox.manager.ui.navigation.deviceDetailGraph import com.flagship.abox.manager.ui.navigation.navigateToSocket import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute +import com.flagship.abox.manager.ui.preferences.SettingsViewModel import com.flagship.abox.manager.ui.scene.SceneListScreen import com.flagship.abox.manager.ui.scene.SceneRoutes import com.flagship.abox.manager.ui.scene.sceneEditorDestination @@ -46,9 +47,11 @@ fun ABoxManagerApp( appSessionViewModel: AppSessionViewModel = hiltViewModel(), sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), loginViewModel: LoginViewModel = hiltViewModel(), + settingsViewModel: SettingsViewModel = hiltViewModel(), ) { val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value + val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value val screenKey = sessionState.screenKey() LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() @@ -56,7 +59,7 @@ fun ABoxManagerApp( loginViewModel.clearSensitiveState() } } - ABoxTheme { + ABoxTheme(preferences = settingsState.preferences) { ABoxSurface(modifier = Modifier.fillMaxSize()) { when (val state = sessionState) { AppSessionUiState.Loading -> Box( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt new file mode 100644 index 0000000..3a99fe5 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt @@ -0,0 +1,227 @@ +package com.flagship.abox.manager.ui.preferences + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.designsystem.ABoxCard +import com.flagship.abox.manager.designsystem.ABoxErrorState +import com.flagship.abox.manager.designsystem.ABoxFilterChip +import com.flagship.abox.manager.designsystem.ABoxPreferenceGroup +import com.flagship.abox.manager.designsystem.ABoxSwitch +import com.flagship.abox.manager.preferences.AppThemeMode +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.NavigationPresentation + +@Composable +fun SettingsRoute( + viewModel: SettingsViewModel = hiltViewModel(), +) { + val state = viewModel.uiState.collectAsStateWithLifecycle().value + SettingsScreen(state = state, onAction = viewModel::onAction) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + state: SettingsUiState, + onAction: (SettingsUiAction) -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) }, + ) { contentPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(contentPadding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + ABoxPreferenceGroup(title = stringResource(R.string.settings_style_section)) { + PreferenceRow( + title = stringResource(R.string.settings_renderer_label), + description = stringResource(R.string.settings_renderer_description), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ABoxFilterChip( + selected = state.preferences.renderer == DesignRenderer.MATERIAL3, + label = stringResource(R.string.settings_renderer_material3), + onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MATERIAL3)) }, + ) + ABoxFilterChip( + selected = state.preferences.renderer == DesignRenderer.MIUIX, + label = stringResource(R.string.settings_renderer_miuix), + onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) }, + ) + } + } + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_theme_section)) { + PreferenceRow( + title = stringResource(R.string.settings_theme_label), + description = null, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + ThemeChip(AppThemeMode.SYSTEM, state, onAction, R.string.settings_theme_system) + ThemeChip(AppThemeMode.LIGHT, state, onAction, R.string.settings_theme_light) + ThemeChip(AppThemeMode.DARK, state, onAction, R.string.settings_theme_dark) + } + } + SwitchPreference( + title = stringResource(R.string.settings_dynamic_color), + description = stringResource(R.string.settings_dynamic_color_description), + checked = state.preferences.dynamicColorEnabled, + modifier = Modifier.testTag("settings-dynamic-color"), + onCheckedChange = { onAction(SettingsUiAction.SetDynamicColor(it)) }, + ) + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_surface_section)) { + SwitchPreference( + title = stringResource(R.string.settings_blur), + description = stringResource(R.string.settings_blur_description), + checked = state.preferences.blurEnabled, + modifier = Modifier.testTag("settings-blur"), + onCheckedChange = { onAction(SettingsUiAction.SetBlur(it)) }, + ) + PreferenceRow( + title = stringResource(R.string.settings_navigation), + description = null, + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + NavigationChip( + presentation = NavigationPresentation.STANDARD, + selected = state.preferences.navigationPresentation == NavigationPresentation.STANDARD, + label = stringResource(R.string.settings_navigation_standard), + onAction = onAction, + ) + NavigationChip( + presentation = NavigationPresentation.FLOATING, + selected = state.preferences.navigationPresentation == NavigationPresentation.FLOATING, + label = stringResource(R.string.settings_navigation_floating), + onAction = onAction, + ) + } + } + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_feedback_section)) { + SwitchPreference( + title = stringResource(R.string.settings_haptics), + description = stringResource(R.string.settings_haptics_description), + checked = state.preferences.hapticsEnabled, + modifier = Modifier.testTag("settings-haptics"), + onCheckedChange = { onAction(SettingsUiAction.SetHaptics(it)) }, + ) + } + + ABoxPreferenceGroup(title = stringResource(R.string.settings_accessibility_section)) { + ABoxCard( + title = stringResource(R.string.settings_accessibility_card_title), + subtitle = stringResource(R.string.settings_accessibility_description), + ) + } + + state.error?.let { error -> + ABoxErrorState( + title = stringResource(R.string.settings_error_save), + description = error.asString(), + ) + } + } + } +} + +@Composable +private fun ThemeChip( + mode: AppThemeMode, + state: SettingsUiState, + onAction: (SettingsUiAction) -> Unit, + labelRes: Int, +) { + ABoxFilterChip( + selected = state.preferences.themeMode == mode, + label = stringResource(labelRes), + onClick = { onAction(SettingsUiAction.SetThemeMode(mode)) }, + ) +} + +@Composable +private fun NavigationChip( + presentation: NavigationPresentation, + selected: Boolean, + label: String, + onAction: (SettingsUiAction) -> Unit, +) { + ABoxFilterChip( + selected = selected, + label = label, + onClick = { onAction(SettingsUiAction.SetNavigation(presentation)) }, + ) +} + +@Composable +private fun PreferenceRow( + title: String, + description: String?, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + description?.let { + Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + content() + } +} + +@Composable +private fun SwitchPreference( + title: String, + description: String, + checked: Boolean, + modifier: Modifier, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = title, style = MaterialTheme.typography.titleMedium) + Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + ABoxSwitch( + checked = checked, + onCheckedChange = onCheckedChange, + contentDescription = title, + modifier = modifier, + ) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt new file mode 100644 index 0000000..d8bdb09 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt @@ -0,0 +1,122 @@ +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.DesignRenderer +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 = _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) }, + ) + 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 = 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 object DismissError : SettingsUiAction +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 4745798..e3be99b 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -259,4 +259,32 @@ Operation failed. Please try again. No sensor data available. An unexpected error occurred. + + + 界面设置 + 样式 + 渲染器 + 选择应用使用的界面渲染方式 + Material 3 + Miuix + 主题 + 主题模式 + 跟随系统 + 浅色 + 深色 + 动态取色 + 使用系统壁纸颜色生成界面配色 + 界面效果 + 模糊效果 + 在支持的界面中启用背景模糊 + 导航样式 + 标准导航栏 + 悬浮导航栏 + 交互反馈 + 触觉反馈 + 操作时提供轻微震动反馈 + 系统辅助功能 + 遵循系统辅助功能设置 + 动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。 + 无法保存界面设置,请重试 diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt new file mode 100644 index 0000000..9de023e --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt @@ -0,0 +1,111 @@ +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.DesignRenderer +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) + } + + + 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 = 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 + } + } +} -- 2.54.0 From c1c21551fb529dbee4fc1b472c92f2c991ce2b52 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 12:51:39 +0800 Subject: [PATCH 10/40] feat: separate session and credential actions --- .../abox/manager/ui/ABoxManagerApp.kt | 5 +- .../manager/ui/common/DomainErrorMapper.kt | 9 +- .../ui/session/SessionActionsViewModel.kt | 42 +++- .../ui/session/SessionActionsViewModelTest.kt | 197 ++++++++++++++++++ .../abox/manager/domain/SessionCoordinator.kt | 29 +++ .../testing/fakes/SessionCoordinatorTest.kt | 128 ++++++++++++ 6 files changed, 397 insertions(+), 13 deletions(-) create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index ce67fc0..73d42ba 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -52,6 +52,7 @@ fun ABoxManagerApp( val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value + val actionError = actionState.errorMessage?.asString() val screenKey = sessionState.screenKey() LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() @@ -74,7 +75,7 @@ fun ABoxManagerApp( is AppSessionUiState.Login -> LoginRoute( profile = state.profile, bootstrapError = state.bootstrapError, - actionError = actionState.errorMessage, + actionError = actionError, actionInProgress = actionState.inProgress, onSwitchProvider = sessionActionsViewModel::switchProvider, viewModel = loginViewModel, @@ -83,7 +84,7 @@ fun ABoxManagerApp( username = state.username, profile = state.profile, actionInProgress = actionState.inProgress, - errorMessage = actionState.errorMessage, + errorMessage = actionError, onLogout = sessionActionsViewModel::logout, ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt index fe37e14..9e47752 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt @@ -13,13 +13,6 @@ object DomainErrorMapper { is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request) is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed) is DomainError.NoData -> UiText.StringResource(R.string.error_no_data) - is DomainError.Unknown -> { - val detail = error.detail - if (!detail.isNullOrBlank()) { - UiText.DynamicString(detail) - } else { - UiText.StringResource(R.string.error_unknown) - } - } + is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown) } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt index f5adb34..d713dde 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModel.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.SessionCoordinator +import com.flagship.abox.manager.domain.SessionValidity +import com.flagship.abox.manager.ui.common.DomainErrorMapper +import com.flagship.abox.manager.ui.common.UiText import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor( private val _uiState = MutableStateFlow(SessionActionsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + fun onAction(action: SessionUiAction) { + when (action) { + SessionUiAction.EndSession -> runAction(coordinator::endActiveSession) + SessionUiAction.ForgetSavedCredentials -> runAction(coordinator::forgetSavedCredentials) + SessionUiAction.SwitchProvider -> runAction(coordinator::switchProvider) + SessionUiAction.ValidateConnection -> runValidation() + SessionUiAction.DismissError -> dismissError() + } + } + + fun dispatch(action: SessionUiAction) = onAction(action) + fun logout() = runAction(coordinator::logout) - fun switchProvider() = runAction(coordinator::switchProvider) + fun switchProvider() = onAction(SessionUiAction.SwitchProvider) fun dismissError() { _uiState.value = _uiState.value.copy(errorMessage = null) @@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor( _uiState.value = SessionActionsUiState(inProgress = true) val result = action() _uiState.value = SessionActionsUiState( - errorMessage = (result as? DomainResult.Failure)?.error?.detail, + errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map), + ) + } + } + + private fun runValidation() { + if (_uiState.value.inProgress) return + viewModelScope.launch { + _uiState.value = SessionActionsUiState(inProgress = true) + val result = coordinator.validateActiveSession() + _uiState.value = SessionActionsUiState( + validity = (result as? DomainResult.Success)?.value, + errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map), ) } } @@ -40,5 +67,14 @@ class SessionActionsViewModel @Inject constructor( data class SessionActionsUiState( val inProgress: Boolean = false, - val errorMessage: String? = null, + val validity: SessionValidity? = null, + val errorMessage: UiText? = null, ) + +sealed interface SessionUiAction { + data object EndSession : SessionUiAction + data object ForgetSavedCredentials : SessionUiAction + data object SwitchProvider : SessionUiAction + data object ValidateConnection : SessionUiAction + data object DismissError : SessionUiAction +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt new file mode 100644 index 0000000..4d3586b --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/session/SessionActionsViewModelTest.kt @@ -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, + ) { + delegate.setValidationResult(profileId, result) + } + } +} + +private fun com.flagship.abox.manager.domain.DomainResult.successValue(): T? = + (this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt index 4923c23..7ab6494 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SessionCoordinator.kt @@ -147,6 +147,35 @@ class SessionCoordinator( } } + suspend fun endActiveSession(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) + when (val logoutResult = profile.gateway().logout(profile.id)) { + is DomainResult.Failure -> return@storeOperation logoutResult + is DomainResult.Success -> Unit + } + if (profile.type == ProviderType.LOCAL_EXPERIENCE) { + profileStore.setActiveProfile(null) + } else { + DomainResult.Success(Unit) + } + } + + suspend fun forgetSavedCredentials(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) + when (val credentialResult = credentialStore.clearCredentials(profile.id)) { + is DomainResult.Failure -> return@storeOperation credentialResult + is DomainResult.Success -> Unit + } + val preferences = profileStore.loginPreferences(profile.id) + profileStore.saveLoginPreferences(preferences.copy(autoLoginEnabled = false)) + } + + suspend fun validateActiveSession(): DomainResult = storeOperation { + val profile = profileStore.activeProfile() + ?: return@storeOperation DomainResult.Success(SessionValidity.INVALID) + profile.gateway().validateSession(profile.id) + } + suspend fun logout(): DomainResult = storeOperation { val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) when (val credentialResult = credentialStore.clearCredentials(profile.id)) { diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt index 43f291a..953124b 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/SessionCoordinatorTest.kt @@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.Session +import com.flagship.abox.manager.domain.SessionValidity import com.flagship.abox.manager.domain.Username import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest @@ -133,6 +134,101 @@ class SessionCoordinatorTest { ) } + @Test + fun endActiveSessionKeepsAboxCredentials() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + val result = fixture.coordinator.endActiveSession() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile, fixture.profiles.activeProfile()) + assertNull(fixture.remote.observeSession(remoteProfile.id).first()) + assertEquals( + credentials, + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + assertTrue(fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) + } + + @Test + fun forgetSavedCredentialsDisablesAutoLoginAndKeepsSessionStateExplicit() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(remoteProfile) + fixture.coordinator.login( + remoteProfile.id, + credentials, + rememberUsername = true, + autoLogin = true, + ) + + val result = fixture.coordinator.forgetSavedCredentials() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile, fixture.profiles.activeProfile()) + assertEquals( + remoteProfile.id, + fixture.remote.observeSession(remoteProfile.id).first()?.profileId, + ) + assertNull( + (fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value, + ) + val preferences = fixture.profiles.loginPreferences(remoteProfile.id) + assertEquals(credentials.username, preferences.rememberedUsername) + assertTrue(!preferences.autoLoginEnabled) + } + + @Test + fun switchProviderClearsActiveProfileAfterGatewayLogout() = runTest { + val profiles = FakeProviderProfileStore() + val remote = RecordingSessionGateway(profiles) + val coordinator = SessionCoordinator( + profileStore = profiles, + credentialStore = FakeCredentialStore(), + remoteGateway = remote, + localGateway = FakeLocalSessionGateway(), + ) + coordinator.selectProvider(remoteProfile) + remote.login(remoteProfile.id, credentials) + + val result = coordinator.switchProvider() + + assertTrue(result is DomainResult.Success) + assertEquals(remoteProfile.id, remote.activeProfileAtLogout) + assertNull(profiles.activeProfile()) + } + + @Test + fun validateActiveSessionDoesNotRelogin() = runTest { + val remote = CountingSessionGateway() + val credentialsStore = CountingCredentialStore() + val profiles = FakeProviderProfileStore() + val coordinator = SessionCoordinator( + profileStore = profiles, + credentialStore = credentialsStore, + remoteGateway = remote, + localGateway = FakeLocalSessionGateway(), + ) + profiles.saveProfile(remoteProfile) + profiles.setActiveProfile(remoteProfile.id) + remote.setValidationResult( + remoteProfile.id, + DomainResult.Success(SessionValidity.INVALID), + ) + + val result = coordinator.validateActiveSession() + + assertEquals(DomainResult.Success(SessionValidity.INVALID), result) + assertEquals(0, remote.loginCalls) + assertEquals(0, credentialsStore.loadCalls) + } + @Test fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest { val fixture = Fixture() @@ -189,6 +285,18 @@ class SessionCoordinatorTest { assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) } + @Test + fun endActiveSessionClearsLocalActiveProfile() = runTest { + val fixture = Fixture() + fixture.coordinator.selectProvider(localProfile) + + val result = fixture.coordinator.endActiveSession() + + assertTrue(result is DomainResult.Success) + assertNull(fixture.profiles.activeProfile()) + assertNull(fixture.local.observeSession(localProfile.id).first()) + } + private class Fixture { val profiles = FakeProviderProfileStore() val credentials = FakeCredentialStore() @@ -197,6 +305,19 @@ class SessionCoordinatorTest { val coordinator = SessionCoordinator(profiles, credentials, remote, local) } + private class RecordingSessionGateway( + private val profileStore: FakeProviderProfileStore, + private val delegate: FakeSessionGateway = FakeSessionGateway(), + ) : SessionGateway by delegate { + var activeProfileAtLogout: ProviderProfileId? = null + private set + + override suspend fun logout(profileId: ProviderProfileId): DomainResult { + activeProfileAtLogout = profileStore.activeProfile()?.id + return delegate.logout(profileId) + } + } + private class CountingSessionGateway( private val delegate: FakeSessionGateway = FakeSessionGateway(), ) : SessionGateway by delegate { @@ -214,6 +335,13 @@ class SessionCoordinatorTest { fun failLogin(profileId: ProviderProfileId, error: DomainError) { delegate.failLogin(profileId, error) } + + fun setValidationResult( + profileId: ProviderProfileId, + result: DomainResult, + ) { + delegate.setValidationResult(profileId, result) + } } private class CountingCredentialStore( -- 2.54.0 From 54eef78a46f07a38a06e1435950f731bfb61c319 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 14:03:46 +0800 Subject: [PATCH 11/40] feat: add adaptive authenticated navigation --- .../abox/manager/ui/shell/MainAppShellTest.kt | 92 +++++ .../abox/manager/ui/ABoxManagerApp.kt | 141 ++----- .../ui/navigation/AuthenticatedNavigation.kt | 391 ++++++++++++++++++ .../abox/manager/ui/navigation/Routes.kt | 6 +- .../abox/manager/ui/shell/AdaptiveLayout.kt | 53 +++ .../abox/manager/ui/shell/MainAppShell.kt | 285 +++++++------ .../flagship/abox/manager/ui/shell/MainTab.kt | 21 +- .../navigation/AuthenticatedNavigationTest.kt | 91 ++++ 8 files changed, 827 insertions(+), 253 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt new file mode 100644 index 0000000..5962902 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt @@ -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.onAllNodesWithText +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() + + @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.onAllNodesWithText("Devices")[0].assertIsDisplayed() + composeRule.onAllNodesWithText("Scenes")[0].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.onNodeWithText("ABox").performClick() + composeRule.onNodeWithText("U").performClick() + + assertEquals(1, providerClicks) + assertEquals(1, avatarClicks) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 73d42ba..ae06c1f 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -5,42 +5,31 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel 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.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme -import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfile 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.LoginViewModel -import com.flagship.abox.manager.ui.navigation.deviceDetailGraph -import com.flagship.abox.manager.ui.navigation.navigateToSocket -import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity -import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute +import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation import com.flagship.abox.manager.ui.preferences.SettingsViewModel -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.provider.ProviderSelectionRoute import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel -import com.flagship.abox.manager.ui.shell.MainAppShell +import com.flagship.abox.manager.ui.shell.AccountProviderBottomSheet @Composable fun ABoxManagerApp( @@ -54,12 +43,14 @@ fun ABoxManagerApp( val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value val actionError = actionState.errorMessage?.asString() val screenKey = sessionState.screenKey() + LaunchedEffect(screenKey) { sessionActionsViewModel.dismissError() if (sessionState !is AppSessionUiState.Login) { loginViewModel.clearSensitiveState() } } + ABoxTheme(preferences = settingsState.preferences) { ABoxSurface(modifier = Modifier.fillMaxSize()) { when (val state = sessionState) { @@ -92,6 +83,7 @@ fun ABoxManagerApp( } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AuthenticatedNavHost( username: Username, @@ -100,100 +92,25 @@ private fun AuthenticatedNavHost( errorMessage: String?, onLogout: () -> Unit, ) { - val navController = rememberNavController() - NavHost( - navController = navController, - startDestination = "home", - ) { - composable("home") { - MainAppShell( - userName = username.value, - providerName = profile.displayName, - onLogout = onLogout, - 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, + var showAccountSheet by remember { mutableStateOf(false) } + + AuthenticatedNavigation( + username = username, + profile = profile, + onLogout = onLogout, + onProviderClick = { showAccountSheet = true }, + onAvatarClick = { showAccountSheet = true }, + ) + + if (showAccountSheet) { + AccountProviderBottomSheet( + userName = username.value, + providerName = profile.displayName, + onDismissRequest = { showAccountSheet = false }, + onLogout = onLogout, + actionInProgress = actionInProgress, + errorMessage = errorMessage, ) - 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 = { - navController.navigate("infrared_settings/$encodedProfileId") - }, - 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, - ) - } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt new file mode 100644 index 0000000..b39264f --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -0,0 +1,391 @@ +package com.flagship.abox.manager.ui.navigation + +import android.net.Uri +import androidx.activity.compose.BackHandler +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.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.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.devicecontrollist.DeviceListScreen +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.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 + +/** 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.values().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 = + 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 + } +} + +@Composable +fun AuthenticatedNavigation( + username: Username, + profile: ProviderProfile, + onLogout: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, + windowSizeClass: ABoxWindowSizeClass? = null, +) { + val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp) + key(profile.id.value) { + AuthenticatedNavigationContent( + username = username, + profile = profile, + onLogout = onLogout, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + windowSizeClass = resolvedWindowSizeClass, + ) + } +} + +@Composable +private fun AuthenticatedNavigationContent( + username: Username, + profile: ProviderProfile, + onLogout: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, + windowSizeClass: ABoxWindowSizeClass, +) { + val navController = rememberNavController() + var selectedDevice by remember { mutableStateOf(null) } + val backStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = backStackEntry?.destination?.route + val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES + val selectedDetailPresentation = selectedDevice?.let { + detailPresentation(windowSizeClass, it) + } + val isExpandedDevicePane = selectedDevice != null && + selectedDetailPresentation == DetailPresentation.PANE && + currentTab == MainTab.DEVICES + + BackHandler(enabled = navController.previousBackStackEntry != null || selectedDevice != null) { + if (selectedDevice != null && selectedDetailPresentation == DetailPresentation.PANE) { + selectedDevice = null + } else { + selectedDevice = null + navController.popBackStack() + } + } + + MainAppShell( + currentTab = currentTab, + windowSizeClass = windowSizeClass, + userName = username.value, + providerName = profile.displayName, + onTabClick = { tab -> + selectedDevice = 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 + } + } + } + }, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + content = { paddingValues -> + AuthenticatedGraphContent( + navController = navController, + profile = profile, + paddingValues = paddingValues, + isExpandedDevicePane = isExpandedDevicePane, + selectedDevice = selectedDevice, + onSelectDevice = { deviceName, type -> + val selection = DeviceSelection(profile.id, deviceName, type) + selectedDevice = selection + val route = when (type) { + DeviceType.SOCKET -> DeviceDetailRoutes.socketRoute(profile.id, deviceName) + DeviceType.TEMPERATURE_HUMIDITY -> DeviceDetailRoutes.temperatureHumidityRoute(profile.id, deviceName) + DeviceType.INFRARED -> "${Routes.INFRARED_HUB}/${Uri.encode(profile.id.value)}" + } + if (detailPresentation(windowSizeClass, selection) == DetailPresentation.FULL_SCREEN) { + navController.navigate(route) + } + }, + onClearDeviceSelection = { selectedDevice = null }, + onLogout = onLogout, + ) + }, + ) +} + +@Composable +private fun AuthenticatedGraphContent( + navController: NavHostController, + profile: ProviderProfile, + paddingValues: PaddingValues, + isExpandedDevicePane: Boolean, + selectedDevice: DeviceSelection?, + onSelectDevice: (DeviceName, DeviceType) -> Unit, + onClearDeviceSelection: () -> Unit, + onLogout: () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + if (isExpandedDevicePane) { + Row(modifier = Modifier.fillMaxSize()) { + AuthenticatedNavHost( + modifier = Modifier.weight(1f), + navController = navController, + profile = profile, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onLogout = onLogout, + ) + DeviceDetailPane( + modifier = Modifier.weight(1f), + selection = requireNotNull(selectedDevice), + onBack = onClearDeviceSelection, + ) + } + } else { + AuthenticatedNavHost( + modifier = Modifier.fillMaxSize(), + navController = navController, + profile = profile, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onLogout = onLogout, + ) + } + } +} + +@Composable +private fun AuthenticatedNavHost( + modifier: Modifier, + navController: NavHostController, + profile: ProviderProfile, + onSelectDevice: (DeviceName, DeviceType) -> Unit, + onClearDeviceSelection: () -> Unit, + onLogout: () -> Unit, +) { + NavHost( + navController = navController, + startDestination = MainTab.DEVICES.route, + modifier = modifier, + ) { + composable(MainTab.DEVICES.route) { + DeviceRoot(onSelectDevice = onSelectDevice) + } + composable(MainTab.SCENES.route) { + 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) { + ActivityDestination() + } + deviceDetailGraph( + onBackClick = { + onClearDeviceSelection() + navController.popBackStack() + }, + ) + sceneEditorDestination( + onBack = navController::popBackStack, + onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) }, + ) + sceneExecutionDestination(onBack = navController::popBackStack) + composable( + route = "${Routes.INFRARED_HUB}/{profileId}", + arguments = listOf(navArgument("profileId") { type = NavType.StringType }), + ) { + InfraredHubRoute( + onBack = { + onClearDeviceSelection() + navController.popBackStack() + }, + onOpenRemote = { remoteId -> + navController.navigate( + "${Routes.INFRARED_PANEL}/${Uri.encode(profile.id.value)}/${Uri.encode(remoteId)}", + ) + }, + onOpenSettings = { + navController.navigate("${Routes.INFRARED_SETTINGS}/${Uri.encode(profile.id.value)}") + }, + onSessionInvalid = onLogout, + ) + } + composable( + route = "${Routes.INFRARED_PANEL}/{profileId}/{remoteId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("remoteId") { type = NavType.StringType }, + ), + ) { + RemotePanelRoute( + onBack = navController::popBackStack, + onSessionInvalid = onLogout, + ) + } + composable( + route = "${Routes.INFRARED_SETTINGS}/{profileId}", + arguments = listOf(navArgument("profileId") { type = NavType.StringType }), + ) { + InfraredControlRoute( + onBack = navController::popBackStack, + onSessionInvalid = onLogout, + ) + } + } +} + +@Composable +private fun DeviceRoot( + modifier: Modifier = Modifier, + onSelectDevice: (DeviceName, DeviceType) -> Unit, +) { + DeviceListScreen( + modifier = modifier, + onSocketClick = { onSelectDevice(DeviceName("Zigbee插座控制器"), DeviceType.SOCKET) }, + onTemperatureHumidityClick = { + onSelectDevice(DeviceName("温湿度传感器"), DeviceType.TEMPERATURE_HUMIDITY) + }, + onInfraredClick = { onSelectDevice(DeviceName("红外遥控器"), DeviceType.INFRARED) }, + ) +} + +@Composable +private fun DeviceDetailPane( + selection: DeviceSelection, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + when (selection.type) { + DeviceType.SOCKET -> { + val viewModel: SocketViewModel = hiltViewModel() + 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() + 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 + } +} + +@Composable +private fun ActivityDestination() { + Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { + Text( + text = androidx.compose.ui.res.stringResource(R.string.activity_placeholder_title), + style = MaterialTheme.typography.headlineMedium, + ) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt index ee3cb43..cc1c4e0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt @@ -4,7 +4,9 @@ object Routes { const val STARTUP = "startup" const val PROVIDER_SELECTION = "provider_selection" const val LOGIN = "login" - - /** Login-complete route contract; the device navigation owner replaces its placeholder. */ const val AUTHENTICATED = "authenticated" + + const val INFRARED_HUB = "infrared_hub" + const val INFRARED_PANEL = "infrared_panel" + const val INFRARED_SETTINGS = "infrared_settings" } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt new file mode 100644 index 0000000..dc65248 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt @@ -0,0 +1,53 @@ +package com.flagship.abox.manager.ui.shell + +import androidx.compose.runtime.Immutable +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, +) + +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 +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt index d32ebdc..9094683 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt @@ -1,179 +1,198 @@ package com.flagship.abox.manager.ui.shell -import androidx.compose.animation.Crossfade -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxNavigationBar import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem -import com.flagship.abox.manager.designsystem.ABoxTopPillBar -import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen +import com.flagship.abox.manager.designsystem.ABoxUserAvatar -/** - * Main authenticated application shell. - * - * Provides: - * 1. A floating/pinned pill top app bar with provider context, '+' action, and avatar account sheet. - * 2. Material 3 bottom navigation bar with pill indicator for Devices, Scenes, and Activity tabs. - * 3. Clear composable slot boundaries for team member integrations. - */ -@OptIn(ExperimentalMaterial3Api::class) +/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */ @Composable fun MainAppShell( + currentTab: MainTab, + windowSizeClass: ABoxWindowSizeClass, userName: String, providerName: String, - onLogout: () -> Unit, - modifier: Modifier = Modifier, - initialTab: MainTab = MainTab.DEVICES, - onTabSelected: (MainTab) -> Unit = {}, - onAddClick: (() -> Unit)? = null, - actionInProgress: Boolean = false, - errorMessage: String? = null, - devicesSlot: @Composable () -> Unit = { DeviceListScreen() }, - scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() }, - activitySlot: @Composable () -> Unit = { DefaultActivitySlot() }, + onTabClick: (MainTab) -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, + content: @Composable (PaddingValues) -> Unit, ) { - var activeTab by rememberSaveable { mutableStateOf(initialTab) } - var showAccountSheet by remember { mutableStateOf(false) } - val tabStateHolder = rememberSaveableStateHolder() + val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL Scaffold( - modifier = modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize(), topBar = { - Box( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - ABoxTopPillBar( - title = stringResource(R.string.top_bar_prompt_format, providerName), - userName = userName, - onAvatarClick = { showAccountSheet = true }, - onAddClick = onAddClick, - addActionContentDescription = stringResource(R.string.action_add), - avatarContentDescription = stringResource(R.string.action_account), - ) - } + ProviderTopBar( + currentTab = currentTab, + providerName = providerName, + userName = userName, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + ) }, bottomBar = { - ABoxNavigationBar { - MainTab.values().forEach { tab -> - val isSelected = (tab == activeTab) - ABoxNavigationBarItem( - selected = isSelected, - onClick = { - activeTab = tab - onTabSelected(tab) - }, - icon = { - Icon( - imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon, - contentDescription = stringResource(tab.labelRes), - ) - }, - label = { - Text(text = stringResource(tab.labelRes)) - }, - ) + if (!useRail) { + ABoxNavigationBar { + MainTab.values().forEach { tab -> + val selected = tab == currentTab + ABoxNavigationBarItem( + selected = selected, + onClick = { onTabClick(tab) }, + icon = { + Icon( + imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, + contentDescription = stringResource(tab.labelRes), + ) + }, + label = { Text(stringResource(tab.labelRes)) }, + ) + } } } }, ) { innerPadding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), + Row( + modifier = Modifier.fillMaxSize(), ) { - Crossfade( - targetState = activeTab, - label = "MainTabCrossfade", - ) { tab -> - tabStateHolder.SaveableStateProvider(tab) { - when (tab) { - MainTab.DEVICES -> devicesSlot() - MainTab.SCENES -> scenesSlot() - MainTab.ACTIVITY -> activitySlot() - } - } + if (useRail) { + MainNavigationRail( + currentTab = currentTab, + onTabClick = onTabClick, + ) + } + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + content(innerPadding) } } } - - if (showAccountSheet) { - AccountProviderBottomSheet( - userName = userName, - providerName = providerName, - onDismissRequest = { showAccountSheet = false }, - onLogout = onLogout, - actionInProgress = actionInProgress, - errorMessage = errorMessage, - ) - } } @Composable -private fun DefaultScenesSlot() { - SlotPlaceholder( - title = stringResource(R.string.scenes_placeholder_title), - description = stringResource(R.string.scenes_placeholder_description), - ) -} - -@Composable -private fun DefaultActivitySlot() { - SlotPlaceholder( - title = stringResource(R.string.activity_placeholder_title), - description = stringResource(R.string.activity_placeholder_description), - ) -} - -@Composable -private fun SlotPlaceholder( - title: String, - description: String, +private fun ProviderTopBar( + currentTab: MainTab, + providerName: String, + userName: String, + onProviderClick: () -> Unit, + onAvatarClick: () -> Unit, ) { - Column( + Box( modifier = Modifier - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), ) { - 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), - ) + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .clip(RoundedCornerShape(24.dp)) + .clickable( + onClickLabel = stringResource(R.string.account_sheet_provider), + onClick = onProviderClick, + ) + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.Bolt, + contentDescription = providerName, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(22.dp), + ) + Text( + text = providerName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(start = 8.dp), + ) + } + Text( + text = stringResource(currentTab.labelRes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp), + ) + 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.values().forEach { tab -> + val selected = tab == currentTab + NavigationRailItem( + selected = selected, + onClick = { onTabClick(tab) }, + icon = { + Icon( + imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, + contentDescription = stringResource(tab.labelRes), + ) + }, + label = { Text(stringResource(tab.labelRes)) }, + ) + } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt index 32f5cac..39b090c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt @@ -11,9 +11,7 @@ import androidx.compose.material.icons.outlined.ViewAgenda import androidx.compose.ui.graphics.vector.ImageVector import com.flagship.abox.manager.R -/** - * Main bottom navigation tabs for the authenticated application shell. - */ +/** Main destinations available while authenticated. */ enum class MainTab( @StringRes val labelRes: Int, val selectedIcon: ImageVector, @@ -24,18 +22,29 @@ enum class MainTab( labelRes = R.string.tab_devices, selectedIcon = Icons.Filled.Home, unselectedIcon = Icons.Outlined.Home, - route = "shell/devices", + route = "tab/devices", ), SCENES( labelRes = R.string.tab_scenes, selectedIcon = Icons.Filled.AutoAwesome, unselectedIcon = Icons.Outlined.AutoAwesome, - route = "shell/scenes", + route = "tab/scenes", ), ACTIVITY( labelRes = R.string.tab_activity, selectedIcon = Icons.Filled.ViewAgenda, unselectedIcon = Icons.Outlined.ViewAgenda, - route = "shell/activity", + route = "tab/activity", ), + ; + + companion object { + fun fromRoute(route: String?): MainTab? = when { + route == DEVICES.route || route?.startsWith("device/") == true || + route?.startsWith("infrared_") == true -> DEVICES + route == SCENES.route || route?.startsWith("scene/") == true -> SCENES + route == ACTIVITY.route -> ACTIVITY + else -> null + } + } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt new file mode 100644 index 0000000..4cd9137 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationTest.kt @@ -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)) + } +} -- 2.54.0 From 05d44578b97d188a5cf25b811a9f9b79b4fa9bce Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 15:22:38 +0800 Subject: [PATCH 12/40] fix: stabilize adaptive navigation state --- .../AuthenticatedNavigationRoutingTest.kt | 215 +++++++++++++++ .../abox/manager/ui/shell/MainAppShellTest.kt | 10 +- .../ui/navigation/AuthenticatedNavigation.kt | 260 +++++++++++++----- .../abox/manager/ui/shell/AdaptiveLayout.kt | 28 +- .../abox/manager/ui/shell/MainAppShell.kt | 22 +- .../flagship/abox/manager/ui/shell/MainTab.kt | 7 +- .../abox/manager/ui/shell/MainTabTest.kt | 2 +- 7 files changed, 450 insertions(+), 94 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationRoutingTest.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationRoutingTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationRoutingTest.kt new file mode 100644 index 0000000..cfc6f8b --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigationRoutingTest.kt @@ -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() + + @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")) + } + } + } + } + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt index 5962902..e00078f 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/MainAppShellTest.kt @@ -5,7 +5,7 @@ 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.onAllNodesWithText +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 @@ -38,8 +38,8 @@ class MainAppShellTest { } } - composeRule.onAllNodesWithText("Devices")[0].assertIsDisplayed() - composeRule.onAllNodesWithText("Scenes")[0].performClick() + composeRule.onNodeWithTag("main_tab_devices").assertIsDisplayed() + composeRule.onNodeWithTag("main_tab_scenes").performClick() assertEquals(MainTab.SCENES, selectedTab) } @@ -83,8 +83,8 @@ class MainAppShellTest { } } - composeRule.onNodeWithText("ABox").performClick() - composeRule.onNodeWithText("U").performClick() + composeRule.onNodeWithTag("main_provider_pill").performClick() + composeRule.onNodeWithTag("main_avatar").performClick() assertEquals(1, providerClicks) assertEquals(1, avatarClicks) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index b39264f..3ee8944 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -15,12 +15,15 @@ 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 @@ -55,12 +58,14 @@ 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.values().associateWith { listOf(it.route) }, + private val backStacks: Map> = MainTab.entries.associateWith { listOf(it.route) }, val selectedDevice: DeviceSelection? = null, ) { fun selectTab(tab: MainTab): AuthenticatedGraphState { @@ -99,65 +104,90 @@ data class AuthenticatedGraphState( } } -@Composable -fun AuthenticatedNavigation( - username: Username, - profile: ProviderProfile, - onLogout: () -> Unit, - onProviderClick: () -> Unit, - onAvatarClick: () -> Unit, - windowSizeClass: ABoxWindowSizeClass? = null, -) { - val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp) - key(profile.id.value) { - AuthenticatedNavigationContent( - username = username, - profile = profile, - onLogout = onLogout, - onProviderClick = onProviderClick, - onAvatarClick = onAvatarClick, - windowSizeClass = resolvedWindowSizeClass, - ) - } -} +data class AuthenticatedNavigationSnapshot( + val currentRoute: String?, + val selectedDevice: DeviceSelection?, + val detailPresentation: DetailPresentation?, +) +private val NullableDeviceSelectionSaver: Saver = 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 -private fun AuthenticatedNavigationContent( - username: Username, - profile: ProviderProfile, - onLogout: () -> Unit, - onProviderClick: () -> Unit, - onAvatarClick: () -> Unit, +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 navController = rememberNavController() - var selectedDevice by remember { mutableStateOf(null) } val backStackEntry by navController.currentBackStackEntryAsState() - val currentRoute = backStackEntry?.destination?.route + val currentRoute = backStackEntry?.actualRoute() + val routeSelection = selectionForRoute(currentRoute)?.takeIf { it.profileId == profileId } + var savedSelection by rememberSaveable( + profileId.value, + stateSaver = NullableDeviceSelectionSaver, + ) { + mutableStateOf(null) + } val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES - val selectedDetailPresentation = selectedDevice?.let { + val selectedDevice = when { + routeSelection != null -> routeSelection + currentRoute == MainTab.DEVICES.route -> savedSelection?.takeIf { it.profileId == profileId } + else -> null + } + val selectedPresentation = selectedDevice?.let { detailPresentation(windowSizeClass, it) } - val isExpandedDevicePane = selectedDevice != null && - selectedDetailPresentation == DetailPresentation.PANE && - currentTab == MainTab.DEVICES - BackHandler(enabled = navController.previousBackStackEntry != null || selectedDevice != null) { - if (selectedDevice != null && selectedDetailPresentation == DetailPresentation.PANE) { - selectedDevice = null - } else { - selectedDevice = null - navController.popBackStack() + LaunchedEffect(profileId, currentRoute, selectedDevice, windowSizeClass) { + if (savedSelection?.profileId != null && savedSelection?.profileId != profileId) { + savedSelection = null + return@LaunchedEffect + } + + val selection = selectedDevice ?: return@LaunchedEffect + if (savedSelection != selection) { + savedSelection = selection + } + + when (detailPresentation(windowSizeClass, selection)) { + DetailPresentation.PANE -> { + 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 + } + } + } } } - MainAppShell( - currentTab = currentTab, - windowSizeClass = windowSizeClass, - userName = username.value, - providerName = profile.displayName, - onTabClick = { tab -> - selectedDevice = 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 { @@ -170,32 +200,73 @@ private fun AuthenticatedNavigationContent( } } }, - onProviderClick = onProviderClick, - onAvatarClick = onAvatarClick, - content = { paddingValues -> - AuthenticatedGraphContent( - navController = navController, - profile = profile, - paddingValues = paddingValues, - isExpandedDevicePane = isExpandedDevicePane, - selectedDevice = selectedDevice, - onSelectDevice = { deviceName, type -> - val selection = DeviceSelection(profile.id, deviceName, type) - selectedDevice = selection - val route = when (type) { - DeviceType.SOCKET -> DeviceDetailRoutes.socketRoute(profile.id, deviceName) - DeviceType.TEMPERATURE_HUMIDITY -> DeviceDetailRoutes.temperatureHumidityRoute(profile.id, deviceName) - DeviceType.INFRARED -> "${Routes.INFRARED_HUB}/${Uri.encode(profile.id.value)}" - } - if (detailPresentation(windowSizeClass, selection) == DetailPresentation.FULL_SCREEN) { - navController.navigate(route) - } - }, - onClearDeviceSelection = { selectedDevice = null }, - onLogout = onLogout, - ) + { deviceName, type -> + savedSelection = DeviceSelection(profileId, deviceName, type) }, + { savedSelection = null }, ) + + BackHandler(enabled = selectedDevice != null || navController.previousBackStackEntry != null) { + savedSelection = null + if (currentRoute != MainTab.DEVICES.route) { + navController.popBackStack() + } + } +} + +@Composable +fun AuthenticatedNavigation( + username: Username, + profile: ProviderProfile, + onLogout: () -> Unit, + onProviderClick: () -> Unit, + onAvatarClick: () -> 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.INFRARED_HUB}/${Uri.encode(profile.id.value)}" + } + }, + selectionForRoute = { route -> profile.selectionForRoute(route) }, + ) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection -> + val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES + MainAppShell( + currentTab = currentTab, + windowSizeClass = resolvedWindowSizeClass, + userName = username.value, + providerName = profile.displayName, + onTabClick = onTabClick, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + content = { paddingValues -> + AuthenticatedGraphContent( + navController = navController, + profile = profile, + paddingValues = paddingValues, + isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && + snapshot.currentRoute == MainTab.DEVICES.route, + selectedDevice = snapshot.selectedDevice, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, + onLogout = onLogout, + ) + }, + ) + } + } } @Composable @@ -214,7 +285,7 @@ private fun AuthenticatedGraphContent( .fillMaxSize() .padding(paddingValues), ) { - if (isExpandedDevicePane) { + if (isExpandedDevicePane && selectedDevice != null) { Row(modifier = Modifier.fillMaxSize()) { AuthenticatedNavHost( modifier = Modifier.weight(1f), @@ -226,7 +297,7 @@ private fun AuthenticatedGraphContent( ) DeviceDetailPane( modifier = Modifier.weight(1f), - selection = requireNotNull(selectedDevice), + selection = selectedDevice, onBack = onClearDeviceSelection, ) } @@ -347,9 +418,10 @@ private fun DeviceDetailPane( 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() + val viewModel: SocketViewModel = hiltViewModel(key = viewModelKey) val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(selection) { viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName)) @@ -362,7 +434,7 @@ private fun DeviceDetailPane( ) } DeviceType.TEMPERATURE_HUMIDITY -> { - val viewModel: TemperatureHumidityViewModel = hiltViewModel() + val viewModel: TemperatureHumidityViewModel = hiltViewModel(key = viewModelKey) val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(selection) { viewModel.onEvent( @@ -389,3 +461,39 @@ private fun ActivityDestination() { ) } } + +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 >= 2 && parts[0] == Routes.INFRARED_HUB -> DeviceSelection( + profileId = id, + deviceName = DeviceName("红外遥控器"), + 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 +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt index dc65248..a8be65d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AdaptiveLayout.kt @@ -1,6 +1,7 @@ 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 @@ -35,7 +36,32 @@ 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 = 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, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt index 9094683..744939c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt @@ -26,6 +26,7 @@ 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.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -63,11 +64,12 @@ fun MainAppShell( bottomBar = { if (!useRail) { ABoxNavigationBar { - MainTab.values().forEach { tab -> + MainTab.entries.forEach { tab -> val selected = tab == currentTab ABoxNavigationBarItem( selected = selected, onClick = { onTabClick(tab) }, + modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"), icon = { Icon( imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, @@ -127,6 +129,7 @@ private fun ProviderTopBar( Row( modifier = Modifier .clip(RoundedCornerShape(24.dp)) + .testTag("main_provider_pill") .clickable( onClickLabel = stringResource(R.string.account_sheet_provider), onClick = onProviderClick, @@ -159,12 +162,14 @@ private fun ProviderTopBar( .weight(1f) .padding(horizontal = 8.dp), ) - ABoxUserAvatar( - name = userName, - onClick = onAvatarClick, - size = 36.dp, - contentDescription = stringResource(R.string.action_account), - ) + Box(modifier = Modifier.testTag("main_avatar")) { + ABoxUserAvatar( + name = userName, + onClick = onAvatarClick, + size = 36.dp, + contentDescription = stringResource(R.string.action_account), + ) + } } } } @@ -180,11 +185,12 @@ private fun MainNavigationRail( containerColor = MaterialTheme.colorScheme.surface, ) { Spacer(modifier = Modifier.height(8.dp)) - MainTab.values().forEach { tab -> + MainTab.entries.forEach { tab -> val selected = tab == currentTab NavigationRailItem( selected = selected, onClick = { onTabClick(tab) }, + modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"), icon = { Icon( imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt index 39b090c..e7c2946 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainTab.kt @@ -40,9 +40,10 @@ enum class MainTab( companion object { fun fromRoute(route: String?): MainTab? = when { - route == DEVICES.route || route?.startsWith("device/") == true || - route?.startsWith("infrared_") == true -> DEVICES - route == SCENES.route || route?.startsWith("scene/") == true -> SCENES + 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 } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt index b65fdf6..a723da4 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/shell/MainTabTest.kt @@ -9,7 +9,7 @@ class MainTabTest { @Test fun `all main tabs have distinct non-empty routes and valid label resources`() { - val tabs = MainTab.values() + val tabs = MainTab.entries assertEquals(3, tabs.size) val routes = tabs.map { it.route } -- 2.54.0 From 6e78b71d3128289331a73cd10bedafc25fb9e802 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:04:57 +0800 Subject: [PATCH 13/40] feat: redesign account and provider dialog --- .../ui/shell/AccountProviderDialogTest.kt | 73 +++++++ .../abox/manager/ui/ABoxManagerApp.kt | 60 ++++-- .../ui/navigation/AuthenticatedNavigation.kt | 10 + .../abox/manager/ui/navigation/Routes.kt | 1 + .../ui/shell/AccountProviderBottomSheet.kt | 183 ---------------- .../manager/ui/shell/AccountProviderDialog.kt | 198 ++++++++++++++++++ 6 files changed, 321 insertions(+), 204 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialogTest.kt delete mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialogTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialogTest.kt new file mode 100644 index 0000000..c1581de --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialogTest.kt @@ -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() + + @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) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index ae06c1f..2c8fd64 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -24,12 +24,14 @@ import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation +import com.flagship.abox.manager.ui.preferences.SettingsRoute import com.flagship.abox.manager.ui.preferences.SettingsViewModel import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel -import com.flagship.abox.manager.ui.shell.AccountProviderBottomSheet +import com.flagship.abox.manager.ui.shell.AccountProviderDialog +import com.flagship.abox.manager.ui.session.SessionUiAction @Composable fun ABoxManagerApp( @@ -74,42 +76,58 @@ fun ABoxManagerApp( is AppSessionUiState.Authenticated -> AuthenticatedNavHost( username = state.username, profile = state.profile, - actionInProgress = actionState.inProgress, - errorMessage = actionError, onLogout = sessionActionsViewModel::logout, + onSessionAction = sessionActionsViewModel::dispatch, ) } } } } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun AuthenticatedNavHost( username: Username, profile: ProviderProfile, - actionInProgress: Boolean, - errorMessage: String?, onLogout: () -> Unit, + onSessionAction: (SessionUiAction) -> Unit, ) { - var showAccountSheet by remember { mutableStateOf(false) } + var showAccountDialog by remember { mutableStateOf(false) } + var showSettings by remember { mutableStateOf(false) } - AuthenticatedNavigation( - username = username, - profile = profile, - onLogout = onLogout, - onProviderClick = { showAccountSheet = true }, - onAvatarClick = { showAccountSheet = true }, - ) + if (showSettings) { + BackHandler { showSettings = false } + SettingsRoute() + } else { + AuthenticatedNavigation( + username = username, + profile = profile, + onLogout = onLogout, + onProviderClick = { showAccountDialog = true }, + onAvatarClick = { showAccountDialog = true }, + ) + } - if (showAccountSheet) { - AccountProviderBottomSheet( + if (showAccountDialog) { + AccountProviderDialog( userName = username.value, providerName = profile.displayName, - onDismissRequest = { showAccountSheet = false }, - onLogout = onLogout, - actionInProgress = actionInProgress, - errorMessage = errorMessage, + onDismissRequest = { showAccountDialog = false }, + onOpenSettings = { + showAccountDialog = false + showSettings = true + }, + onSwitchProvider = { + showAccountDialog = false + onSessionAction(SessionUiAction.SwitchProvider) + }, + onEndSession = { + showAccountDialog = false + onSessionAction(SessionUiAction.EndSession) + }, + onForgetCredentials = { + showAccountDialog = false + onSessionAction(SessionUiAction.ForgetSavedCredentials) + }, ) } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index 3ee8944..3b5abb9 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -42,6 +42,7 @@ 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.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 @@ -221,6 +222,7 @@ fun AuthenticatedNavigation( onLogout: () -> Unit, onProviderClick: () -> Unit, onAvatarClick: () -> Unit, + onOpenSettings: () -> Unit = {}, windowSizeClass: ABoxWindowSizeClass? = null, ) { val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp) @@ -262,6 +264,7 @@ fun AuthenticatedNavigation( onSelectDevice = onSelectDevice, onClearDeviceSelection = onClearDeviceSelection, onLogout = onLogout, + onOpenSettings = onOpenSettings, ) }, ) @@ -279,6 +282,7 @@ private fun AuthenticatedGraphContent( onSelectDevice: (DeviceName, DeviceType) -> Unit, onClearDeviceSelection: () -> Unit, onLogout: () -> Unit, + onOpenSettings: () -> Unit, ) { Box( modifier = Modifier @@ -294,6 +298,7 @@ private fun AuthenticatedGraphContent( onSelectDevice = onSelectDevice, onClearDeviceSelection = onClearDeviceSelection, onLogout = onLogout, + onOpenSettings = onOpenSettings, ) DeviceDetailPane( modifier = Modifier.weight(1f), @@ -309,6 +314,7 @@ private fun AuthenticatedGraphContent( onSelectDevice = onSelectDevice, onClearDeviceSelection = onClearDeviceSelection, onLogout = onLogout, + onOpenSettings = onOpenSettings, ) } } @@ -322,6 +328,7 @@ private fun AuthenticatedNavHost( onSelectDevice: (DeviceName, DeviceType) -> Unit, onClearDeviceSelection: () -> Unit, onLogout: () -> Unit, + onOpenSettings: () -> Unit, ) { NavHost( navController = navController, @@ -342,6 +349,9 @@ private fun AuthenticatedNavHost( composable(MainTab.ACTIVITY.route) { ActivityDestination() } + composable(Routes.SETTINGS) { + SettingsRoute() + } deviceDetailGraph( onBackClick = { onClearDeviceSelection() diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt index cc1c4e0..c7b2adb 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt @@ -5,6 +5,7 @@ object Routes { const val PROVIDER_SELECTION = "provider_selection" const val LOGIN = "login" const val AUTHENTICATED = "authenticated" + const val SETTINGS = "settings" const val INFRARED_HUB = "infrared_hub" const val INFRARED_PANEL = "infrared_panel" diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt deleted file mode 100644 index 106c288..0000000 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderBottomSheet.kt +++ /dev/null @@ -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, - ) - } - } - } -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt new file mode 100644 index 0000000..c418397 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt @@ -0,0 +1,198 @@ +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CloudDone +import androidx.compose.material.icons.outlined.Logout +import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.unit.dp +import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxIconButton +import com.flagship.abox.manager.designsystem.ABoxUserAvatar +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey + +private enum class DestructiveAction { + END_SESSION, + FORGET_CREDENTIALS, +} + +@Composable +fun AccountProviderDialog( + userName: String, + providerName: String, + onDismissRequest: () -> Unit, + onOpenSettings: () -> Unit, + onSwitchProvider: () -> Unit, + onEndSession: () -> Unit, + onForgetCredentials: () -> Unit, + modifier: Modifier = Modifier, +) { + var confirmation by remember { mutableStateOf(null) } + + if (confirmation == null) { + AlertDialog( + onDismissRequest = onDismissRequest, + modifier = modifier.testTag("account_dialog"), + title = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("账户与提供方") + ABoxIconButton( + key = ABoxIconKey.CLOSE, + contentDescription = "关闭账户对话框", + onClick = onDismissRequest, + modifier = Modifier.testTag("account_dialog_close"), + ) + } + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + ABoxUserAvatar(name = userName, onClick = {}, size = 64.dp) + Text( + text = userName.ifBlank { "当前账户" }, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = providerName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Outlined.CloudDone, + contentDescription = "连接方式", + modifier = Modifier.size(24.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text("当前连接方式", style = MaterialTheme.typography.labelMedium) + Text(providerName, style = MaterialTheme.typography.bodyLarge) + } + TextButton( + onClick = onSwitchProvider, + modifier = Modifier.testTag("account_dialog_switch_provider"), + ) { + Icon(Icons.Outlined.Refresh, "切换提供方") + Text("切换") + } + } + } + AccountActionButton( + icon = Icons.Outlined.Settings, + label = "界面设置", + description = "调整主题、渲染器和交互偏好", + tag = "account_dialog_open_settings", + onClick = onOpenSettings, + ) + AccountActionButton( + icon = Icons.Outlined.Logout, + label = "退出当前会话", + description = "结束当前提供方会话", + tag = "account_dialog_end_session", + onClick = { confirmation = DestructiveAction.END_SESSION }, + ) + TextButton( + onClick = { confirmation = DestructiveAction.FORGET_CREDENTIALS }, + modifier = Modifier.fillMaxWidth().testTag("account_dialog_forget_credentials"), + ) { + Text("清除保存的凭据", color = MaterialTheme.colorScheme.error) + } + } + }, + confirmButton = {}, + ) + } else { + val action = confirmation ?: return + val isForget = action == DestructiveAction.FORGET_CREDENTIALS + AlertDialog( + onDismissRequest = { confirmation = null }, + title = { Text(if (isForget) "清除保存的凭据" else "退出当前会话") }, + text = { + Text( + if (isForget) { + "确定清除当前提供方保存的登录凭据吗?" + } else { + "确定退出当前会话吗?" + }, + ) + }, + confirmButton = { + ABoxButton( + onClick = { + confirmation = null + if (isForget) onForgetCredentials() else onEndSession() + }, + modifier = Modifier.testTag("account_dialog_confirm"), + ) { + Text("确定") + } + }, + dismissButton = { + TextButton(onClick = { confirmation = null }) { Text("取消") } + }, + ) + } +} + +@Composable +private fun AccountActionButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + label: String, + description: String, + tag: String, + onClick: () -> Unit, +) { + TextButton( + onClick = onClick, + modifier = Modifier.fillMaxWidth().testTag(tag), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon(icon, contentDescription = label) + Column(modifier = Modifier.weight(1f)) { + Text(label) + Text(description, style = MaterialTheme.typography.bodySmall) + } + } + } +} -- 2.54.0 From e14b4cee27de6b776d82d91f8d79f7b67eece7fd Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:11:36 +0800 Subject: [PATCH 14/40] feat: add profile scoped device catalog --- android/app/build.gradle.kts | 1 + .../abox/manager/di/FoundationModule.kt | 22 ------- .../abox/manager/di/DeviceGatewayModule.kt | 16 +++++ android/core/devicecatalog/build.gradle.kts | 46 ++++++++++++++ .../RoomConfiguredDeviceCatalogTest.kt | 57 +++++++++++++++++ .../devicecatalog/RoutingDeviceCatalog.kt | 27 ++++++++ .../devicecatalog/data/ConfiguredDeviceDao.kt | 19 ++++++ .../data/ConfiguredDeviceEntity.kt | 16 +++++ .../data/ConfiguredDeviceRepository.kt | 61 +++++++++++++++++++ .../data/DeviceCatalogDatabase.kt | 9 +++ .../devicecatalog/di/DeviceCatalogModule.kt | 31 ++++++++++ settings.gradle.kts | 1 + 12 files changed, 284 insertions(+), 22 deletions(-) delete mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt create mode 100644 android/core/devicecatalog/build.gradle.kts create mode 100644 android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt create mode 100644 android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt create mode 100644 android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt create mode 100644 android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt create mode 100644 android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt create mode 100644 android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt create mode 100644 android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9fce481..a278a2c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -67,6 +67,7 @@ dependencies { implementation(project(":domain")) implementation(project(":android:core:designsystem")) implementation(project(":android:core:preferences")) + implementation(project(":android:core:devicecatalog")) implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) implementation(project(":absdk-bridge")) diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt deleted file mode 100644 index cc805b8..0000000 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/FoundationModule.kt +++ /dev/null @@ -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() -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt index 88ee898..c1e027b 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/DeviceGatewayModule.kt @@ -2,12 +2,16 @@ package com.flagship.abox.manager.di import com.flagship.abox.manager.absdk.bridge.AbsdkBridge import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.DeviceCatalog import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.TemperatureHumidityGateway import com.flagship.abox.manager.simulation.SimulationInfraredGateway import com.flagship.abox.manager.simulation.SimulationSocketGateway import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway +import com.flagship.abox.manager.simulation.SimulationDeviceCatalog +import com.flagship.abox.manager.devicecatalog.RoutingDeviceCatalog +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -22,6 +26,18 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) object DeviceGatewayModule { + @Provides + @Singleton + fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog() + + @Provides + @Singleton + fun provideDeviceCatalog( + profileStore: ProviderProfileStore, + configured: RoomConfiguredDeviceRepository, + simulation: SimulationDeviceCatalog, + ): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation) + @Provides @Singleton fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway() diff --git a/android/core/devicecatalog/build.gradle.kts b/android/core/devicecatalog/build.gradle.kts new file mode 100644 index 0000000..d59a566 --- /dev/null +++ b/android/core/devicecatalog/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.flagship.abox.manager.devicecatalog" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } +} + +kapt { + correctErrorTypes = true + arguments { arg("room.schemaLocation", "$projectDir/schemas") } +} + +dependencies { + api(project(":domain")) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + kapt(libs.room.compiler) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.kotlinx.coroutines.core) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.room.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt b/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt new file mode 100644 index 0000000..f78936f --- /dev/null +++ b/android/core/devicecatalog/src/androidTest/kotlin/com/flagship/abox/manager/devicecatalog/RoomConfiguredDeviceCatalogTest.kt @@ -0,0 +1,57 @@ +package com.flagship.abox.manager.devicecatalog + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository +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 kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomConfiguredDeviceCatalogTest { + private lateinit var database: DeviceCatalogDatabase + private lateinit var repository: RoomConfiguredDeviceRepository + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), DeviceCatalogDatabase::class.java).build() + repository = RoomConfiguredDeviceRepository(database.configuredDeviceDao()) + } + + @After + fun tearDown() = database.close() + + @Test + fun configuredDevicesAreIsolatedByProfile() = runTest { + repository.add(ProviderProfileId("one"), DeviceName("plug"), DeviceType.SOCKET) + val result = repository.observeDevices(ProviderProfileId("two")).first() as DomainResult.Success + assertTrue(result.value.isEmpty()) + } + + @Test + fun sameProfileRejectsDuplicateDeviceNameAcrossTypes() = runTest { + val profile = ProviderProfileId("one") + assertTrue(repository.add(profile, DeviceName("same"), DeviceType.SOCKET) is DomainResult.Success) + assertTrue(repository.add(profile, DeviceName("same"), DeviceType.INFRARED) is DomainResult.Failure) + } + + @Test + fun removeEmitsUpdatedCatalog() = runTest { + val profile = ProviderProfileId("one") + repository.add(profile, DeviceName("plug"), DeviceType.SOCKET) + repository.remove(profile, DeviceName("plug")) + val result = repository.observeDevices(profile).first() as DomainResult.Success + assertEquals(emptyList(), result.value) + } +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt new file mode 100644 index 0000000..30b050c --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/RoutingDeviceCatalog.kt @@ -0,0 +1,27 @@ +package com.flagship.abox.manager.devicecatalog + +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +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.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.flow + +class RoutingDeviceCatalog( + private val profileStore: ProviderProfileStore, + private val abox: DeviceCatalog, + private val local: DeviceCatalog, +) : DeviceCatalog { + override fun observeDevices(profileId: ProviderProfileId): Flow>> = flow { + val active = profileStore.activeProfile() + if (active?.id != profileId) { + emit(DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))) + return@flow + } + emitAll(if (active.type == ProviderType.ABOX) abox.observeDevices(profileId) else local.observeDevices(profileId)) + } +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt new file mode 100644 index 0000000..ffe44d0 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt @@ -0,0 +1,19 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface ConfiguredDeviceDao { + @Query("SELECT * FROM configured_devices WHERE profileId = :profileId ORDER BY deviceName") + fun observe(profileId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.ABORT) + suspend fun insert(device: ConfiguredDeviceEntity) + + @Query("DELETE FROM configured_devices WHERE profileId = :profileId AND deviceName = :deviceName") + suspend fun remove(profileId: String, deviceName: String): Int +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt new file mode 100644 index 0000000..d823784 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceEntity.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +@Entity( + tableName = "configured_devices", + indices = [Index(value = ["profileId", "deviceName"], unique = true)], +) +data class ConfiguredDeviceEntity( + @PrimaryKey val id: String, + val profileId: String, + val deviceName: String, + val deviceType: String, +) diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt new file mode 100644 index 0000000..c29616f --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt @@ -0,0 +1,61 @@ +package com.flagship.abox.manager.devicecatalog.data + +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.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface ConfiguredDeviceRepository { + fun observe(profileId: ProviderProfileId): Flow>> + suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult + suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult +} + +class RoomConfiguredDeviceRepository( + private val dao: ConfiguredDeviceDao, +) : ConfiguredDeviceRepository, DeviceCatalog { + override fun observe(profileId: ProviderProfileId): Flow>> = + dao.observe(profileId.value).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) } + + override fun observeDevices(profileId: ProviderProfileId): Flow>> = observe(profileId) + + override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult { + if (profileId.value.isBlank() || name.value.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Profile and device name are required")) + } + val descriptor = DeviceDescriptor(DeviceId("${profileId.value}:${name.value}"), profileId, name, type) + return try { + dao.insert(descriptor.toEntity()) + DomainResult.Success(descriptor) + } catch (error: Exception) { + DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name")) + } + } + + override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult = try { + dao.remove(profileId.value, deviceName.value) + DomainResult.Success(Unit) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = error.message ?: "Unable to remove device")) + } +} + +private fun ConfiguredDeviceEntity.toDomain() = DeviceDescriptor( + id = DeviceId(id), + profileId = ProviderProfileId(profileId), + name = DeviceName(deviceName), + type = DeviceType.valueOf(deviceType), +) + +private fun DeviceDescriptor.toEntity() = ConfiguredDeviceEntity( + id = id.value, + profileId = profileId.value, + deviceName = name.value, + deviceType = type.name, +) diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt new file mode 100644 index 0000000..fde611b --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/DeviceCatalogDatabase.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.devicecatalog.data + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [ConfiguredDeviceEntity::class], version = 1, exportSchema = false) +abstract class DeviceCatalogDatabase : RoomDatabase() { + abstract fun configuredDeviceDao(): ConfiguredDeviceDao +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt new file mode 100644 index 0000000..447f4b7 --- /dev/null +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/di/DeviceCatalogModule.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.devicecatalog.di + +import android.content.Context +import androidx.room.Room +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase +import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository +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 DeviceCatalogModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): DeviceCatalogDatabase = + Room.databaseBuilder(context, DeviceCatalogDatabase::class.java, "abox_devices.db").build() + + @Provides + @Singleton + fun provideRepository(database: DeviceCatalogDatabase): RoomConfiguredDeviceRepository = + RoomConfiguredDeviceRepository(database.configuredDeviceDao()) + + @Provides + @Singleton + fun provideConfiguredDeviceRepository(repository: RoomConfiguredDeviceRepository): ConfiguredDeviceRepository = repository +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 8ed9912..b31f7a6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -32,6 +32,7 @@ include(":absdk-bridge") include(":testing:fakes") include(":android:core:designsystem") include(":android:core:preferences") +include(":android:core:devicecatalog") include(":android:core:session") include(":android:feature:infrared") include(":android:app") -- 2.54.0 From 876acd1e7b3dc2617cea08d7e96a6d39f8e67155 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:23:04 +0800 Subject: [PATCH 15/40] feat: replace device shortcuts with device center --- .../ui/devicecenter/DeviceCenterModels.kt | 27 +++ .../ui/devicecenter/DeviceCenterRoute.kt | 27 +++ .../ui/devicecenter/DeviceCenterScreen.kt | 166 ++++++++++++++++++ .../ui/devicecenter/DeviceCenterViewModel.kt | 81 +++++++++ .../ui/navigation/AuthenticatedNavigation.kt | 22 +-- .../devicecenter/DeviceCenterViewModelTest.kt | 75 ++++++++ 6 files changed, 381 insertions(+), 17 deletions(-) create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt new file mode 100644 index 0000000..e823aa3 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt @@ -0,0 +1,27 @@ +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 + +data class DeviceCardKey(val profileId: ProviderProfileId, val deviceName: DeviceName) +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 -> "红外控制器" + }, +) +enum class DeviceCenterContent { LOADING, CONTENT, EMPTY, ERROR } +data class DeviceCenterUiState( + val profileId: ProviderProfileId? = null, + val filter: DeviceCategoryFilter = DeviceCategoryFilter.ALL, + val cards: List = emptyList(), + val selectedCard: DeviceCardKey? = null, + val content: DeviceCenterContent = DeviceCenterContent.LOADING, +) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt new file mode 100644 index 0000000..99fbc51 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt @@ -0,0 +1,27 @@ +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, + modifier = modifier, + ) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt new file mode 100644 index 0000000..4efac83 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt @@ -0,0 +1,166 @@ +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.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.platform.testTag +import androidx.compose.ui.unit.dp +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 + +@Composable +fun DeviceCenterScreen( + state: DeviceCenterUiState, + onFilterChanged: (DeviceCategoryFilter) -> Unit, + onOpenDevice: (DeviceCardUiModel) -> Unit, + onConfigureDevice: (String, DeviceType) -> 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), + ) { + Text("设备中心", style = MaterialTheme.typography.headlineSmall) + Text("选择设备以查看状态或打开控制页面", style = MaterialTheme.typography.bodyMedium) + FilterRow(selected = state.filter, onSelected = onFilterChanged) + when (state.content) { + DeviceCenterContent.LOADING -> Text("正在加载设备", modifier = Modifier.testTag("device_center_loading")) + DeviceCenterContent.EMPTY -> ABoxEmptyState( + title = "尚未配置可管理设备", + description = "请先配置设备名称和类型", + modifier = Modifier.testTag("device_center_empty"), + action = { + Button(onClick = { showConfiguration = true }) { Text("配置设备") } + }, + ) + DeviceCenterContent.ERROR -> Text("设备列表加载失败", 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 }) { card -> + DeviceCard(card = card, onClick = { 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("配置设备") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("设备名称") }, + 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("保存") } + }, + dismissButton = { Button(onClick = onDismiss) { Text("取消") } }, + ) +} + +@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 DeviceCard(card: DeviceCardUiModel, onClick: () -> Unit) { + val icon = when (card.device.type) { + DeviceType.SOCKET -> ABoxIconKey.SOCKET + DeviceType.TEMPERATURE_HUMIDITY -> ABoxIconKey.SENSOR + DeviceType.INFRARED -> ABoxIconKey.INFRARED + } + ABoxCard( + title = card.displayName, + subtitle = card.typeLabel, + onClick = onClick, + modifier = Modifier.testTag("device_card_${card.device.id.value}"), + leadingContent = { ABoxIcon(icon, contentDescription = card.typeLabel) }, + ) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt new file mode 100644 index 0000000..5d24ece --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt @@ -0,0 +1,81 @@ +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.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository +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 dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class DeviceCenterViewModel @Inject constructor( + private val catalog: DeviceCatalog, + private val configuredRepository: ConfiguredDeviceRepository, +) : ViewModel() { + private val _uiState = MutableStateFlow(DeviceCenterUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + private var catalogJob: Job? = null + private var allCards: List = 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 { + catalog.observeDevices(profileId).collectLatest { result -> + when (result) { + is DomainResult.Success -> { + allCards = result.value.map(::toCard) + publishCards() + } + 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) } + } + + 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 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 +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index 3b5abb9..3d03c75 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -40,7 +40,7 @@ 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.devicecenter.DeviceCenterRoute import com.flagship.abox.manager.ui.scene.SceneListScreen import com.flagship.abox.manager.ui.preferences.SettingsRoute import com.flagship.abox.manager.ui.scene.SceneRoutes @@ -336,7 +336,10 @@ private fun AuthenticatedNavHost( modifier = modifier, ) { composable(MainTab.DEVICES.route) { - DeviceRoot(onSelectDevice = onSelectDevice) + DeviceCenterRoute( + profileId = profile.id, + onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) }, + ) } composable(MainTab.SCENES.route) { SceneListScreen( @@ -407,21 +410,6 @@ private fun AuthenticatedNavHost( } } -@Composable -private fun DeviceRoot( - modifier: Modifier = Modifier, - onSelectDevice: (DeviceName, DeviceType) -> Unit, -) { - DeviceListScreen( - modifier = modifier, - onSocketClick = { onSelectDevice(DeviceName("Zigbee插座控制器"), DeviceType.SOCKET) }, - onTemperatureHumidityClick = { - onSelectDevice(DeviceName("温湿度传感器"), DeviceType.TEMPERATURE_HUMIDITY) - }, - onInfraredClick = { onSelectDevice(DeviceName("红外遥控器"), DeviceType.INFRARED) }, - ) -} - @Composable private fun DeviceDetailPane( selection: DeviceSelection, diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt new file mode 100644 index 0000000..c13b47f --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt @@ -0,0 +1,75 @@ +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.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 }) + } + + private fun descriptor(profile: ProviderProfileId, name: String, type: DeviceType) = + DeviceDescriptor(DeviceId("${profile.value}:$name"), profile, DeviceName(name), type) + + private class TestCatalog(initial: List) : DeviceCatalog { + val devices = initial.toMutableList() + private val state = MutableStateFlow>>(DomainResult.Success(initial)) + override fun observeDevices(profileId: ProviderProfileId): Flow>> = state + fun emit(value: List) { devices.clear(); devices.addAll(value); state.value = DomainResult.Success(value) } + } + + private class TestConfiguredRepository : ConfiguredDeviceRepository { + override fun observe(profileId: ProviderProfileId): Flow>> = + MutableStateFlow(DomainResult.Success(emptyList())) + override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult = + DomainResult.Failure(DomainError.InvalidRequest()) + override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult = + DomainResult.Success(Unit) + } +} -- 2.54.0 From a30ab9e3769a994d1b3047095d582b327ff8c9b9 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:27:40 +0800 Subject: [PATCH 16/40] feat: confirm socket controls by readback --- .../abox/manager/absdk/bridge/AbsdkBridge.kt | 19 ++++++++++++ .../manager/di/ProviderRoutingGateways.kt | 9 ++++++ .../abox/manager/ui/socket/SocketUiState.kt | 15 +++++++++- .../abox/manager/ui/socket/SocketViewModel.kt | 30 +++++++++++-------- .../manager/ui/socket/SocketViewModelTest.kt | 26 ++++++++++++++++ .../flagship/abox/manager/domain/Gateways.kt | 9 ++++++ .../simulation/SimulationDeviceGateways.kt | 9 ++++++ .../testing/fakes/FakeDeviceGateways.kt | 17 +++++++++++ 8 files changed, 121 insertions(+), 13 deletions(-) diff --git a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt index 9c26d29..324d2d5 100644 --- a/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt +++ b/absdk-bridge/src/main/kotlin/com/flagship/abox/manager/absdk/bridge/AbsdkBridge.kt @@ -133,6 +133,25 @@ class AbsdkBridge internal constructor( } } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = serialized { + withActiveSession(profileId) { + when (val control = call { + client.setSocketPower(deviceName.value, powerState.toSdkStatus()) + }) { + is ClientCall.Failed -> DomainResult.Failure(control.error) + is ClientCall.Completed -> if (control.response.isSuccess()) { + DomainResult.Success(Unit) + } else { + DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response)) + } + } + } + } + override suspend fun getReading( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt index b5576c0..b612ae5 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/ProviderRoutingGateways.kt @@ -62,6 +62,15 @@ internal class RoutingSocketGateway( is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName) } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val provider = resolver.resolve(profileId)) { + is DomainResult.Failure -> provider + is DomainResult.Success -> delegate(provider.value).commandPower(profileId, deviceName, powerState) + } + override suspend fun setPower( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt index 2c47d33..fbb53cf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt @@ -14,14 +14,27 @@ data class SocketUiState( val isOffline: Boolean = false, val errorMessage: UiText? = null, val lastConfirmedTimeMillis: Long? = null, + val operation: SocketOperationState = SocketOperationState.IdleConfirmed, ) { 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 get() = powerState == SocketPowerState.ON } +sealed interface SocketOperationState { + data object IdleConfirmed : SocketOperationState + data class PendingCommand(val target: SocketPowerState) : SocketOperationState + data class RefreshingConfirmation(val target: SocketPowerState) : SocketOperationState + data class AwaitingConfirmation(val target: SocketPowerState) : SocketOperationState + data class Failed(val target: SocketPowerState) : SocketOperationState + data object Unavailable : SocketOperationState +} + sealed interface SocketUiEvent { data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt index 15364cc..264828e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt @@ -56,6 +56,7 @@ class SocketViewModel @Inject constructor( it.copy( profileId = event.profileId, deviceName = event.deviceName, + operation = SocketOperationState.Unavailable, ) } loadStatus() @@ -115,6 +116,7 @@ class SocketViewModel @Inject constructor( isOffline = false, errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(), + operation = SocketOperationState.IdleConfirmed, ) } } @@ -126,6 +128,7 @@ class SocketViewModel @Inject constructor( isLoading = false, isOffline = isOffline, errorMessage = DomainErrorMapper.map(result.error), + operation = SocketOperationState.Unavailable, ) } } @@ -139,25 +142,27 @@ class SocketViewModel @Inject constructor( val deviceName = current.deviceName ?: return // 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 } viewModelScope.launch { - _state.update { it.copy(isOperating = true, errorMessage = null) } - when (val result = socketGateway.setPower(profileId, deviceName, targetState)) { + _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) } + when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) { is DomainResult.Success -> { - _state.update { - it.copy( - powerState = result.value.powerState, - isOperating = false, - isOffline = false, - errorMessage = null, - lastConfirmedTimeMillis = System.currentTimeMillis(), - ) + _state.update { it.copy(operation = SocketOperationState.RefreshingConfirmation(targetState)) } + when (val confirmation = socketGateway.getStatus(profileId, deviceName)) { + is DomainResult.Success -> _state.update { + it.copy(powerState = confirmation.value.powerState, isOperating = false, isOffline = false, + errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(), + operation = SocketOperationState.IdleConfirmed) + } + is DomainResult.Failure -> _state.update { + it.copy(isOperating = false, errorMessage = DomainErrorMapper.map(confirmation.error), + operation = SocketOperationState.AwaitingConfirmation(targetState)) + } } } - is DomainResult.Failure -> { val isOffline = result.error is DomainError.DeviceOffline _state.update { @@ -165,6 +170,7 @@ class SocketViewModel @Inject constructor( isOperating = false, isOffline = isOffline, errorMessage = DomainErrorMapper.map(result.error), + operation = SocketOperationState.Failed(targetState), ) } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt index 9c43ba8..9aa680f 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt @@ -157,6 +157,32 @@ class SocketViewModelTest { assertNotNull(state.errorMessage) } + @Test + fun commandSuccessReadsBackConfirmedState() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) + + assertEquals(SocketPowerState.ON, viewModel.state.value.powerState) + assertEquals(SocketOperationState.IdleConfirmed, viewModel.state.value.operation) + } + + @Test + fun readbackFailureKeepsLastConfirmedStateAndMarksAwaitingConfirmation() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Readback failed")) + + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) + + assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState) + assertEquals( + SocketOperationState.AwaitingConfirmation(SocketPowerState.ON), + viewModel.state.value.operation, + ) + } + @Test fun refresh_queriesUpdatedGatewayStatus() = runTest { val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt index 4108621..64a75aa 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Gateways.kt @@ -63,6 +63,15 @@ interface SocketGateway { deviceName: DeviceName, ): DomainResult + suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult = when (val result = setPower(profileId, deviceName, powerState)) { + is DomainResult.Success -> DomainResult.Success(Unit) + is DomainResult.Failure -> result + } + suspend fun setPower( profileId: ProviderProfileId, deviceName: DeviceName, diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt index c45d265..9daedf5 100644 --- a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceGateways.kt @@ -33,6 +33,15 @@ class SimulationSocketGateway : SocketGateway { states[key] = powerState return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) } + + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + states[DeviceKey(profileId, deviceName)] = powerState + return DomainResult.Success(Unit) + } } class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway { diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index 60c3c21..27b6a55 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -18,6 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList class FakeSocketGateway : SocketGateway { private val states = ConcurrentHashMap() private val errors = ConcurrentHashMap() + private val commandErrors = ConcurrentHashMap() override suspend fun getStatus( profileId: ProviderProfileId, @@ -35,11 +36,27 @@ class FakeSocketGateway : SocketGateway { return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) } + override suspend fun commandPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + val key = DeviceKey(profileId, deviceName) + commandErrors[key]?.let { return DomainResult.Failure(it) } + states[key] = powerState + return DomainResult.Success(Unit) + } + fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) { val key = DeviceKey(profileId, deviceName) if (error == null) errors.remove(key) else errors[key] = error } + fun setCommandError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) { + val key = DeviceKey(profileId, deviceName) + if (error == null) commandErrors.remove(key) else commandErrors[key] = error + } + private fun resultFor( profileId: ProviderProfileId, deviceName: DeviceName, -- 2.54.0 From 9ba17c7d72847c2509a41f57245776d1dbc12eb5 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:32:12 +0800 Subject: [PATCH 17/40] feat: render partial sensor readings --- .../TemperatureHumidityScreen.kt | 34 +++++++++---------- .../TemperatureHumidityUiState.kt | 8 ++--- .../TemperatureHumidityViewModel.kt | 6 ++-- .../TemperatureHumidityViewModelTest.kt | 16 +++++++-- .../flagship/abox/manager/domain/Models.kt | 4 +-- 5 files changed, 39 insertions(+), 29 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt index 3cf5571..1c2f13b 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt @@ -40,6 +40,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.designsystem.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.DeviceName import java.text.SimpleDateFormat import java.util.Date @@ -159,17 +161,17 @@ fun TemperatureHumidityScreen( ) { TelemetryMetricCard( title = stringResource(R.string.th_temperature_label), - value = state.temperature ?: "--", + value = state.temperature, unit = stringResource(R.string.th_unit_celsius), - icon = "🌡️", + icon = ABoxIconKey.TEMPERATURE, modifier = Modifier.weight(1f), ) TelemetryMetricCard( title = stringResource(R.string.th_humidity_label), - value = state.humidity ?: "--", + value = state.humidity, unit = stringResource(R.string.th_unit_percent), - icon = "💧", + icon = ABoxIconKey.HUMIDITY, modifier = Modifier.weight(1f), ) } @@ -279,11 +281,12 @@ fun TemperatureHumidityScreen( @Composable private fun TelemetryMetricCard( title: String, - value: String, + value: String?, unit: String, - icon: String, + icon: ABoxIconKey, modifier: Modifier = Modifier, ) { + if (value == null) return Card( modifier = modifier, colors = CardDefaults.cardColors( @@ -298,10 +301,7 @@ private fun TelemetryMetricCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text( - text = icon, - style = MaterialTheme.typography.headlineMedium, - ) + ABoxIcon(icon, contentDescription = title, modifier = Modifier.size(28.dp)) Spacer(modifier = Modifier.height(8.dp)) @@ -323,14 +323,12 @@ private fun TelemetryMetricCard( fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, ) - if (value != "--") { - Text( - text = " $unit", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 4.dp), - ) - } + Text( + text = " $unit", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt index 678a497..39e0cf8 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt @@ -16,11 +16,11 @@ data class TemperatureHumidityUiState( val errorMessage: UiText? = null, val lastUpdatedTimeMillis: Long? = null, ) { - val formattedTemperature: String - get() = if (temperature.isNullOrBlank()) "--" else "$temperature °C" + val formattedTemperature: String? + get() = temperature?.takeIf { it.isNotBlank() }?.let { "$it °C" } - val formattedHumidity: String - get() = if (humidity.isNullOrBlank()) "--" else "$humidity %" + val formattedHumidity: String? + get() = humidity?.takeIf { it.isNotBlank() }?.let { "$it %" } val hasValidData: Boolean get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank() diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt index 916fc80..bc5c8e8 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt @@ -101,11 +101,11 @@ class TemperatureHumidityViewModel @Inject constructor( when (val result = thGateway.getReading(profileId, deviceName)) { is DomainResult.Success -> { val reading = result.value - val isBlankData = reading.temperature.isBlank() && reading.humidity.isBlank() + val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank() _state.update { it.copy( - temperature = reading.temperature.takeIf { t -> t.isNotBlank() }, - humidity = reading.humidity.takeIf { h -> h.isNotBlank() }, + temperature = reading.temperature?.takeIf { t -> t.isNotBlank() }, + humidity = reading.humidity?.takeIf { h -> h.isNotBlank() }, isLoading = false, isRefreshing = false, isOffline = false, diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt index b5c2512..485df55 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt @@ -129,8 +129,8 @@ class TemperatureHumidityViewModelTest { assertTrue(state.hasNoData) assertNull(state.temperature) assertNull(state.humidity) - assertEquals("--", state.formattedTemperature) - assertEquals("--", state.formattedHumidity) + assertNull(state.formattedTemperature) + assertNull(state.formattedHumidity) assertFalse(state.hasValidData) } @@ -190,4 +190,16 @@ class TemperatureHumidityViewModelTest { viewModel.onEvent(TemperatureHumidityUiEvent.ClearError) assertNull(viewModel.state.value.errorMessage) } + + @Test + fun partialReadingKeepsAvailableMetric() = runTest { + fakeGateway.setReading( + TemperatureHumidityReading(profileId, deviceName, temperature = "26.5", humidity = null), + ) + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + + assertEquals("26.5", viewModel.state.value.temperature) + assertNull(viewModel.state.value.humidity) + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt index b1e1cc4..b3051e6 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Models.kt @@ -83,8 +83,8 @@ data class SocketStatus( data class TemperatureHumidityReading( val profileId: ProviderProfileId, val deviceName: DeviceName, - val temperature: String, - val humidity: String, + val temperature: String?, + val humidity: String?, ) data class InfraredCode( -- 2.54.0 From 171d1daa509d6160d34dfc82687c916d62c71af7 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:39:07 +0800 Subject: [PATCH 18/40] feat: unify device detail presentation --- .../abox/manager/ui/socket/SocketScreen.kt | 101 +++++------------- .../TemperatureHumidityScreen.kt | 97 +++++------------ .../manager/infrared/hub/InfraredHubScreen.kt | 18 ++-- .../infrared/panel/RemotePanelScreen.kt | 11 +- 4 files changed, 68 insertions(+), 159 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt index 62db0e1..8bd9dc5 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt @@ -47,7 +47,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold +import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.designsystem.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.SocketPowerState import java.text.SimpleDateFormat @@ -79,39 +83,10 @@ fun SocketScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - var showEditNameDialog by remember { mutableStateOf(false) } - - Scaffold( + ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { - Text( - text = state.deviceName?.value ?: stringResource(R.string.socket_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - navigationIcon = { - IconButton(onClick = onBackClick) { - 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) }, - enabled = !state.isLoading && !state.isOperating, - ) { - Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - } - }, - ) - }, + title = state.deviceName?.value ?: stringResource(R.string.socket_title), + onBack = onBackClick, ) { paddingValues -> Column( modifier = Modifier @@ -121,6 +96,17 @@ fun SocketScreen( .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + ABoxIconButton( + key = ABoxIconKey.REFRESH, + contentDescription = "刷新插座状态", + onClick = { onEvent(SocketUiEvent.Refresh) }, + enabled = !state.isLoading && !state.isOperating, + ) + } if (state.isOffline) { OfflineBanner( onRetry = { onEvent(SocketUiEvent.Refresh) }, @@ -248,41 +234,6 @@ fun SocketScreen( } } - if (showEditNameDialog) { - var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) } - AlertDialog( - onDismissRequest = { showEditNameDialog = false }, - title = { Text(stringResource(R.string.dialog_edit_device_name_title)) }, - text = { - OutlinedTextField( - value = inputName, - onValueChange = { inputName = it }, - label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - 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)) - } - }, - ) - } } @Composable @@ -310,10 +261,10 @@ private fun PowerIndicatorBadge( if (isLoading) { ABoxLoadingIndicator(modifier = Modifier.size(36.dp)) } else { - Text( - text = if (powerState == SocketPowerState.ON) "⚡" else "○", - style = MaterialTheme.typography.headlineLarge, - color = indicatorColor, + ABoxIcon( + key = if (powerState == SocketPowerState.ON) ABoxIconKey.POWER else ABoxIconKey.SOCKET, + contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭", + modifier = Modifier.size(40.dp), ) } } @@ -377,9 +328,11 @@ private fun ErrorBanner( style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f), ) - TextButton(onClick = onDismiss) { - Text("✕") - } + ABoxIconButton( + key = ABoxIconKey.CLOSE, + contentDescription = "关闭错误提示", + onClick = onDismiss, + ) } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt index 1c2f13b..67796fe 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt @@ -39,7 +39,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.DeviceName @@ -72,39 +74,10 @@ fun TemperatureHumidityScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() - var showEditNameDialog by remember { mutableStateOf(false) } - - Scaffold( + ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { - Text( - text = state.deviceName?.value ?: stringResource(R.string.th_title), - style = MaterialTheme.typography.titleLarge, - ) - }, - navigationIcon = { - IconButton(onClick = onBackClick) { - Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) - } - }, - actions = { - IconButton( - onClick = { showEditNameDialog = true }, - 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) - } - }, - ) - }, + title = state.deviceName?.value ?: stringResource(R.string.th_title), + onBack = onBackClick, ) { paddingValues -> Column( modifier = Modifier @@ -114,6 +87,17 @@ fun TemperatureHumidityScreen( .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + ABoxIconButton( + key = ABoxIconKey.REFRESH, + contentDescription = "刷新温湿度数据", + onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) }, + enabled = !state.isLoading && !state.isRefreshing, + ) + } if (state.isOffline) { OfflineBanner( onRetry = { onEvent(TemperatureHumidityUiEvent.Refresh) }, @@ -241,41 +225,6 @@ fun TemperatureHumidityScreen( } } - if (showEditNameDialog) { - var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) } - AlertDialog( - onDismissRequest = { showEditNameDialog = false }, - title = { Text(stringResource(R.string.dialog_edit_device_name_title)) }, - text = { - OutlinedTextField( - value = inputName, - onValueChange = { inputName = it }, - label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - TextButton( - 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)) - } - }, - ) - } } @Composable @@ -353,7 +302,11 @@ private fun NoDataCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text("📭", style = MaterialTheme.typography.displaySmall) + ABoxIcon( + key = ABoxIconKey.SENSOR, + contentDescription = "暂无传感器数据", + modifier = Modifier.size(40.dp), + ) Spacer(modifier = Modifier.height(12.dp)) Text( text = stringResource(R.string.th_no_data_title), @@ -432,9 +385,11 @@ private fun ErrorBanner( style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f), ) - TextButton(onClick = onDismiss) { - Text("✕") - } + ABoxIconButton( + key = ABoxIconKey.CLOSE, + contentDescription = "关闭错误提示", + onClick = onDismiss, + ) } } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index 1ee37cb..cccbbf1 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -59,6 +59,8 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.designsystem.ABoxIconButton +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary @@ -154,17 +156,15 @@ internal fun InfraredHubScreen( } }, navigationIcon = { - TextButton(onClick = onBack) { - Text(stringResource(R.string.infrared_back)) - } + ABoxIconButton(ABoxIconKey.BACK, "返回", onBack) }, actions = { - IconButton( + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = "打开红外设置", onClick = onNavigateToSettings, modifier = Modifier.testTag("hub_settings_button"), - ) { - Text("⚙️", fontSize = 20.sp) - } + ) }, ) }, @@ -199,7 +199,7 @@ internal fun InfraredHubScreen( onClick = { showCreateDialog = true }, modifier = Modifier.testTag("hub_add_remote_button"), ) { - Text("➕ " + stringResource(R.string.infrared_hub_add_remote)) + Text(stringResource(R.string.infrared_hub_add_remote)) } } @@ -278,7 +278,7 @@ private fun VirtualRemoteCard( } Text(icon, fontSize = 28.sp) IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) { - Text("🗑️", fontSize = 16.sp) + Text(stringResource(R.string.infrared_delete)) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt index 34f4dd6..cc0a73a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -60,6 +60,9 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface +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.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcStateSnapshot @@ -175,9 +178,7 @@ internal fun RemotePanelScreen( } }, navigationIcon = { - TextButton(onClick = onBack) { - Text(stringResource(R.string.infrared_back)) - } + ABoxIconButton(ABoxIconKey.BACK, "返回", onBack) }, actions = { // 非空调面板显示学习模式开关 @@ -185,7 +186,7 @@ internal fun RemotePanelScreen( FilterChip( selected = state.isLearnMode, onClick = onToggleLearnMode, - label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, + label = { Text(if (state.isLearnMode) "录制中" else "学习模式") }, colors = FilterChipDefaults.filterChipColors( selectedContainerColor = Color(0xFFFF5252), selectedLabelColor = Color.White, @@ -222,7 +223,7 @@ internal fun RemotePanelScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("🔴", fontSize = 16.sp) + ABoxIcon(ABoxIconKey.PENDING, "录制中", modifier = Modifier.size(20.dp)) Text( text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码", color = Color(0xFFC62828), -- 2.54.0 From b537cd1824f2cab7f17daa320c0f91d6c8aadacb Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:42:16 +0800 Subject: [PATCH 19/40] fix: isolate infrared slots by hardware --- .../domain/infrared/AddVirtualRemoteKey.kt | 7 +++- .../domain/infrared/LearnVirtualRemoteKey.kt | 10 +++-- .../infrared/VirtualRemoteRepository.kt | 6 +++ .../infrared/VirtualRemoteUseCaseTest.kt | 42 +++++++++++++++++++ 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt index 26cb835..9e5d46d 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt @@ -25,8 +25,11 @@ class AddVirtualRemoteKey( val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase() val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { // Find next available slot - val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty() - val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() + val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) { + is DomainResult.Success -> result.value + is DomainResult.Failure -> return result + } + val usedSlots = quota.assignedStaticSlots val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位")) nextSlot.toString() diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt index fb453a3..5b6f3e2 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt @@ -26,9 +26,13 @@ class LearnVirtualRemoteKey( ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found")) val targetSlot = binding.assignedSlot ?: run { - val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty() - val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() - val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1 + val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) { + is DomainResult.Success -> result.value + is DomainResult.Failure -> return result + } + val nextSlot = quota.availableStaticSlots.firstOrNull() ?: return DomainResult.Failure( + DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"), + ) nextSlot.toString() } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt index fb9e622..53ff586 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt @@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first interface VirtualRemoteRepository { fun observeRemotes( @@ -25,6 +26,11 @@ interface VirtualRemoteRepository { deviceName: DeviceName, ): Flow> + suspend fun slotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = observeSlotQuota(profileId, deviceName).first() + suspend fun getRemote( profileId: ProviderProfileId, remoteId: VirtualRemoteId, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt index d507536..a8bed9c 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt @@ -116,4 +116,46 @@ class VirtualRemoteUseCaseTest { val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value assertEquals(null, fetched) } + + @Test + fun staticKeyAllocationUsesSlotsAcrossRemotesOnTheSameHardware() = runTest { + val firstRemote = VirtualRemote( + id = VirtualRemoteId("remote-one"), + profileId = profileId, + deviceName = deviceName, + displayName = "First", + type = RemoteDeviceType.TV, + transmissionMode = TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) + repository.createRemote( + firstRemote, + listOf( + RemoteKeyBinding( + id = "binding-one", + remoteId = firstRemote.id, + profileId = profileId, + buttonId = "POWER", + displayName = "Power", + assignedSlot = "1", + ), + ), + ) + val secondRemote = VirtualRemote( + id = VirtualRemoteId("remote-two"), + profileId = profileId, + deviceName = deviceName, + displayName = "Second", + type = RemoteDeviceType.TV, + transmissionMode = TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 2L, + updatedAtEpochMillis = 2L, + ) + repository.createRemote(secondRemote, emptyList()) + + val result = AddVirtualRemoteKey(repository)(profileId, secondRemote.id, "Volume") + + assertEquals("2", (result as DomainResult.Success).value.assignedSlot) + } } -- 2.54.0 From 4798ae32ba642e6c93bdd37bbef39928d601d3a1 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:49:39 +0800 Subject: [PATCH 20/40] feat: preserve infrared ownership in schema --- .../infrared/data/local/InfraredDatabase.kt | 2 +- .../infrared/data/local/InfraredEntities.kt | 6 ++++++ .../infrared/data/local/InfraredMigrations.kt | 20 +++++++++++++++++++ .../data/local/RoomVirtualRemoteRepository.kt | 2 ++ .../infrared/di/InfraredFeatureModule.kt | 3 ++- .../domain/infrared/AddVirtualRemoteKey.kt | 1 + .../domain/infrared/CreateVirtualRemote.kt | 2 ++ .../domain/infrared/VirtualRemoteModels.kt | 1 + 8 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMigrations.kt diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt index af179fd..31a2ec3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt @@ -10,7 +10,7 @@ import androidx.room.RoomDatabase VirtualRemoteEntity::class, RemoteKeyBindingEntity::class, ], - version = 2, + version = 3, exportSchema = false, ) abstract class InfraredDatabase : RoomDatabase() { diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt index 73112e5..aa0e857 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt @@ -65,6 +65,11 @@ data class VirtualRemoteEntity( indices = [ Index(value = ["remoteId"]), Index(value = ["profileId"]), + Index( + value = ["profileId", "hardwareDeviceName", "assignedSlot"], + unique = true, + name = "index_remote_key_bindings_hardware_slot", + ), ], ) data class RemoteKeyBindingEntity( @@ -72,6 +77,7 @@ data class RemoteKeyBindingEntity( val id: String, val remoteId: String, val profileId: String, + val hardwareDeviceName: String, val buttonId: String, val displayName: String, val assignedSlot: String?, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMigrations.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMigrations.kt new file mode 100644 index 0000000..92e543e --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredMigrations.kt @@ -0,0 +1,20 @@ +package com.flagship.abox.manager.infrared.data.local + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +val MIGRATION_2_3 = object : Migration(2, 3) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE remote_key_bindings ADD COLUMN hardwareDeviceName TEXT NOT NULL DEFAULT ''", + ) + db.execSQL( + "UPDATE remote_key_bindings SET hardwareDeviceName = " + + "(SELECT deviceName FROM virtual_remotes WHERE virtual_remotes.id = remote_key_bindings.remoteId)", + ) + db.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_remote_key_bindings_hardware_slot " + + "ON remote_key_bindings(profileId, hardwareDeviceName, assignedSlot)", + ) + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt index 712e679..1a40b17 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt @@ -206,6 +206,7 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi id = id, remoteId = VirtualRemoteId(remoteId), profileId = ProviderProfileId(profileId), + hardwareDeviceName = DeviceName(hardwareDeviceName), buttonId = buttonId, displayName = displayName, assignedSlot = assignedSlot, @@ -219,6 +220,7 @@ private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindi id = id, remoteId = remoteId.value, profileId = profileId.value, + hardwareDeviceName = hardwareDeviceName.value, buttonId = buttonId, displayName = displayName, assignedSlot = assignedSlot, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 13d499a..00c4b6f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -20,6 +20,7 @@ import com.flagship.abox.manager.infrared.data.local.InfraredDao import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository +import com.flagship.abox.manager.infrared.data.local.MIGRATION_2_3 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -49,7 +50,7 @@ object InfraredFeatureModule { context, InfraredDatabase::class.java, "abox_infrared.db", - ).fallbackToDestructiveMigration().build() + ).addMigrations(MIGRATION_2_3).build() } @Provides diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt index 9e5d46d..95a89c2 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt @@ -39,6 +39,7 @@ class AddVirtualRemoteKey( id = UUID.randomUUID().toString(), remoteId = remoteId, profileId = profileId, + hardwareDeviceName = remote.deviceName, buttonId = buttonId, displayName = trimmed, assignedSlot = assignedSlot, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt index cc2f747..c4c333d 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt @@ -64,6 +64,7 @@ class CreateVirtualRemote( id = UUID.randomUUID().toString(), remoteId = remoteId, profileId = profileId, + hardwareDeviceName = deviceName, buttonId = template.buttonId, displayName = template.displayName, assignedSlot = (index + 1).toString(), @@ -77,6 +78,7 @@ class CreateVirtualRemote( id = UUID.randomUUID().toString(), remoteId = remoteId, profileId = profileId, + hardwareDeviceName = deviceName, buttonId = template.buttonId, displayName = template.displayName, assignedSlot = null, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt index 112a29c..4d34693 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt @@ -80,6 +80,7 @@ data class RemoteKeyBinding( val id: String, val remoteId: VirtualRemoteId, val profileId: ProviderProfileId, + val hardwareDeviceName: DeviceName = DeviceName(""), val buttonId: String, val displayName: String, val assignedSlot: String? = null, -- 2.54.0 From 26ab5095e5973124994582503bb807ea82d041de Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:55:30 +0800 Subject: [PATCH 21/40] feat: route infrared through selected device --- .../ui/navigation/AuthenticatedNavigation.kt | 25 ++++++++++++------- .../abox/manager/ui/navigation/Routes.kt | 12 +++++++-- .../infrared/hub/InfraredHubViewModel.kt | 10 +++++++- .../infrared/ui/InfraredControlViewModel.kt | 10 +++++++- 4 files changed, 44 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index 3d03c75..beb6bac 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -239,7 +239,7 @@ fun AuthenticatedNavigation( profile.id, selection.deviceName, ) - DeviceType.INFRARED -> "${Routes.INFRARED_HUB}/${Uri.encode(profile.id.value)}" + DeviceType.INFRARED -> Routes.infraredHub(profile.id.value, selection.deviceName.value) } }, selectionForRoute = { route -> profile.selectionForRoute(route) }, @@ -367,9 +367,12 @@ private fun AuthenticatedNavHost( ) sceneExecutionDestination(onBack = navController::popBackStack) composable( - route = "${Routes.INFRARED_HUB}/{profileId}", - arguments = listOf(navArgument("profileId") { type = NavType.StringType }), - ) { + route = Routes.INFRARED_HUB, + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("deviceName") { type = NavType.StringType }, + ), + ) { entry -> InfraredHubRoute( onBack = { onClearDeviceSelection() @@ -381,7 +384,8 @@ private fun AuthenticatedNavHost( ) }, onOpenSettings = { - navController.navigate("${Routes.INFRARED_SETTINGS}/${Uri.encode(profile.id.value)}") + val deviceName = entry.arguments?.getString("deviceName").orEmpty() + navController.navigate(Routes.infraredSettings(profile.id.value, deviceName)) }, onSessionInvalid = onLogout, ) @@ -399,8 +403,11 @@ private fun AuthenticatedNavHost( ) } composable( - route = "${Routes.INFRARED_SETTINGS}/{profileId}", - arguments = listOf(navArgument("profileId") { type = NavType.StringType }), + route = Routes.INFRARED_SETTINGS, + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("deviceName") { type = NavType.StringType }, + ), ) { InfraredControlRoute( onBack = navController::popBackStack, @@ -473,9 +480,9 @@ private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()), type = DeviceType.TEMPERATURE_HUMIDITY, ) - parts.size >= 2 && parts[0] == Routes.INFRARED_HUB -> DeviceSelection( + parts.size >= 3 && parts[0] == "infrared_hub" -> DeviceSelection( profileId = id, - deviceName = DeviceName("红外遥控器"), + deviceName = DeviceName(parts[2].decodeRoutePart()), type = DeviceType.INFRARED, ) else -> null diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt index c7b2adb..ea9bf10 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/Routes.kt @@ -1,5 +1,7 @@ package com.flagship.abox.manager.ui.navigation +import android.net.Uri + object Routes { const val STARTUP = "startup" const val PROVIDER_SELECTION = "provider_selection" @@ -7,7 +9,13 @@ object Routes { const val AUTHENTICATED = "authenticated" const val SETTINGS = "settings" - const val INFRARED_HUB = "infrared_hub" + const val INFRARED_HUB = "infrared_hub/{profileId}/{deviceName}" const val INFRARED_PANEL = "infrared_panel" - const val INFRARED_SETTINGS = "infrared_settings" + 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)}" } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt index 9e69efd..5952514 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -49,6 +49,9 @@ class InfraredHubViewModel @Inject constructor( val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } private val messageFlow = MutableStateFlow(null) + private val routeDeviceName: DeviceName? = savedStateHandle.get("deviceName") + ?.takeIf { it.isNotBlank() } + ?.let(::DeviceName) private data class HubSnapshot( val providerProfile: ProviderProfile? = null, @@ -68,7 +71,12 @@ class InfraredHubViewModel @Inject constructor( when (devResult) { is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error)) is DomainResult.Success -> { - val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME + val deviceName = routeDeviceName ?: devResult.value ?: return@flatMapLatest flowOf( + HubSnapshot( + providerProfile = active, + configError = DomainError.InvalidRequest(detail = "No infrared device configured"), + ), + ) combine( virtualRemoteRepository.observeRemotes(profileId, deviceName), virtualRemoteRepository.observeSlotQuota(profileId, deviceName), diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 2d494ac..30ba248 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -40,6 +40,9 @@ class InfraredControlViewModel @Inject constructor( private val deviceNameInputFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) + private val routeDeviceName: DeviceName? = savedStateHandle.get("deviceName") + ?.takeIf { it.isNotBlank() } + ?.let(::DeviceName) private data class ServerState( val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null, @@ -65,7 +68,12 @@ class InfraredControlViewModel @Inject constructor( ), ) is DomainResult.Success -> { - val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME + val deviceName = routeDeviceName ?: deviceResult.value ?: return@flatMapLatest flowOf( + ServerState( + providerProfile = active, + configError = DomainError.InvalidRequest(detail = "No infrared device configured"), + ), + ) combine( virtualRemoteRepository.observeRemotes(profileId, deviceName), virtualRemoteRepository.observeSlotQuota(profileId, deviceName), -- 2.54.0 From 176a70cf7fc3b59799171c5079214da21698a8bc Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 19:58:51 +0800 Subject: [PATCH 22/40] fix: clean up infrared learning cancellation --- .../infrared/panel/RemotePanelViewModel.kt | 33 ++++++++++++++++++- .../panel/RemotePanelViewModelTest.kt | 25 ++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt index 221dad3..6027600 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt @@ -24,6 +24,8 @@ import com.flagship.abox.manager.infrared.remote.UiText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted @@ -59,6 +61,7 @@ class RemotePanelViewModel @Inject constructor( private val localAcStateFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) private var acDebounceJob: Job? = null + private var learningJob: Job? = null private val remoteFlow = if (profileId == null || remoteId == null) { flowOf(null) @@ -128,6 +131,7 @@ class RemotePanelViewModel @Inject constructor( fun toggleLearnMode() { val newMode = !isLearnModeFlow.value isLearnModeFlow.value = newMode + if (!newMode) cancelLearning() if (newMode) { messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled)) } @@ -167,7 +171,7 @@ class RemotePanelViewModel @Inject constructor( if (state.value.learningButtonId != null) return learningButtonIdFlow.value = buttonId - viewModelScope.launch { + learningJob = viewModelScope.launch { try { when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { is DomainResult.Success -> { @@ -175,12 +179,39 @@ class RemotePanelViewModel @Inject constructor( } is DomainResult.Failure -> handleDomainError(res.error) } + } catch (cancelled: kotlinx.coroutines.CancellationException) { + val binding = state.value.bindings.firstOrNull { it.buttonId == buttonId } + if (binding != null) { + withContext(NonCancellable) { + virtualRemoteRepository.updateKeyBinding( + binding.copy( + status = com.flagship.abox.manager.domain.infrared.InfraredKeyStatus.FAILED, + lastErrorCode = null, + lastErrorDetail = "学习已取消,需要重新学习", + ), + ) + } + } + throw cancelled } finally { learningButtonIdFlow.value = null } } } + fun cancelLearning() { + learningJob?.cancel() + learningJob = null + learningButtonIdFlow.value = null + isLearnModeFlow.value = false + } + + override fun onCleared() { + cancelLearning() + acDebounceJob?.cancel() + super.onCleared() + } + fun addCustomButton(displayName: String) { val currentProfile = profileId ?: return val currentRemoteId = remoteId ?: return diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt index 8db53e1..e29023c 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt @@ -226,4 +226,29 @@ class RemotePanelViewModelTest { job.cancel() } + + @Test + fun cancellingLearningModeClearsActiveLearningState() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearnMode() + runCurrent() + vm.cancelLearning() + runCurrent() + + assertEquals(false, vm.state.value.isLearnMode) + assertEquals(null, vm.state.value.learningButtonId) + job.cancel() + } } -- 2.54.0 From 25ed255ddfd4820f02164bdfb99fdf3777cdd71d Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:03:22 +0800 Subject: [PATCH 23/40] feat: define activity event contracts --- .../flagship/abox/manager/domain/Activity.kt | 68 ++++++++++++++++ .../domain/activity/ActivityContractTest.kt | 78 +++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt new file mode 100644 index 0000000..0a5bade --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Activity.kt @@ -0,0 +1,68 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.Flow + +@JvmInline +value class ActivityEventId(val value: String) + +enum class ActivityEventKind { + SESSION_SIGNED_IN, + SESSION_SIGNED_OUT, + SOCKET_COMMAND, + SOCKET_CONFIRMATION, + SENSOR_REFRESH, + INFRARED_SEND, + INFRARED_LEARN, + SCENE_EXECUTION, + SCENE_ACTION, +} + +enum class ActivitySource { USER, AUTOMATION, SYSTEM } + +enum class ActivityOutcome { + SUCCEEDED, + FAILED, + CANCELLED, + AWAITING_CONFIRMATION, +} + +enum class ActivityTargetType { PROFILE, DEVICE, SCENE, SCENE_ACTION, REMOTE, INFRARED_KEY } + +data class ActivityTarget( + val type: ActivityTargetType, + val stableId: String, + val displayName: String, +) + +data class ActivityEvent( + val id: ActivityEventId, + val profileId: ProviderProfileId, + val occurredAtEpochMillis: Long, + val kind: ActivityEventKind, + val source: ActivitySource, + val outcome: ActivityOutcome, + val target: ActivityTarget, + val errorCode: String? = null, +) + +data class ActivityEventCursor( + val occurredAtEpochMillis: Long, + val id: ActivityEventId, +) : Comparable { + override fun compareTo(other: ActivityEventCursor): Int { + val timeComparison = occurredAtEpochMillis.compareTo(other.occurredAtEpochMillis) + return if (timeComparison != 0) timeComparison else id.value.compareTo(other.id.value) + } +} + +interface ActivityRepository { + fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> + + suspend fun loadBefore( + profileId: ProviderProfileId, + before: ActivityEventCursor, + limit: Int, + ): DomainResult> + + suspend fun append(event: ActivityEvent): DomainResult +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt new file mode 100644 index 0000000..cf3a713 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/activity/ActivityContractTest.kt @@ -0,0 +1,78 @@ +package com.flagship.abox.manager.domain.activity + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +import com.flagship.abox.manager.domain.ActivityEventId +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivityRepository +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.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ActivityContractTest { + @Test + fun eventDoesNotContainCredentialsOrPayloadFields() { + val event = ActivityEvent( + id = ActivityEventId("event-1"), + profileId = ProviderProfileId("profile-1"), + occurredAtEpochMillis = 10L, + kind = ActivityEventKind.SOCKET_COMMAND, + source = ActivitySource.USER, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.DEVICE, "device-1", "客厅插座"), + errorCode = "20504", + ) + + val fields = ActivityEvent::class.java.declaredFields.map { it.name } + assertFalse(fields.any { it.contains("password", ignoreCase = true) }) + assertFalse(fields.any { it.contains("token", ignoreCase = true) }) + assertFalse(fields.any { it.contains("payload", ignoreCase = true) }) + assertTrue(event.errorCode == "20504") + } + + @Test + fun cursorOrdersEqualTimestampsById() { + val first = ActivityEventCursor(10L, ActivityEventId("a")) + val second = ActivityEventCursor(10L, ActivityEventId("b")) + + assertTrue(first < second) + } + + @Test + fun recordingFailureDoesNotAlterOriginalDomainResult() = runTest { + val repository = object : ActivityRepository { + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = + MutableStateFlow(DomainResult.Success(emptyList())) + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = + DomainResult.Success(emptyList()) + override suspend fun append(event: ActivityEvent) = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.Unknown()) + } + val original: DomainResult = DomainResult.Success("command-complete") + + val recorded = repository.append(testEvent()) + + assertTrue(recorded is DomainResult.Failure) + assertTrue(original is DomainResult.Success) + assertTrue((original as DomainResult.Success).value == "command-complete") + } + + private fun testEvent() = ActivityEvent( + id = ActivityEventId("event-1"), + profileId = ProviderProfileId("profile-1"), + occurredAtEpochMillis = 10L, + kind = ActivityEventKind.SESSION_SIGNED_IN, + source = ActivitySource.SYSTEM, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.PROFILE, "profile-1", "账户"), + ) +} -- 2.54.0 From 867096d7ef903618a2bc8339e64833e1c001cc35 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:08:37 +0800 Subject: [PATCH 24/40] feat: add automation persistence foundation --- android/app/build.gradle.kts | 1 + android/feature/automation/build.gradle.kts | 43 ++++++++++ .../automation/AutomationDatabaseTest.kt | 83 +++++++++++++++++++ .../manager/automation/data/AutomationDao.kt | 26 ++++++ .../automation/data/AutomationDatabase.kt | 9 ++ .../automation/data/AutomationEntities.kt | 33 ++++++++ .../automation/data/RoomActivityRepository.kt | 58 +++++++++++++ .../data/RoomAutomationRuntimeRepository.kt | 29 +++++++ .../manager/automation/di/AutomationModule.kt | 32 +++++++ settings.gradle.kts | 1 + 10 files changed, 315 insertions(+) create mode 100644 android/feature/automation/build.gradle.kts create mode 100644 android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index a278a2c..13dab95 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -70,6 +70,7 @@ dependencies { implementation(project(":android:core:devicecatalog")) implementation(project(":android:core:session")) implementation(project(":android:feature:infrared")) + implementation(project(":android:feature:automation")) implementation(project(":absdk-bridge")) implementation(project(":runtime:simulation")) debugImplementation(project(":testing:fakes")) diff --git a/android/feature/automation/build.gradle.kts b/android/feature/automation/build.gradle.kts new file mode 100644 index 0000000..925aff8 --- /dev/null +++ b/android/feature/automation/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.kapt) + alias(libs.plugins.hilt.android) +} + +android { + namespace = "com.flagship.abox.manager.automation" + compileSdk = 37 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } +} + +kotlin { + compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) } +} + +kapt { + correctErrorTypes = true +} + +dependencies { + api(project(":domain")) + implementation(libs.room.runtime) + implementation(libs.room.ktx) + kapt(libs.room.compiler) + implementation(libs.hilt.android) + kapt(libs.hilt.compiler) + implementation(libs.kotlinx.coroutines.core) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.test.runner) + androidTestImplementation(libs.room.testing) + androidTestImplementation(libs.kotlinx.coroutines.test) +} diff --git a/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt b/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt new file mode 100644 index 0000000..56d8e81 --- /dev/null +++ b/android/feature/automation/src/androidTest/kotlin/com/flagship/abox/manager/automation/AutomationDatabaseTest.kt @@ -0,0 +1,83 @@ +package com.flagship.abox.manager.automation + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.automation.data.ActivityEventEntity +import com.flagship.abox.manager.automation.data.AutomationDatabase +import com.flagship.abox.manager.automation.data.RoomActivityRepository +import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +import com.flagship.abox.manager.domain.ActivityEventId +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.ProviderProfileId +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneTriggerId +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AutomationDatabaseTest { + private lateinit var database: AutomationDatabase + private lateinit var activityRepository: RoomActivityRepository + private lateinit var runtimeRepository: RoomAutomationRuntimeRepository + + @Before + fun setUp() { + database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AutomationDatabase::class.java).build() + activityRepository = RoomActivityRepository(database.automationDao()) + runtimeRepository = RoomAutomationRuntimeRepository(database.automationDao()) + } + + @After + fun tearDown() = database.close() + + @Test + fun activityEventsAreIsolatedByProfileAndPageByCursor() = runTest { + activityRepository.append(event("profile-a", "a", 10L)) + activityRepository.append(event("profile-a", "b", 10L)) + activityRepository.append(event("profile-b", "c", 10L)) + + val first = activityRepository.observeLatest(ProviderProfileId("profile-a"), 1).first() + val firstEvent = (first as DomainResult.Success).value.single() + val next = activityRepository.loadBefore( + ProviderProfileId("profile-a"), + ActivityEventCursor(firstEvent.occurredAtEpochMillis, firstEvent.id), + 1, + ) + + assertEquals("b", firstEvent.id.value) + assertEquals(listOf("a"), (next as DomainResult.Success).value.map { it.id.value }) + } + + @Test + fun occurrenceClaimIsAtomic() = runTest { + val first = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence") + val second = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence") + + assertTrue((first as DomainResult.Success).value) + assertEquals(false, (second as DomainResult.Success).value) + } + + private fun event(profile: String, id: String, time: Long) = ActivityEvent( + id = ActivityEventId(id), + profileId = ProviderProfileId(profile), + occurredAtEpochMillis = time, + kind = ActivityEventKind.SOCKET_COMMAND, + source = ActivitySource.USER, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.DEVICE, id, "设备"), + ) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt new file mode 100644 index 0000000..6fd478b --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDao.kt @@ -0,0 +1,26 @@ +package com.flagship.abox.manager.automation.data + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import kotlinx.coroutines.flow.Flow + +@Dao +interface AutomationDao { + @Query("SELECT * FROM activity_events WHERE profileId = :profileId ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit") + fun observeLatest(profileId: String, limit: Int): Flow> + + @Query( + "SELECT * FROM activity_events WHERE profileId = :profileId AND " + + "(occurredAtEpochMillis < :beforeTime OR (occurredAtEpochMillis = :beforeTime AND id < :beforeId)) " + + "ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit", + ) + suspend fun loadBefore(profileId: String, beforeTime: Long, beforeId: String, limit: Int): List + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertEvent(event: ActivityEventEntity): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt new file mode 100644 index 0000000..3a8a677 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationDatabase.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.automation.data + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false) +abstract class AutomationDatabase : RoomDatabase() { + abstract fun automationDao(): AutomationDao +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt new file mode 100644 index 0000000..735ab7d --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/AutomationEntities.kt @@ -0,0 +1,33 @@ +package com.flagship.abox.manager.automation.data + +import androidx.room.Entity +import androidx.room.Index + +@Entity( + tableName = "activity_events", + primaryKeys = ["profileId", "id"], + indices = [Index(value = ["profileId", "occurredAtEpochMillis", "id"])], +) +data class ActivityEventEntity( + val profileId: String, + val id: String, + val occurredAtEpochMillis: Long, + val kind: String, + val source: String, + val outcome: String, + val targetType: String, + val targetStableId: String, + val targetDisplayName: String, + val errorCode: String?, +) + +@Entity( + tableName = "automation_occurrences", + primaryKeys = ["profileId", "sceneId", "triggerId", "occurrenceKey"], +) +data class AutomationOccurrenceEntity( + val profileId: String, + val sceneId: String, + val triggerId: String, + val occurrenceKey: String, +) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt new file mode 100644 index 0000000..5e30a10 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt @@ -0,0 +1,58 @@ +package com.flagship.abox.manager.automation.data + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +import com.flagship.abox.manager.domain.ActivityEventId +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivityRepository +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.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +class RoomActivityRepository(private val dao: AutomationDao) : ActivityRepository { + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = + dao.observeLatest(profileId.value, limit.coerceAtLeast(0)).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) } + + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int): DomainResult> = try { + DomainResult.Success(dao.loadBefore(profileId.value, before.occurredAtEpochMillis, before.id.value, limit.coerceAtLeast(0)).map { it.toDomain() }) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable")) + } + + override suspend fun append(event: ActivityEvent): DomainResult = try { + dao.insertEvent(event.toEntity()) + DomainResult.Success(Unit) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable")) + } +} + +private fun ActivityEventEntity.toDomain() = ActivityEvent( + id = ActivityEventId(id), + profileId = ProviderProfileId(profileId), + occurredAtEpochMillis = occurredAtEpochMillis, + kind = ActivityEventKind.valueOf(kind), + source = ActivitySource.valueOf(source), + outcome = ActivityOutcome.valueOf(outcome), + target = ActivityTarget(ActivityTargetType.valueOf(targetType), targetStableId, targetDisplayName), + errorCode = errorCode, +) + +private fun ActivityEvent.toEntity() = ActivityEventEntity( + profileId = profileId.value, + id = id.value, + occurredAtEpochMillis = occurredAtEpochMillis, + kind = kind.name, + source = source.name, + outcome = outcome.name, + targetType = target.type.name, + targetStableId = target.stableId, + targetDisplayName = target.displayName, + errorCode = errorCode, +) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt new file mode 100644 index 0000000..0506e92 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomAutomationRuntimeRepository.kt @@ -0,0 +1,29 @@ +package com.flagship.abox.manager.automation.data + +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.SceneId +import com.flagship.abox.manager.domain.SceneTriggerId + +data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean) + +interface AutomationRuntimeRepository { + suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult +} + +class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository { + override suspend fun claimOccurrence( + profileId: ProviderProfileId, + sceneId: SceneId, + triggerId: SceneTriggerId, + occurrenceKey: String, + ): DomainResult = try { + val inserted = dao.claimOccurrence( + AutomationOccurrenceEntity(profileId.value, sceneId.value, triggerId.value, occurrenceKey), + ) + DomainResult.Success(inserted != -1L) + } catch (error: Exception) { + DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable")) + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt new file mode 100644 index 0000000..7932c36 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt @@ -0,0 +1,32 @@ +package com.flagship.abox.manager.automation.di + +import android.content.Context +import androidx.room.Room +import com.flagship.abox.manager.automation.data.AutomationDatabase +import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository +import com.flagship.abox.manager.automation.data.RoomActivityRepository +import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository +import com.flagship.abox.manager.domain.ActivityRepository +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 AutomationModule { + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase = + Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build() + + @Provides + @Singleton + fun provideActivityRepository(database: AutomationDatabase): ActivityRepository = RoomActivityRepository(database.automationDao()) + + @Provides + @Singleton + fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao()) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index b31f7a6..a5f925f 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,4 +35,5 @@ include(":android:core:preferences") include(":android:core:devicecatalog") include(":android:core:session") include(":android:feature:infrared") +include(":android:feature:automation") include(":android:app") -- 2.54.0 From ca4c34f21c840a914d49b118301414acfcd48471 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:13:11 +0800 Subject: [PATCH 25/40] feat: add observable scene execution foundation --- android/app/src/main/AndroidManifest.xml | 1 + android/feature/automation/build.gradle.kts | 1 + .../manager/automation/di/AutomationModule.kt | 7 +++ .../AndroidNotificationGateway.kt | 44 +++++++++++++++++++ .../manager/domain/NotificationGateway.kt | 9 ++++ .../abox/manager/domain/SceneExecution.kt | 14 ++++++ .../abox/manager/domain/SceneExecutor.kt | 23 ++++++++++ .../manager/domain/SceneExecutionFlowTest.kt | 35 +++++++++++++++ 8 files changed, 134 insertions(+) create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5b9189a..5cda260 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ + { + if (android.os.Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + } + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT), + ) + return try { + NotificationManagerCompat.from(context).notify( + profileId.value.hashCode(), + NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(message) + .setAutoCancel(true) + .build(), + ) + DomainResult.Success(Unit) + } catch (_: SecurityException) { + DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) + } + } + + private companion object { const val CHANNEL_ID = "automation_events" } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt new file mode 100644 index 0000000..5bef2b1 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/NotificationGateway.kt @@ -0,0 +1,9 @@ +package com.flagship.abox.manager.domain + +interface NotificationGateway { + suspend fun show( + profileId: ProviderProfileId, + title: String, + message: String, + ): DomainResult +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt index f6267ed..2ed7aa9 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -1,5 +1,19 @@ package com.flagship.abox.manager.domain +@JvmInline +value class SceneExecutionId(val value: String) + +enum class SceneExecutionSource { USER, AUTOMATION } + +sealed interface SceneExecutionUpdate { + data class Started(val executionId: SceneExecutionId, val scene: Scene) : SceneExecutionUpdate + data class StepPending(val action: SceneAction) : SceneExecutionUpdate + data class StepRunning(val action: SceneAction) : SceneExecutionUpdate + data class StepFinished(val result: SceneActionExecutionResult) : SceneExecutionUpdate + data class Completed(val result: SceneExecutionResult) : SceneExecutionUpdate + data class Rejected(val error: DomainError) : SceneExecutionUpdate +} + enum class SceneActionOutcome { SUCCESS, FAILURE, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt index 71432ce..73dbe2e 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt @@ -1,5 +1,9 @@ package com.flagship.abox.manager.domain +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.util.UUID + /** * Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent * actions; callers receive a [SceneExecutionResult] that summarizes every step. @@ -9,4 +13,23 @@ package com.flagship.abox.manager.domain */ interface SceneExecutor { suspend fun execute(scene: Scene): DomainResult + + fun execute( + scene: Scene, + executionId: SceneExecutionId = SceneExecutionId(UUID.randomUUID().toString()), + source: SceneExecutionSource = SceneExecutionSource.USER, + ): Flow = flow { + emit(SceneExecutionUpdate.Started(executionId, scene)) + when (val result = execute(scene)) { + is DomainResult.Failure -> emit(SceneExecutionUpdate.Rejected(result.error)) + is DomainResult.Success -> { + result.value.results.forEach { actionResult -> + emit(SceneExecutionUpdate.StepPending(actionResult.action)) + emit(SceneExecutionUpdate.StepRunning(actionResult.action)) + emit(SceneExecutionUpdate.StepFinished(actionResult)) + } + emit(SceneExecutionUpdate.Completed(result.value)) + } + } + } } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt new file mode 100644 index 0000000..31cc8c2 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/SceneExecutionFlowTest.kt @@ -0,0 +1,35 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Test + +class SceneExecutionFlowTest { + @Test + fun observableExecutionPublishesLifecycleEventsWithoutChangingLegacyResult() = runTest { + val scene = Scene( + id = SceneId("scene"), + profileId = ProviderProfileId("profile"), + name = "测试情景", + ) + val executor = DefaultSceneExecutor( + socketGateway = object : SocketGateway { + override suspend fun getStatus(profileId: ProviderProfileId, deviceName: DeviceName) = + DomainResult.Success(SocketStatus(profileId, deviceName, SocketPowerState.OFF)) + override suspend fun setPower(profileId: ProviderProfileId, deviceName: DeviceName, powerState: SocketPowerState) = + DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + }, + infraredGateway = object : InfraredGateway { + override suspend fun sendKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit) + override suspend fun learnKey(profileId: ProviderProfileId, deviceName: DeviceName, key: String) = DomainResult.Success(Unit) + override suspend fun downloadCodes(profileId: ProviderProfileId, codes: List) = DomainResult.Success(emptyList()) + }, + ) + + val updates = executor.execute(scene, SceneExecutionId("run-1")).toList() + + assertTrue(updates.first() is SceneExecutionUpdate.Started) + assertTrue(updates.last() is SceneExecutionUpdate.Completed) + } +} -- 2.54.0 From 713106df146bbf52d4dece19af569a8bc09375d1 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:15:54 +0800 Subject: [PATCH 26/40] feat: stream scene execution progress --- .../ui/scene/SceneExecutionViewModel.kt | 22 +++++++-- .../manager/domain/DefaultSceneExecutor.kt | 46 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt index 8bc1c00..200b14d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -8,6 +8,9 @@ import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneExecutionResult import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneExecutionId +import com.flagship.abox.manager.domain.SceneExecutionSource +import com.flagship.abox.manager.domain.SceneExecutionUpdate import com.flagship.abox.manager.domain.SceneRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject @@ -15,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collect sealed interface SceneExecutionUiState { data object Loading : SceneExecutionUiState @@ -71,11 +75,19 @@ class SceneExecutionViewModel @Inject constructor( _uiState.value = SceneExecutionUiState.Failed("Active profile mismatch") return@launch } - when (val result = sceneExecutor.execute(scene)) { - is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value) - is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed( - result.error.detail ?: "Execution failed", - ) + sceneExecutor.execute( + scene = scene, + executionId = SceneExecutionId("manual-${scene.id.value}"), + source = SceneExecutionSource.USER, + ).collect { update -> + when (update) { + is SceneExecutionUpdate.Completed -> _uiState.value = SceneExecutionUiState.Completed(update.result) + is SceneExecutionUpdate.Rejected -> _uiState.value = SceneExecutionUiState.Failed( + update.error.detail ?: "Execution failed", + ) + is SceneExecutionUpdate.Started -> _uiState.value = SceneExecutionUiState.Running(update.scene) + else -> Unit + } } } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt index d0111c9..fe01285 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow /** * Production implementation of [SceneExecutor]. @@ -24,6 +26,50 @@ class DefaultSceneExecutor( ) : SceneExecutor { private val runningScenes = ConcurrentHashMap() + override fun execute( + scene: Scene, + executionId: SceneExecutionId, + source: SceneExecutionSource, + ): Flow = flow { + val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null + if (alreadyRunning) { + emit(SceneExecutionUpdate.Rejected(DomainError.OperationFailed(detail = "Scene is already running"))) + return@flow + } + val startTime = System.currentTimeMillis() + try { + emit(SceneExecutionUpdate.Started(executionId, scene)) + val results = mutableListOf() + var aborted = false + for (action in scene.actions) { + if (aborted) { + val skipped = SceneActionExecutionResult(action, SceneActionOutcome.SKIPPED, null) + results += skipped + emit(SceneExecutionUpdate.StepFinished(skipped)) + continue + } + emit(SceneExecutionUpdate.StepPending(action)) + emit(SceneExecutionUpdate.StepRunning(action)) + val started = System.currentTimeMillis() + val result = executeAction(scene.profileId, action).copy( + executionDurationMillis = System.currentTimeMillis() - started, + ) + results += result + emit(SceneExecutionUpdate.StepFinished(result)) + if (result.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + aborted = true + } + } + emit( + SceneExecutionUpdate.Completed( + SceneExecutionResult(scene, results, System.currentTimeMillis() - startTime), + ), + ) + } finally { + runningScenes.remove(scene.id) + } + } + override suspend fun execute(scene: Scene): DomainResult { val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null if (alreadyRunning) { -- 2.54.0 From cce0441476b889d6654d2a584f0b596785390a17 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:20:44 +0800 Subject: [PATCH 27/40] feat: evaluate automation conditions safely --- .../abox/manager/domain/Automation.kt | 151 ++++++++++++++++++ .../com/flagship/abox/manager/domain/Scene.kt | 2 +- .../domain/automation/SceneEvaluatorTest.kt | 80 ++++++++++ 3 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt new file mode 100644 index 0000000..941bd73 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Automation.kt @@ -0,0 +1,151 @@ +package com.flagship.abox.manager.domain + +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +interface AutomationClock { + fun nowEpochMillis(): Long +} + +data class AutomationSnapshot( + val observedAtEpochMillis: Long, + val readings: Map> = emptyMap(), + val socketStatuses: Map> = emptyMap(), +) + +sealed interface TriggerEvaluation { + data class Matched(val occurrenceKey: String) : TriggerEvaluation + data object NotMatched : TriggerEvaluation + data class Unavailable(val error: DomainError) : TriggerEvaluation +} + +sealed interface ConditionsEvaluation { + data object Satisfied : ConditionsEvaluation + data class NotSatisfied(val condition: SceneCondition) : ConditionsEvaluation + data class Unavailable(val error: DomainError) : ConditionsEvaluation +} + +class SceneTriggerEvaluator { + fun evaluate(scene: Scene, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation { + if (scene.triggers.isEmpty()) return TriggerEvaluation.NotMatched + val matched = scene.triggers.firstNotNullOfOrNull { trigger -> evaluateOne(trigger, snapshot, now) } + return when (matched) { + is TriggerEvaluation.Matched -> matched + is TriggerEvaluation.Unavailable -> matched + is TriggerEvaluation.NotMatched -> matched + null -> TriggerEvaluation.NotMatched + } + } + + private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) { + is SceneTrigger.Time -> { + val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time")) + val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek + if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) { + TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}") + } else null + } + is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) { + it.temperature + } + is SceneTrigger.Humidity -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdPercent) { + it.humidity + } + is SceneTrigger.DeviceState -> { + when (val result = snapshot.socketStatuses[trigger.deviceName]) { + null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable")) + is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error) + is DomainResult.Success -> if (result.value.powerState == trigger.expectedState) { + TriggerEvaluation.Matched("${snapshot.observedAtEpochMillis}:${trigger.id.value}") + } else null + } + } + } + + private fun threshold( + result: DomainResult?, + operator: ComparisonOperator, + threshold: Double, + value: (TemperatureHumidityReading) -> String?, + ): TriggerEvaluation? { + if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold")) + return when (result) { + null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error) + is DomainResult.Success -> { + val actual = value(result.value)?.toDoubleOrNull() + ?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null + } + } + } + + private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) { + ComparisonOperator.GREATER_THAN -> actual > threshold + ComparisonOperator.LESS_THAN -> actual < threshold + ComparisonOperator.EQUALS -> actual == threshold + ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold + ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + } + + private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull() +} + +class SceneConditionEvaluator { + fun evaluate(conditions: List, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation { + for (condition in conditions) { + when (val result = evaluateOne(condition, snapshot, now)) { + ConditionsEvaluation.Satisfied -> Unit + else -> return result + } + } + return ConditionsEvaluation.Satisfied + } + + private fun evaluateOne(condition: SceneCondition, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation = when (condition) { + is SceneCondition.Temperature -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdCelsius) { it.temperature } + is SceneCondition.Humidity -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdPercent) { it.humidity } + is SceneCondition.SocketPower -> when (val result = snapshot.socketStatuses[condition.deviceName]) { + null -> ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable")) + is DomainResult.Failure -> ConditionsEvaluation.Unavailable(result.error) + is DomainResult.Success -> if (result.value.powerState == condition.expectedState) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + is SceneCondition.TimeRange -> { + val start = parseTime(condition.startTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range start")) + val end = parseTime(condition.endTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range end")) + val current = now.toLocalTime() + val inside = if (start <= end) current in start..end else current >= start || current <= end + if (inside) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + } + + private fun metricCondition( + condition: SceneCondition, + result: DomainResult?, + operator: ComparisonOperator, + threshold: Double, + value: (TemperatureHumidityReading) -> String?, + ): ConditionsEvaluation { + if (!threshold.isFinite()) return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold")) + val actual = when (result) { + null -> return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + is DomainResult.Failure -> return ConditionsEvaluation.Unavailable(result.error) + is DomainResult.Success -> value(result.value)?.toDoubleOrNull() + ?: return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable")) + } + return if (compare(actual, operator, threshold)) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition) + } + + private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) { + ComparisonOperator.GREATER_THAN -> actual > threshold + ComparisonOperator.LESS_THAN -> actual < threshold + ComparisonOperator.EQUALS -> actual == threshold + ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold + ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold + } + + private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull() +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt index aa97602..7a71edf 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -62,7 +62,7 @@ sealed interface SceneTrigger { data class DeviceState( override val id: SceneTriggerId, val deviceName: DeviceName, - val expectedState: String, // e.g. "ON" / "OFF" + val expectedState: SocketPowerState, val label: String = "", ) : SceneTrigger } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt new file mode 100644 index 0000000..8980977 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/automation/SceneEvaluatorTest.kt @@ -0,0 +1,80 @@ +package com.flagship.abox.manager.domain.automation + +import com.flagship.abox.manager.domain.AutomationSnapshot +import com.flagship.abox.manager.domain.ComparisonOperator +import com.flagship.abox.manager.domain.ConditionsEvaluation +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneCondition +import com.flagship.abox.manager.domain.SceneConditionEvaluator +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerEvaluator +import com.flagship.abox.manager.domain.SceneTriggerId +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import com.flagship.abox.manager.domain.TriggerEvaluation +import java.time.LocalDateTime +import org.junit.Assert.assertTrue +import org.junit.Test + +class SceneEvaluatorTest { + private val profile = ProviderProfileId("profile") + private val sensor = DeviceName("sensor") + private val clockTime = LocalDateTime.of(2026, 8, 26, 7, 30) + private val triggerEvaluator = SceneTriggerEvaluator() + private val conditionEvaluator = SceneConditionEvaluator() + + @Test + fun timeMatchesConfiguredWeekday() { + val trigger = SceneTrigger.Time(SceneTriggerId("time"), "07:30", setOf(3)) + val result = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime) + assertTrue(result is TriggerEvaluation.Matched) + } + + @Test + fun timeRangeCrossesMidnight() { + val condition = SceneCondition.TimeRange(com.flagship.abox.manager.domain.SceneConditionId("range"), "22:00", "07:00") + val result = conditionEvaluator.evaluate(listOf(condition), AutomationSnapshot(1L), LocalDateTime.of(2026, 8, 26, 6, 45)) + assertTrue(result is ConditionsEvaluation.Satisfied) + } + + @Test + fun temperatureComparisonAndMissingReadingFailClosed() { + val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0) + val missing = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime) + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", null))), + ) + val matched = triggerEvaluator.evaluate(scene(trigger), reading, clockTime) + assertTrue(missing is TriggerEvaluation.Unavailable) + assertTrue(matched is TriggerEvaluation.Matched) + } + + @Test + fun allConditionsMustPass() { + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))), + ) + val conditions = listOf( + SceneCondition.Temperature(com.flagship.abox.manager.domain.SceneConditionId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0), + SceneCondition.Humidity(com.flagship.abox.manager.domain.SceneConditionId("humidity"), sensor, ComparisonOperator.LESS_THAN, 50.0), + ) + assertTrue(conditionEvaluator.evaluate(conditions, reading, clockTime) is ConditionsEvaluation.Satisfied) + } + + @Test + fun invalidNumberReturnsUnavailable() { + val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, Double.NaN) + val reading = AutomationSnapshot( + 1L, + readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))), + ) + assertTrue(triggerEvaluator.evaluate(scene(trigger), reading, clockTime) is TriggerEvaluation.Unavailable) + } + + private fun scene(trigger: SceneTrigger) = Scene(SceneId("scene"), profile, "测试", triggers = listOf(trigger)) +} -- 2.54.0 From fd67820c63ffafac2f74b8962a6a119f6bab0926 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:26:59 +0800 Subject: [PATCH 28/40] feat: add fail closed automation evaluation --- .../manager/ui/scene/SceneEditorViewModel.kt | 6 ++- .../runtime/AutomationBootstrapper.kt | 16 ++++++ .../runtime/AutomationPollWorker.kt | 10 ++++ .../automation/runtime/AutomationScheduler.kt | 53 +++++++++++++++++++ .../WorkManagerAutomationScheduleStore.kt | 31 +++++++++++ 5 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index e38d4ee..02619dd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -191,11 +191,13 @@ class SceneEditorViewModel @Inject constructor( } fun addDeviceStateTrigger(deviceName: String, expectedState: String) { + val parsedState = runCatching { SocketPowerState.valueOf(expectedState.trim().uppercase()) }.getOrNull() + ?: return val trigger = SceneTrigger.DeviceState( id = SceneTriggerId(UUID.randomUUID().toString()), deviceName = DeviceName(deviceName), - expectedState = expectedState, - label = "$deviceName 状态变为 $expectedState", + expectedState = parsedState, + label = "$deviceName 状态变为 ${parsedState.name}", ) _state.update { it.copy(triggers = it.triggers + trigger) } } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt new file mode 100644 index 0000000..be5b730 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt @@ -0,0 +1,16 @@ +package com.flagship.abox.manager.automation.runtime + +import com.flagship.abox.manager.domain.ProviderProfileStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +class AutomationBootstrapper( + private val profileStore: ProviderProfileStore, + private val scheduler: AutomationScheduler, + private val scope: CoroutineScope, +) { + fun start() { + scope.launch { profileStore.observeActiveProfile().collectLatest { scheduler.reconcileActiveProfile() } } + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt new file mode 100644 index 0000000..6478dd9 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationPollWorker.kt @@ -0,0 +1,10 @@ +package com.flagship.abox.manager.automation.runtime + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters + +class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { + override suspend fun doWork(): Result = + if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success() +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt new file mode 100644 index 0000000..b8fd478 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationScheduler.kt @@ -0,0 +1,53 @@ +package com.flagship.abox.manager.automation.runtime + +import com.flagship.abox.manager.domain.AutomationType +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.ProviderProfileStore +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SessionGateway +import com.flagship.abox.manager.domain.SessionValidity +import kotlinx.coroutines.flow.first + +interface AutomationScheduler { + suspend fun reconcileActiveProfile(): DomainResult + suspend fun reconcile(profileId: ProviderProfileId): DomainResult + suspend fun cancel(profileId: ProviderProfileId): DomainResult +} + +interface AutomationScheduleStore { + suspend fun replace(profileId: ProviderProfileId, scenes: List): DomainResult + suspend fun remove(profileId: ProviderProfileId): DomainResult +} + +class ProfileAutomationScheduler( + private val profileStore: ProviderProfileStore, + private val sessionGateway: SessionGateway, + private val sceneRepository: SceneRepository, + private val scheduleStore: AutomationScheduleStore, +) : AutomationScheduler { + override suspend fun reconcileActiveProfile(): DomainResult { + val active = profileStore.activeProfile() ?: return DomainResult.Success(Unit) + return reconcile(active.id) + } + + override suspend fun reconcile(profileId: ProviderProfileId): DomainResult { + val active = profileStore.activeProfile() + if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")) + return when (val validity = sessionGateway.validateSession(profileId)) { + is DomainResult.Success -> if (validity.value == SessionValidity.VALID) { + when (val result = sceneRepository.observeScenes(profileId).first()) { + is DomainResult.Failure -> result + is DomainResult.Success -> scheduleStore.replace(profileId, result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC }) + } + } else { + scheduleStore.remove(profileId) + } + is DomainResult.Failure -> scheduleStore.remove(profileId) + } + } + + override suspend fun cancel(profileId: ProviderProfileId): DomainResult = scheduleStore.remove(profileId) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt new file mode 100644 index 0000000..4fd9cd9 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.automation.runtime + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import java.util.concurrent.TimeUnit + +class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore { + private val workManager = WorkManager.getInstance(context) + + override suspend fun replace(profileId: ProviderProfileId, scenes: List): DomainResult { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) + .build() + workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request) + return DomainResult.Success(Unit) + } + + override suspend fun remove(profileId: ProviderProfileId): DomainResult { + workManager.cancelUniqueWork(workName(profileId)) + return DomainResult.Success(Unit) + } + + private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}" + + companion object { const val KEY_PROFILE_ID = "profile_id" } +} -- 2.54.0 From 5a25c31b3925b0a0ef3893e03b983cc62308ea42 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:29:13 +0800 Subject: [PATCH 29/40] feat: guard automation scheduling by profile --- .../automation/runtime/AutomationBootstrapper.kt | 11 ++++++++++- .../runtime/WorkManagerAutomationScheduleStore.kt | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt index be5b730..373ce7a 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/AutomationBootstrapper.kt @@ -4,13 +4,22 @@ import com.flagship.abox.manager.domain.ProviderProfileStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import com.flagship.abox.manager.domain.ProviderProfileId class AutomationBootstrapper( private val profileStore: ProviderProfileStore, private val scheduler: AutomationScheduler, private val scope: CoroutineScope, ) { + private var previousProfileId: ProviderProfileId? = null + fun start() { - scope.launch { profileStore.observeActiveProfile().collectLatest { scheduler.reconcileActiveProfile() } } + scope.launch { + profileStore.observeActiveProfile().collectLatest { active -> + previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) } + previousProfileId = active?.id + scheduler.reconcileActiveProfile() + } + } } } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt index 4fd9cd9..5fa0e17 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/runtime/WorkManagerAutomationScheduleStore.kt @@ -13,6 +13,7 @@ class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleS private val workManager = WorkManager.getInstance(context) override suspend fun replace(profileId: ProviderProfileId, scenes: List): DomainResult { + if (scenes.isEmpty()) return remove(profileId) val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) .setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build()) .build() -- 2.54.0 From 1139606f9f9d428f8db014a6897688db6970e62a Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:32:07 +0800 Subject: [PATCH 30/40] fix: reject unconfigured scene device fallbacks --- .../abox/manager/ui/scene/SceneEditorScreen.kt | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index 314c4d7..c33f1ef 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -84,9 +84,6 @@ import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding 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) @Composable @@ -774,7 +771,7 @@ private fun AddTriggerDialog( onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, ) { 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 tempThreshold by remember { mutableDoubleStateOf(28.0) } var humThreshold by remember { mutableDoubleStateOf(40.0) } @@ -855,9 +852,9 @@ private fun AddConditionDialog( onAddTimeRange: (String, String) -> Unit, ) { var selectedType by remember { mutableIntStateOf(0) } - var tempDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } - var humDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } - var socketDeviceName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) } + var tempDeviceName by remember { mutableStateOf("") } + var humDeviceName by remember { mutableStateOf("") } + var socketDeviceName by remember { mutableStateOf("") } var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } var tempVal by remember { mutableDoubleStateOf(26.0) } var humVal by remember { mutableDoubleStateOf(50.0) } @@ -997,7 +994,7 @@ private fun AddActionDialog( val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title) val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg) - var socketName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) } + var socketName by remember { mutableStateOf("") } var socketPower by remember { mutableStateOf(SocketPowerState.ON) } var selectedRemoteIndex by remember { mutableIntStateOf(0) } @@ -1056,7 +1053,6 @@ private fun AddActionDialog( 1 -> { if (availableRemotes.isEmpty()) { 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 { Text(stringResource(R.string.scene_select_virtual_remote)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { @@ -1107,7 +1103,7 @@ private fun AddActionDialog( confirmButton = { val canConfirm = when (actionType) { 0 -> socketName.isNotBlank() - 1 -> selectedButtonId.isNotBlank() + 1 -> availableRemotes.isNotEmpty() && selectedButtonId.isNotBlank() 3 -> notifyTitle.isNotBlank() else -> true } @@ -1119,8 +1115,6 @@ private fun AddActionDialog( val remote = availableRemotes.getOrNull(selectedRemoteIndex) if (remote != null) { onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) - } else { - onAddRemote("default-ir", DEFAULT_IR_DEVICE_NAME, selectedButtonId, selectedButtonName) } } 2 -> onAddDelay(delaySeconds) -- 2.54.0 From 8e3d7e604b13989a00a4042d69fcca4b1f6f609d Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:37:30 +0800 Subject: [PATCH 31/40] feat: select configured devices in scene editor --- .../manager/ui/scene/SceneEditorScreen.kt | 100 +++++++++++------- .../manager/ui/scene/SceneEditorViewModel.kt | 22 ++++ .../ui/scene/SceneEditorViewModelTest.kt | 16 +-- 3 files changed, 90 insertions(+), 48 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index c33f1ef..5c6a765 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -81,6 +81,8 @@ import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneTrigger import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.DeviceType +import com.flagship.abox.manager.domain.DeviceDescriptor import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote @@ -569,6 +571,7 @@ private fun SceneEditorContent( if (showTriggerDialog) { AddTriggerDialog( + availableDevices = state.availableDevices, onDismiss = { showTriggerDialog = false }, onAdd = { triggerType, devName, time, temp, hum, op -> when (triggerType) { @@ -583,6 +586,7 @@ private fun SceneEditorContent( if (showConditionDialog) { AddConditionDialog( + availableDevices = state.availableDevices, onDismiss = { showConditionDialog = false }, onAddTemp = { devName, op, temp -> viewModel.addTemperatureCondition(devName, op, temp) @@ -606,6 +610,7 @@ private fun SceneEditorContent( if (showActionDialog) { AddActionDialog( availableRemotes = state.availableRemotes, + availableDevices = state.availableDevices, onDismiss = { showActionDialog = false }, viewModel = viewModel, onAddSocket = { name, power -> @@ -767,6 +772,7 @@ private fun SceneAction.describe(): String = when (this) { @Composable private fun AddTriggerDialog( + availableDevices: List, onDismiss: () -> Unit, onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, ) { @@ -779,7 +785,11 @@ private fun AddTriggerDialog( val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") } 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( onDismissRequest = onDismiss, @@ -806,13 +816,16 @@ private fun AddTriggerDialog( ) } 1, 2 -> { - OutlinedTextField( - value = deviceName, - onValueChange = { deviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Text(stringResource(R.string.scene_device_name_label)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + matchingDevices.forEach { device -> + FilterChip( + selected = deviceName == device.name.value, + onClick = { deviceName = device.name.value }, + label = { Text(device.name.value) }, + ) + } + } 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.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) @@ -845,6 +858,7 @@ private fun AddTriggerDialog( @Composable private fun AddConditionDialog( + availableDevices: List, onDismiss: () -> Unit, onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit, onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit, @@ -867,10 +881,13 @@ private fun AddConditionDialog( val isEndTimeValid = timeRegex.matches(endTime.trim()) 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) { - 0 -> tempDeviceName.isNotBlank() - 1 -> humDeviceName.isNotBlank() - 2 -> socketDeviceName.isNotBlank() + 0 -> sensors.any { it.name.value == tempDeviceName } + 1 -> sensors.any { it.name.value == humDeviceName } + 2 -> sockets.any { it.name.value == socketDeviceName } else -> true } @@ -887,13 +904,12 @@ private fun AddConditionDialog( } when (selectedType) { 0 -> { - OutlinedTextField( - value = tempDeviceName, - onValueChange = { tempDeviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Text(stringResource(R.string.scene_device_name_label)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + sensors.forEach { device -> + FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) }) + } + } 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.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) @@ -902,13 +918,12 @@ private fun AddConditionDialog( Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f) } 1 -> { - OutlinedTextField( - value = humDeviceName, - onValueChange = { humDeviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Text(stringResource(R.string.scene_device_name_label)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + sensors.forEach { device -> + FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) }) + } + } 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.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) @@ -917,13 +932,12 @@ private fun AddConditionDialog( Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f) } 2 -> { - OutlinedTextField( - value = socketDeviceName, - onValueChange = { socketDeviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Text(stringResource(R.string.scene_device_name_label)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + sockets.forEach { device -> + FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) }) + } + } Text(stringResource(R.string.scene_target_socket_state)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) }) @@ -982,6 +996,7 @@ private fun AddConditionDialog( @Composable private fun AddActionDialog( availableRemotes: List, + availableDevices: List, onDismiss: () -> Unit, viewModel: SceneEditorViewModel, onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit, @@ -994,7 +1009,7 @@ private fun AddActionDialog( val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title) val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg) - var socketName by remember { mutableStateOf("") } + var socketName by remember { mutableStateOf(availableDevices.firstOrNull { it.type == DeviceType.SOCKET }?.name?.value.orEmpty()) } var socketPower by remember { mutableStateOf(SocketPowerState.ON) } var selectedRemoteIndex by remember { mutableIntStateOf(0) } @@ -1037,13 +1052,16 @@ private fun AddActionDialog( } when (actionType) { 0 -> { - OutlinedTextField( - value = socketName, - onValueChange = { socketName = it }, - label = { Text(stringResource(R.string.scene_select_socket)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) + Text(stringResource(R.string.scene_select_socket)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device -> + FilterChip( + selected = socketName == device.name.value, + onClick = { socketName = device.name.value }, + label = { Text(device.name.value) }, + ) + } + } Text(stringResource(R.string.scene_socket_power_state)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) }) @@ -1102,7 +1120,7 @@ private fun AddActionDialog( }, confirmButton = { val canConfirm = when (actionType) { - 0 -> socketName.isNotBlank() + 0 -> availableDevices.any { it.type == DeviceType.SOCKET && it.name.value == socketName } 1 -> availableRemotes.isNotEmpty() && selectedButtonId.isNotBlank() 3 -> notifyTitle.isNotBlank() else -> true diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index 02619dd..4be21f1 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -6,6 +6,9 @@ import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.ComparisonOperator import com.flagship.abox.manager.domain.ConcurrencyMode import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DeviceCatalog +import com.flagship.abox.manager.domain.DeviceDescriptor +import com.flagship.abox.manager.domain.DeviceType import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfileStore @@ -50,6 +53,7 @@ data class SceneEditorState( val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, val enableLogging: Boolean = true, val availableRemotes: List = emptyList(), + val availableDevices: List = emptyList(), val isLoading: Boolean = false, val isSaving: Boolean = false, val isSaved: Boolean = false, @@ -64,6 +68,7 @@ class SceneEditorViewModel @Inject constructor( private val sceneRepository: SceneRepository, private val virtualRemoteRepository: VirtualRemoteRepository, private val profileStore: ProviderProfileStore, + private val deviceCatalog: DeviceCatalog, ) : ViewModel() { private val _state = MutableStateFlow(SceneEditorState()) @@ -74,6 +79,23 @@ class SceneEditorViewModel @Inject constructor( init { observeAvailableRemotes() + observeAvailableDevices() + } + + private fun observeAvailableDevices() { + viewModelScope.launch { + profileStore.observeActiveProfile().collectLatest { profile -> + if (profile == null) { + _state.update { it.copy(availableDevices = emptyList()) } + } else { + deviceCatalog.observeDevices(profile.id).collectLatest { result -> + if (result is DomainResult.Success) { + _state.update { it.copy(availableDevices = result.value) } + } + } + } + } + } } private fun observeAvailableRemotes() { diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt index 3458c11..2a56895 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -20,6 +20,7 @@ import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.testing.MainDispatcherRule import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore import com.flagship.abox.manager.testing.fakes.FakeSceneRepository +import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle @@ -39,6 +40,7 @@ class SceneEditorViewModelTest { private val profileStore = FakeProviderProfileStore() private val sceneRepository = FakeSceneRepository() private val virtualRemoteRepository = FakeVirtualRemoteRepository() + private val deviceCatalog = FakeDeviceCatalog() private val profileId = ProviderProfileId("local-experience") private val existingScene = Scene( @@ -66,7 +68,7 @@ class SceneEditorViewModelTest { @Test fun initializeWithNullPreparesNewScene() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.MANUAL) val state = viewModel.state.value @@ -79,7 +81,7 @@ class SceneEditorViewModelTest { @Test fun initializeWithExistingSceneLoadsState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(existingScene.id) // Immediately binds sceneId and enters loading state @@ -101,7 +103,7 @@ class SceneEditorViewModelTest { @Test fun addingAndReorderingActionsUpdatesState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("My New Scene") @@ -129,7 +131,7 @@ class SceneEditorViewModelTest { @Test fun addingTriggersAndConditionsForAutomaticRule() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.AUTOMATIC) viewModel.updateName("Auto Rule") @@ -153,7 +155,7 @@ class SceneEditorViewModelTest { @Test fun saveNewScenePersistsToRepository() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("Brand New Scene") viewModel.addSocketAction("socket-main", SocketPowerState.OFF) @@ -170,7 +172,7 @@ class SceneEditorViewModelTest { @Test fun automaticSceneWithoutTriggersIsInvalid() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(null, AutomationType.AUTOMATIC) viewModel.updateName("Auto Without Trigger") viewModel.addSocketAction("socket-1", SocketPowerState.ON) @@ -188,7 +190,7 @@ class SceneEditorViewModelTest { @Test fun initializeIsIdempotentAndPreservesDraftState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog) viewModel.initialize(existingScene.id) advanceUntilIdle() -- 2.54.0 From 7afcea296ea50cc3fcc7b76f1741d6d33b7380fd Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:44:15 +0800 Subject: [PATCH 32/40] feat: add persisted activity timeline --- .../ui/navigation/AuthenticatedNavigation.kt | 13 +- android/feature/automation/build.gradle.kts | 11 ++ .../automation/ui/activity/ActivityRoute.kt | 15 +++ .../automation/ui/activity/ActivityScreen.kt | 57 +++++++++ .../ui/activity/ActivityViewModel.kt | 87 ++++++++++++++ .../ui/activity/ActivityViewModelTest.kt | 113 ++++++++++++++++++ 6 files changed, 285 insertions(+), 11 deletions(-) create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt create mode 100644 android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index beb6bac..220a30d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -41,6 +41,7 @@ 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 @@ -350,7 +351,7 @@ private fun AuthenticatedNavHost( ) } composable(MainTab.ACTIVITY.route) { - ActivityDestination() + ActivityRoute(profileId = profile.id) } composable(Routes.SETTINGS) { SettingsRoute() @@ -457,16 +458,6 @@ private fun DeviceDetailPane( } } -@Composable -private fun ActivityDestination() { - Box(modifier = Modifier.fillMaxSize().padding(24.dp)) { - Text( - text = androidx.compose.ui.res.stringResource(R.string.activity_placeholder_title), - style = MaterialTheme.typography.headlineMedium, - ) - } -} - private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? { val parts = route?.split('/') ?: return null return when { diff --git a/android/feature/automation/build.gradle.kts b/android/feature/automation/build.gradle.kts index e0eab99..e7fce16 100644 --- a/android/feature/automation/build.gradle.kts +++ b/android/feature/automation/build.gradle.kts @@ -1,6 +1,7 @@ plugins { alias(libs.plugins.android.library) alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.kapt) alias(libs.plugins.hilt.android) } @@ -14,6 +15,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } + buildFeatures { compose = true } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -36,6 +39,14 @@ dependencies { implementation(libs.hilt.android) kapt(libs.hilt.compiler) implementation(libs.androidx.work.runtime.ktx) + implementation(platform(libs.compose.bom)) + implementation(libs.compose.ui) + implementation(libs.compose.material3) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.androidx.hilt.navigation.compose) + testImplementation(libs.junit) + testImplementation(libs.kotlinx.coroutines.test) implementation(libs.kotlinx.coroutines.core) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.test.runner) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt new file mode 100644 index 0000000..790c1cf --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityRoute.kt @@ -0,0 +1,15 @@ +package com.flagship.abox.manager.automation.ui.activity + +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.ProviderProfileId + +@Composable +fun ActivityRoute(profileId: ProviderProfileId, modifier: Modifier = Modifier, viewModel: ActivityViewModel = hiltViewModel()) { + val state = viewModel.state.collectAsStateWithLifecycle().value + LaunchedEffect(profileId) { viewModel.initialize(profileId) } + ActivityScreen(state = state, onLoadEarlier = viewModel::loadEarlier, modifier = modifier) +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt new file mode 100644 index 0000000..0518550 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt @@ -0,0 +1,57 @@ +package com.flagship.abox.manager.automation.ui.activity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityOutcome +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Composable +fun ActivityScreen( + state: ActivityUiState, + onLoadEarlier: () -> Unit, + modifier: Modifier = Modifier, + zoneId: ZoneId = ZoneId.systemDefault(), +) { + Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("活动记录", style = MaterialTheme.typography.headlineSmall) + if (!state.loading && state.groups.isEmpty()) { + Text("暂无活动记录", modifier = Modifier.testTag("activity_empty")) + } else { + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("activity_timeline")) { + state.groups.forEach { group -> + item { Text(group.label, style = MaterialTheme.typography.titleMedium) } + items(group.events, key = { it.id }) { event -> ActivityEventRow(event, zoneId) } + } + if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text("加载更早记录") } } + } + } + } +} + +@Composable +private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) { + val outcome = when (event.outcome) { + ActivityOutcome.SUCCEEDED -> "成功" + ActivityOutcome.FAILED -> "失败" + ActivityOutcome.CANCELLED -> "已取消" + ActivityOutcome.AWAITING_CONFIRMATION -> "等待确认" + } + Column(modifier = Modifier.fillMaxSize().testTag("activity_event_${event.id.value}")) { + Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge) + Text("$outcome · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}") + } +} diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt new file mode 100644 index 0000000..23cafe1 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt @@ -0,0 +1,87 @@ +package com.flagship.abox.manager.automation.ui.activity + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +import com.flagship.abox.manager.domain.ActivityRepository +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.Instant +import java.time.ZoneId +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 javax.inject.Inject + +data class ActivityGroup(val label: String, val events: List) + +data class ActivityUiState( + val profileId: ProviderProfileId? = null, + val events: List = emptyList(), + val groups: List = emptyList(), + val loading: Boolean = true, + val loadingEarlier: Boolean = false, + val hasMore: Boolean = true, + val error: Boolean = false, +) + +@HiltViewModel +class ActivityViewModel @Inject constructor( + private val repository: ActivityRepository, +) : ViewModel() { + private val _state = MutableStateFlow(ActivityUiState()) + val state: StateFlow = _state.asStateFlow() + private var observation: Job? = null + + fun initialize(profileId: ProviderProfileId, zoneId: ZoneId = ZoneId.systemDefault()) { + if (_state.value.profileId == profileId && observation?.isActive == true) return + observation?.cancel() + _state.value = ActivityUiState(profileId = profileId) + observation = viewModelScope.launch { + repository.observeLatest(profileId, 100).collectLatest { result -> + when (result) { + is DomainResult.Success -> publish(result.value, zoneId) + is DomainResult.Failure -> _state.value = _state.value.copy(loading = false, error = true) + } + } + } + } + + fun loadEarlier(zoneId: ZoneId = ZoneId.systemDefault()) { + val current = _state.value + val profileId = current.profileId ?: return + val last = current.events.lastOrNull() ?: return + if (current.loadingEarlier) return + viewModelScope.launch { + _state.value = _state.value.copy(loadingEarlier = true) + val result = repository.loadBefore(profileId, ActivityEventCursor(last.occurredAtEpochMillis, last.id), 100) + when (result) { + is DomainResult.Success -> publish((current.events + result.value).distinctBy { it.id }, zoneId) + is DomainResult.Failure -> _state.value = _state.value.copy(error = true) + } + _state.value = _state.value.copy(loadingEarlier = false, hasMore = resultHasMore(result)) + } + } + + private fun publish(events: List, zoneId: ZoneId) { + val sorted = events.sortedWith(compareByDescending { it.occurredAtEpochMillis }.thenByDescending { it.id.value }) + val today = Instant.now().atZone(zoneId).toLocalDate() + val groups = sorted.groupBy { + val date = Instant.ofEpochMilli(it.occurredAtEpochMillis).atZone(zoneId).toLocalDate() + when (date) { + today -> "今天" + today.minusDays(1) -> "昨天" + else -> "更早记录" + } + }.map { ActivityGroup(it.key, it.value) } + _state.value = _state.value.copy(events = sorted, groups = groups, loading = false, error = false) + } + + private fun resultHasMore(result: DomainResult>): Boolean = + result is DomainResult.Success && result.value.size >= 100 +} diff --git a/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt b/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt new file mode 100644 index 0000000..8bdac86 --- /dev/null +++ b/android/feature/automation/src/test/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModelTest.kt @@ -0,0 +1,113 @@ +package com.flagship.abox.manager.automation.ui.activity + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventCursor +import com.flagship.abox.manager.domain.ActivityEventId +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivityRepository +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.ProviderProfileId +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ActivityViewModelTest { + private val dispatcher = StandardTestDispatcher() + private val scope = TestScope(dispatcher) + private val profile = ProviderProfileId("profile") + private val zone = ZoneId.of("Asia/Singapore") + private lateinit var repository: TestActivityRepository + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + repository = TestActivityRepository() + } + + @After + fun tearDown() = Dispatchers.resetMain() + + @Test + fun realEmptyRepositoryShowsEmptyState() = scope.runTest { + val viewModel = ActivityViewModel(repository) + viewModel.initialize(profile, zone) + advanceUntilIdle() + + assertTrue(viewModel.state.value.events.isEmpty()) + assertTrue(viewModel.state.value.groups.isEmpty()) + assertEquals(false, viewModel.state.value.error) + } + + @Test + fun eventsGroupByLocalDate() = scope.runTest { + val now = Instant.now().atZone(zone).truncatedTo(ChronoUnit.MINUTES) + repository.latest.value = DomainResult.Success( + listOf( + event("today", now.toInstant().toEpochMilli()), + event("yesterday", now.minusDays(1).toInstant().toEpochMilli()), + event("earlier", now.minusDays(3).toInstant().toEpochMilli()), + ), + ) + val viewModel = ActivityViewModel(repository) + viewModel.initialize(profile, zone) + advanceUntilIdle() + + assertEquals(listOf("今天", "昨天", "更早记录"), viewModel.state.value.groups.map { it.label }) + } + + @Test + fun loadEarlierRemovesDuplicateEventsByStableId() = scope.runTest { + val newest = event("newest", 30L) + val older = event("older", 20L) + repository.latest.value = DomainResult.Success(listOf(newest)) + repository.before = DomainResult.Success(listOf(newest, older)) + val viewModel = ActivityViewModel(repository) + viewModel.initialize(profile, zone) + advanceUntilIdle() + viewModel.loadEarlier(zone) + advanceUntilIdle() + + assertEquals(listOf("newest", "older"), viewModel.state.value.events.map { it.id.value }) + } + + private fun event(id: String, time: Long) = ActivityEvent( + id = ActivityEventId(id), + profileId = profile, + occurredAtEpochMillis = time, + kind = ActivityEventKind.SOCKET_COMMAND, + source = ActivitySource.USER, + outcome = ActivityOutcome.SUCCEEDED, + target = ActivityTarget(ActivityTargetType.DEVICE, id, id), + ) + + private class TestActivityRepository : ActivityRepository { + val latest = MutableStateFlow>>(DomainResult.Success(emptyList())) + var before: DomainResult> = DomainResult.Success(emptyList()) + + override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow>> = latest + override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = this.before + override suspend fun append(event: ActivityEvent): DomainResult = DomainResult.Success(Unit) + } +} -- 2.54.0 From 0e4a50b3ddff3627868ed907a3aa811121f26b50 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:48:54 +0800 Subject: [PATCH 33/40] feat: add isolated activity event writer --- .../activity/ActivityEventWriter.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt new file mode 100644 index 0000000..ac36bc7 --- /dev/null +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt @@ -0,0 +1,45 @@ +package com.flagship.abox.manager.automation.activity + +import com.flagship.abox.manager.domain.ActivityEvent +import com.flagship.abox.manager.domain.ActivityEventId +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivityRepository +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.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class ActivityEventWriter( + private val repository: ActivityRepository, + private val scope: CoroutineScope, +) { + fun recordDevice( + profileId: ProviderProfileId, + deviceName: DeviceName, + kind: ActivityEventKind, + outcome: ActivityOutcome, + errorCode: String? = null, + ) { + scope.launch { + runCatching { + repository.append( + ActivityEvent( + id = ActivityEventId(UUID.randomUUID().toString()), + profileId = profileId, + occurredAtEpochMillis = System.currentTimeMillis(), + kind = kind, + source = ActivitySource.USER, + outcome = outcome, + target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value), + errorCode = errorCode, + ), + ) + } + } + } +} -- 2.54.0 From 7a107cc55aca7f3266b5a469932b2573e6481d82 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 20:50:17 +0800 Subject: [PATCH 34/40] docs: record device data capability boundaries --- docs/testing/device-data-capability-matrix.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/testing/device-data-capability-matrix.md diff --git a/docs/testing/device-data-capability-matrix.md b/docs/testing/device-data-capability-matrix.md new file mode 100644 index 0000000..5bb0703 --- /dev/null +++ b/docs/testing/device-data-capability-matrix.md @@ -0,0 +1,32 @@ +# 设备数据能力矩阵 + +本文只记录当前代码中有领域端口、实现和测试支撑的设备数据能力。未列出的数据不应在界面中以固定值、随机值、`--` 或伪造图表呈现。 + +| 能力 | ABox | 本地体验 | 依据 | 界面策略 | +|---|---|---|---|---| +| 插座开关状态读取 | 支持 | 支持 | `SocketGateway.getStatus` | 展示已确认状态 | +| 插座开关控制 | 支持 | 支持 | `SocketGateway.commandPower` / `setPower` | 命令完成后回读;回读失败显示等待确认 | +| 温度当前读数 | 支持,字段可能缺失 | 支持 | `TemperatureHumidityGateway.getReading` | 仅字段存在时展示 | +| 湿度当前读数 | 支持,字段可能缺失 | 支持 | `TemperatureHumidityGateway.getReading` | 仅字段存在时展示 | +| 红外按键发送 | 支持 | 支持 | `InfraredGateway.sendKey` | 展示真实操作结果 | +| 红外按键学习 | 支持 | 支持 | `InfraredGateway.learnKey` | 展示学习状态和结果 | +| 红外码下载 | 支持 | 支持 | `InfraredGateway.downloadCodes` | 仅用于真实下载流程 | +| 插座电压/电流/功率/电量 | 不可用 | 不可用 | 没有对应领域端口 | 不渲染 | +| 插座倒计时 | 不可用 | 不可用 | 没有对应领域端口 | 不渲染 | +| 插座历史趋势 | 不可用 | 不可用 | 没有历史数据端口 | 不渲染 | +| 温湿度历史趋势 | 不可用 | 不可用 | 没有历史数据端口 | 不渲染 | +| 网关诊断延迟 | 不可用 | 不适用 | 没有诊断端口 | 不渲染 | +| Token 有效期展示 | 不可用 | 不适用 | SDK 未提供可安全展示的有效期端口 | 不渲染 | + +## Provider 边界 + +- ABox 设备目录只包含用户明确配置的 `DeviceName` 和 `DeviceType`。 +- 本地体验设备来自确定性的模拟目录。 +- 两个 Provider 的设备描述、状态和历史记录不可互换。 +- “不可用”表示没有对应能力,不表示设备故障;界面不添加禁用的伪功能控件。 + +## 数据处理 + +- ABSDK 原始 Map、响应文本、Token、密码和红外原始码值不进入 UI 活动记录。 +- 错误记录最多保留经过领域层确认的外部错误码。 +- 温湿度单个字段缺失时保留另一个有效字段;两个字段均缺失时进入无数据状态。 -- 2.54.0 From d8d9eb4f283c77d3341d35404fadb2c257594978 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 21:14:09 +0800 Subject: [PATCH 35/40] fix: remove legacy text icons and placeholders --- .../manager/ui/scene/SceneEditorScreen.kt | 2 +- .../abox/manager/ui/scene/SceneListScreen.kt | 40 ++++++------- .../src/main/res/values-zh-rCN/strings.xml | 50 ++++++++-------- android/app/src/main/res/values/strings.xml | 50 ++++++++-------- android/feature/infrared/build.gradle.kts | 1 + .../manager/infrared/hub/InfraredHubScreen.kt | 34 +++++------ .../infrared/panel/RemotePanelScreen.kt | 60 +++++++++++-------- .../manager/infrared/remote/RemoteModels.kt | 26 ++++---- .../remote/ui/InfraredRemoteScreen.kt | 10 ++-- .../src/main/res/values-zh-rCN/strings.xml | 24 ++++---- .../infrared/src/main/res/values/strings.xml | 22 +++---- .../fakes/FakeVirtualRemoteRepository.kt | 13 ++-- 12 files changed, 173 insertions(+), 159 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index 5c6a765..22f275f 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -747,7 +747,7 @@ private fun ActionItemRow( @Composable 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.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) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index 5560346..a2659fd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -68,6 +68,8 @@ import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneColor import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.designsystem.icon.ABoxIcon +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @@ -250,9 +252,10 @@ private fun SceneCard( ), contentAlignment = Alignment.Center, ) { - Text( - text = scene.icon.displayEmoji(), - style = MaterialTheme.typography.titleMedium, + ABoxIcon( + key = scene.icon.toABoxIconKey(), + contentDescription = scene.name, + modifier = Modifier.size(28.dp), ) } Spacer(Modifier.width(12.dp)) @@ -375,10 +378,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text( - text = "⚡", - style = MaterialTheme.typography.displayMedium, - ) + ABoxIcon(ABoxIconKey.SCENES, contentDescription = "暂无情景", modifier = Modifier.size(48.dp)) Spacer(Modifier.size(8.dp)) Text( text = stringResource(R.string.scene_empty_title), @@ -396,19 +396,19 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { } } -fun SceneIcon.displayEmoji(): String = when (this) { - SceneIcon.HOME -> "🏠" - SceneIcon.OFFICE -> "💼" - SceneIcon.NIGHT -> "🌙" - SceneIcon.MOVIE -> "🎬" - SceneIcon.AWAY -> "🚶" - SceneIcon.READING -> "📖" - SceneIcon.DINING -> "🍽️" - SceneIcon.GAMING -> "🎮" - SceneIcon.THERMOSTAT -> "🌡️" - SceneIcon.LIGHT -> "💡" - SceneIcon.CLEAN -> "🧹" - SceneIcon.CUSTOM -> "✨" +fun SceneIcon.toABoxIconKey(): ABoxIconKey = when (this) { + SceneIcon.HOME -> ABoxIconKey.DEVICES + SceneIcon.OFFICE -> ABoxIconKey.ACTIVITY + SceneIcon.NIGHT -> ABoxIconKey.PENDING + SceneIcon.MOVIE -> ABoxIconKey.SCENES + SceneIcon.AWAY -> ABoxIconKey.OFFLINE + SceneIcon.READING -> ABoxIconKey.DEVICES + SceneIcon.DINING -> ABoxIconKey.DEVICES + SceneIcon.GAMING -> ABoxIconKey.SCENES + SceneIcon.THERMOSTAT -> ABoxIconKey.TEMPERATURE + SceneIcon.LIGHT -> ABoxIconKey.POWER + SceneIcon.CLEAN -> ABoxIconKey.REFRESH + SceneIcon.CUSTOM -> ABoxIconKey.UNKNOWN_DEVICE } internal fun SceneColor.toColorOrNull(): Color? { diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 89d8729..6edd76b 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -82,9 +82,9 @@ 其他选项与外观 - + 新增触发器 - + 新增条件 - + 新增动作 + 新增触发器 + 新增条件 + 新增动作 添加 保存指令 @@ -104,10 +104,10 @@ 选择触发器类型 - ⏰ 定时时间触发 - 🌡️ 环境温度触发 - 💧 环境湿度触发 - 🔌 插座状态变更触发 + 定时时间触发 + 环境温度触发 + 环境湿度触发 + 插座状态变更触发 触发时间点(如:07:30) 温度阈值 (°C) 湿度阈值 (%) @@ -118,19 +118,19 @@ 选择限制条件 - 🌡️ 环境温度范围 - 💧 环境湿度范围 - 🔌 插座当前电源状态 - 🕒 生效时间区间 + 环境温度范围 + 环境湿度范围 + 插座当前电源状态 + 生效时间区间 起始时间(如:22:00) 结束时间(如:07:00) 选择执行动作 - 🔌 智能插座控制 - 📺 红外遥控按键 - ⏱️ 延时等待 (缓冲) - 🔔 发送系统提示 + 智能插座控制 + 红外遥控按键 + 延时等待(缓冲) + 发送系统提示 选择目标插座 设定电源状态 选择虚拟遥控器 @@ -144,8 +144,8 @@ 插座【%1$s】: 设为 %2$s 遥控【%1$s】: 发送 %2$s 红外【%1$s】: 槽位 %2$s - ⏱️ 等待 %1$d 秒 - 🔔 提示: %1$s + 等待 %1$d 秒 + 提示:%1$s 执行指令 @@ -164,13 +164,13 @@ 时间格式无效(请按 HH:mm 格式输入,如 07:30) 时间格式无效(请按 HH:mm 格式输入,如 22:00) - ⏰ 定时 - 🌡️ 温度 - 💧 湿度 - 🔌 插座 - 📺 遥控 - ⏱️ 延时 - 🔔 提示 + 定时 + 温度 + 湿度 + 插座 + 遥控 + 延时 + 提示 > 高于 < 低于 开启 @@ -184,7 +184,7 @@ 尚未添加触发器,请点击下方按钮添加 尚未添加限制条件,请点击下方按钮添加 尚未添加执行动作,请点击下方按钮添加 - ⚡ %1$d 个触发器 • %2$d 个动作 + %1$d 个触发器 · %2$d 个动作 %1$d 个执行动作 点击下方按钮立即开始执行 重新执行 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index e3be99b..20b7022 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -82,9 +82,9 @@ Other Options & Appearance - + Add Trigger - + Add Condition - + Add Action + Add Trigger + Add Condition + Add Action Add Save Command @@ -104,10 +104,10 @@ Select Trigger Type - ⏰ Time / Schedule Trigger - 🌡️ Temperature Threshold - 💧 Humidity Threshold - 🔌 Socket State Changed + Time / Schedule Trigger + Temperature Threshold + Humidity Threshold + Socket State Changed Trigger Time (e.g. 07:30) Temperature Threshold (°C) Humidity Threshold (%) @@ -118,19 +118,19 @@ Select Condition - 🌡️ Temperature Range - 💧 Humidity Range - 🔌 Current Socket Power - 🕒 Active Time Range + Temperature Range + Humidity Range + Current Socket Power + Active Time Range Start Time (e.g. 22:00) End Time (e.g. 07:00) Select Action - 🔌 Smart Socket Control - 📺 Infrared Remote Button - ⏱️ Delay / Buffer Timer - 🔔 System Notification + Smart Socket Control + Infrared Remote Button + Delay / Buffer Timer + System Notification Select Target Socket Target Power State Select Virtual Remote @@ -144,8 +144,8 @@ Socket [%1$s]: Set %2$s Remote [%1$s]: Send %2$s Infrared [%1$s]: Slot %2$s - ⏱️ Delay %1$d seconds - 🔔 Notify: %1$s + Delay %1$d seconds + Notify: %1$s Execute Command @@ -164,13 +164,13 @@ Invalid time format (use HH:mm, e.g. 07:30) Invalid time format (use HH:mm, e.g. 22:00) - ⏰ Schedule - 🌡️ Temperature - 💧 Humidity - 🔌 Socket - 📺 Remote - ⏱️ Delay - 🔔 Notify + Schedule + Temperature + Humidity + Socket + Remote + Delay + Notify > Above < Below Turn ON @@ -184,7 +184,7 @@ No triggers configured. Tap + below to add. No conditions configured. Tap + below to add. No actions configured. Tap + below to add. - ⚡ %1$d triggers • %2$d actions + %1$d triggers · %2$d actions %1$d actions Tap the button below to run immediately Re-run diff --git a/android/feature/infrared/build.gradle.kts b/android/feature/infrared/build.gradle.kts index 2bf2ebb..38c0709 100644 --- a/android/feature/infrared/build.gradle.kts +++ b/android/feature/infrared/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) + implementation(libs.compose.material.icons.extended) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index cccbbf1..b8bb0a7 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -271,10 +271,10 @@ private fun VirtualRemoteCard( verticalAlignment = Alignment.CenterVertically, ) { val icon = when (remote.type) { - RemoteDeviceType.TV -> "📺" - RemoteDeviceType.AIR_CONDITIONER -> "❄️" + RemoteDeviceType.TV -> "电视" + RemoteDeviceType.AIR_CONDITIONER -> "空调" RemoteDeviceType.FAN -> "🌀" - RemoteDeviceType.CUSTOM -> "🎛️" + RemoteDeviceType.CUSTOM -> "自定义" } Text(icon, fontSize = 28.sp) IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) { @@ -288,11 +288,11 @@ private fun VirtualRemoteCard( ) val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) { - "❄️ 状态机动态写码" + "状态机动态写码" } else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { - "🚀 静态独占槽位" + "静态独占槽位" } else { - "📦 动态写码" + "动态写码" } Text( @@ -430,7 +430,7 @@ private fun CreateVirtualRemoteDialog( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("遥控器名称(如:客厅电视、我的遥控器)") }, + label = { Text("遥控器名称(如:电视、我的遥控器)") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"), ) @@ -443,10 +443,10 @@ private fun CreateVirtualRemoteDialog( ) { OutlinedTextField( value = when (selectedCustomLayout) { - PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)" - PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)" - PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)" - PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局" + PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)" + PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)" + PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)" + PanelLayoutStyle.AC_COMPACT -> "空调布局" }, onValueChange = {}, readOnly = true, @@ -459,21 +459,21 @@ private fun CreateVirtualRemoteDialog( onDismissRequest = { layoutDropdownExpanded = false }, ) { DropdownMenuItem( - text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") }, + text = { Text("电视导航布局(内置方向盘、音量、频道)") }, onClick = { selectedCustomLayout = PanelLayoutStyle.TV_NAV layoutDropdownExpanded = false }, ) DropdownMenuItem( - text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") }, + text = { Text("媒体播放器布局(播放、快进、切歌)") }, onClick = { selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER layoutDropdownExpanded = false }, ) DropdownMenuItem( - text = { Text("🎛️ 自由网格布局 (可自由增删按键)") }, + text = { Text("自由网格布局(可自由增删按键)") }, onClick = { selectedCustomLayout = PanelLayoutStyle.GRID layoutDropdownExpanded = false @@ -538,7 +538,7 @@ private fun CreateVirtualRemoteDialog( onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS }, ) Column { - Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium) + Text("静态独占槽位模式", fontWeight = FontWeight.Medium) Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -552,7 +552,7 @@ private fun CreateVirtualRemoteDialog( onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH }, ) Column { - Text("📦 动态写码模式", fontWeight = FontWeight.Medium) + Text("动态写码模式", fontWeight = FontWeight.Medium) Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } } @@ -563,7 +563,7 @@ private fun CreateVirtualRemoteDialog( modifier = Modifier.fillMaxWidth(), ) { Text( - text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", + text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。", fontSize = 12.sp, modifier = Modifier.padding(10.dp), color = MaterialTheme.colorScheme.onSecondaryContainer, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt index cc0a73a..654a178 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -41,6 +41,16 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowLeft +import androidx.compose.material.icons.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.VolumeOff +import androidx.compose.material.icons.outlined.Tv import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -333,7 +343,7 @@ private fun AcControlPanel( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (acState.power) "● 运行中" else "○ 已关机", + text = if (acState.power) "运行中" else "已关机", color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Bold, ) @@ -345,7 +355,7 @@ private fun AcControlPanel( .background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline) .testTag("ac_power_toggle"), ) { - Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + Icon(Icons.Outlined.PowerSettingsNew, contentDescription = "电源", tint = Color.White) } } @@ -371,14 +381,14 @@ private fun AcControlPanel( modifier = Modifier.size(56.dp).testTag("ac_temp_down"), shape = CircleShape, ) { - Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold) + Icon(Icons.Outlined.Remove, contentDescription = "降低温度") } Button( onClick = { onAdjustTemp(1) }, modifier = Modifier.size(56.dp).testTag("ac_temp_up"), shape = CircleShape, ) { - Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold) + Icon(Icons.Outlined.Add, contentDescription = "升高温度") } } } @@ -421,19 +431,19 @@ private fun AcControlPanel( FilterChip( selected = acState.verticalSwing, onClick = onToggleVSwing, - label = { Text("↕ 上下扫风") }, + label = { Text("上下扫风") }, modifier = Modifier.weight(1f), ) FilterChip( selected = acState.horizontalSwing, onClick = onToggleHSwing, - label = { Text("↔ 左右扫风") }, + label = { Text("左右扫风") }, modifier = Modifier.weight(1f), ) FilterChip( selected = acState.eco, onClick = onToggleEco, - label = { Text("🌱 节能 ECO") }, + label = { Text("节能") }, modifier = Modifier.weight(1f), ) } @@ -463,9 +473,9 @@ private fun TvControlPanel( horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { - InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_POWER", "电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_SOURCE", "信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(8.dp)) @@ -482,7 +492,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_UP") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), ) { - ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId) + ButtonIndicatorText("TV_NAV_UP", "上", learningButtonId, sendingButtonId) } // 下 @@ -490,7 +500,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_DOWN") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), ) { - ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId) + ButtonIndicatorText("TV_NAV_DOWN", "下", learningButtonId, sendingButtonId) } // 左 @@ -498,7 +508,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_LEFT") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), ) { - ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId) + ButtonIndicatorText("TV_NAV_LEFT", "左", learningButtonId, sendingButtonId) } // 右 @@ -506,7 +516,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_RIGHT") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), ) { - ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId) + ButtonIndicatorText("TV_NAV_RIGHT", "右", learningButtonId, sendingButtonId) } // OK 居中大按钮 @@ -554,10 +564,10 @@ private fun TvControlPanel( ) { Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId) + ButtonIndicatorText("TV_CH_UP", "频道上调", learningButtonId, sendingButtonId) } Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId) + ButtonIndicatorText("TV_CH_DOWN", "频道下调", learningButtonId, sendingButtonId) } } } @@ -583,8 +593,8 @@ private fun MediaPlayerControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_POWER", "电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(16.dp)) @@ -642,13 +652,13 @@ private fun GenericGridControlPanel( ) { Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp) Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) { - Text("➕ 添加按键") + Text("添加按键") } } if (bindings.isEmpty()) { Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("暂无自定义按键,请使用上方添加操作新建", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { LazyVerticalGrid( @@ -697,9 +707,9 @@ private fun GridButtonCard( Column(modifier = Modifier.weight(1f)) { Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp) val statusText = when { - isLearning -> "🟡 正在录制信号..." - binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})" - else -> "⚪ 待录制 (点击录码)" + isLearning -> "正在录制信号..." + binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})" + else -> "待录制(点击录码)" } Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } @@ -712,7 +722,7 @@ private fun GridButtonCard( onClick = onDelete, modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"), ) { - Text("🗑️", fontSize = 15.sp) + Text("删除", fontSize = 12.sp) } } } @@ -756,7 +766,7 @@ private fun ButtonIndicatorText( sendingButtonId: String?, ) { if (learningButtonId == buttonId) { - Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold) + Text("录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold) } else if (sendingButtonId == buttonId) { CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) } else { diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt index bb50d7a..3d59edb 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt @@ -26,24 +26,24 @@ data class RemoteButtonSpec( object RemoteSceneConfig { object AirConditioner { - val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻") - val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲") - val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼") - val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀") - val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨") - val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔") - val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱") + val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "电源") + val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "升温") + val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "降温") + val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式") + val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速") + val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风") + val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时") val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) } object Television { - val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻") - val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇") - val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲") - val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼") - val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀") - val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶") + val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "电源") + val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "静音") + val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "上") + val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "下") + val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "左") + val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "右") val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK") val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +") val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -") diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index 97ccfc3..57b3678 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.designsystem.ABoxIconButton +import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel @@ -158,12 +160,12 @@ internal fun InfraredRemoteScreen( } // 按键管理 / 设置入口按钮 - IconButton( + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = "打开红外设置", onClick = onOpenSettings, modifier = Modifier.testTag("remote_settings_button"), - ) { - Text("⚙️", fontSize = 20.sp) - } + ) }, ) }, diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index 0a6dae8..b6b5e04 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -35,12 +35,12 @@ 红外遥控器 录制中 学习模式 - 🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号 - ❄️ 空调面板 - 📺 电视面板 + 学习模式已开启:点击任意按键开始录制实体遥控器信号 + 空调面板 + 电视面板 设置 - 🔴 学习模式已开启:点击任意按键开始录制 + 学习模式已开启:点击任意按键开始录制 正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键… 【%1$s】录制成功 按键【%1$s】尚未学习,开启右上角学习模式即可录制 @@ -51,8 +51,8 @@ 温度调节 音量 频道 - ❄️ 制冷模式 - 💨 自动风速 | ↔ 自动扫风 + 制冷模式 + 自动风速 | 自动扫风 空调-电源 空调-升温 @@ -98,10 +98,10 @@ 静态占用:%1$d 动态缓冲:Slot %1$s 虚拟遥控器 - 暂无虚拟遥控器,点击右上角或下方 ➕ 添加 + 暂无虚拟遥控器,请使用添加操作 添加遥控器 - 🚀 静态独占(%1$s 槽位) - 📦 动态缓冲(共用 Slot %1$s) + 静态独占(%1$s 槽位) + 动态缓冲(共用槽位 %1$s) 添加虚拟遥控器 设备类型 预设码库 / 品牌 @@ -110,8 +110,8 @@ 节省槽位(0 额外槽位),现场写码发射(~80ms) 硬件槽位不足,请选择动态写码模式 硬件槽位与底层按键管理 - 📺 电视机 / 电视盒子 - ❄️ 空调设备 + 电视机 / 电视盒子 + 空调设备 🌀 电风扇 / 空气净化器 - 🎛️ 自定义面板 + 自定义面板 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index e9a5a6f..9deb20c 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -36,12 +36,12 @@ Recording My Remotes Learn Mode - 🔴 Learn mode active: tap any button to record - ❄️ Air Conditioner - 📺 Television + Learn mode active: tap any button to record + Air Conditioner + Television Settings - 🔴 Learn mode active: tap any button to record + Learn mode active: tap any button to record Recording [%1$s] (Slot %2$s), press remote button… [%1$s] recorded successfully Button [%1$s] is not learned. Enable learn mode to record. @@ -52,8 +52,8 @@ Temp Control VOL CH - ❄️ Cool Mode - 💨 Auto Fan | ↔ Swing + Cool Mode + Auto Fan | Swing AC Power AC Temp + @@ -100,8 +100,8 @@ Virtual Remotes No virtual remotes added yet. Tap + to add one. Add Remote - 🚀 Static Mode (%1$s slots) - 📦 Dynamic Mode (Scratch Slot %1$s) + Static Mode (%1$s slots) + Dynamic Mode (Scratch Slot %1$s) Add Virtual Remote Device Type Brand / Preset Library @@ -110,8 +110,8 @@ Saves slots (0 extra slots), writes code on-the-fly (~80ms) Slot quota exceeded. Please choose Dynamic mode. Hardware Slot / Raw Key Management - 📺 Television (TV) - ❄️ Air Conditioner (AC) + Television (TV) + Air Conditioner (AC) 🌀 Fan / Air Purifier - 🎛️ Custom Panel + Custom Panel diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt index ac183cc..a0b2acf 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt @@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import java.util.concurrent.ConcurrentHashMap class FakeVirtualRemoteRepository : VirtualRemoteRepository { @@ -80,13 +81,13 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { ): DomainResult { remotes[remote.id] = remote bindings[remote.id] = initialBindings.toMutableList() - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(remote.id) } override suspend fun updateRemote(remote: VirtualRemote): DomainResult { remotes[remote.id] = remote - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -96,7 +97,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { ): DomainResult { remotes.remove(remoteId) bindings.remove(remoteId) - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -109,7 +110,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { list.add(binding) } bindings[binding.remoteId] = list - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -120,7 +121,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { val list = bindings[remoteId] ?: return DomainResult.Success(Unit) list.removeAll { it.buttonId == buttonId } bindings[remoteId] = list - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } @@ -131,7 +132,7 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { ): DomainResult { val existing = remotes[remoteId] ?: return DomainResult.Success(Unit) remotes[remoteId] = existing.copy(acState = state) - changeTrigger.value = System.currentTimeMillis() + changeTrigger.update { it + 1L } return DomainResult.Success(Unit) } } -- 2.54.0 From cd4a5ea04c0129f8402b542448ecd871f0bd2923 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 21:24:20 +0800 Subject: [PATCH 36/40] test: align infrared UI instrumentation coverage --- .../remote/InfraredRemoteScreenTest.kt | 1 + .../infrared/ui/InfraredControlScreenTest.kt | 45 +++++-------------- 2 files changed, 11 insertions(+), 35 deletions(-) diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt index a071166..593331b 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt index 99699b1..f1a796d 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -3,17 +3,13 @@ package com.flagship.abox.manager.infrared.ui import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -25,53 +21,32 @@ class InfraredControlScreenTest { val composeTestRule = createComposeRule() @Test - fun rendersControlScreenAndTriggersKeyOperations() { - val testKey = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = ProviderProfileId("profile1"), - deviceName = DeviceName("ir-1"), - displayName = "Power", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) + fun rendersDeviceConfigurationAndAcceptsDeviceNameInput() { val state = InfraredControlUiState( profileId = ProviderProfileId("profile1"), providerType = ProviderType.LOCAL_EXPERIENCE, savedDeviceName = DeviceName("ir-1"), - keys = listOf(testKey), loading = false, ) - var sendClicked = false - var deleteClicked = false + var changedName: String? = null composeTestRule.setContent { InfraredControlScreen( state = state, onBack = {}, onSessionInvalid = {}, - onDeviceNameChanged = {}, + onDeviceNameChanged = { changedName = it }, onSaveDeviceName = {}, - onLearn = { _, _ -> }, - onRetry = {}, - onSend = { sendClicked = true }, - onRename = { _, _ -> }, - onDelete = { deleteClicked = true }, onMessageShown = {}, - nextSlot = 2, ) } - composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick() - assertTrue(sendClicked) - - composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick() - composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick() - assertTrue(deleteClicked) + composeTestRule.onNodeWithText("运行环境与连接信息").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_device_name_input") + .assertIsDisplayed() + .performTextInput("-updated") + composeTestRule.waitForIdle() + assertEquals("-updated", changedName) } } -- 2.54.0 From 816606234b17648994c235784057f5f6ea125a5b Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Wed, 26 Aug 2026 21:30:45 +0800 Subject: [PATCH 37/40] docs: update release hardware validation --- docs/architecture.md | 6 +-- docs/testing/infrared-hardware-validation.md | 46 +++++++++---------- .../release-lan-hardware-validation.md | 38 +++++++++++++++ 3 files changed, 62 insertions(+), 28 deletions(-) create mode 100644 docs/testing/release-lan-hardware-validation.md diff --git a/docs/architecture.md b/docs/architecture.md index e2231f6..256062d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,7 +94,7 @@ ABSDK 防腐层已在 `:absdk-bridge` 中完成同步阻塞调用的后台串行 Fake 只用于开发和测试,不能作为真实 ABox 或真实硬件验收证据。`:testing:fakes` 是测试专用模块,不得加入应用生产依赖。 -一个例外是 `FakeDeviceCatalog`:ABSDK 不提供设备目录,而提供方作用域的生产 ABox 设备目录由设备目录模块负责,尚未落地。在该模块交付前,debug 构建的组合根暂时用 `FakeDeviceCatalog` 充当 ABox 目录占位。这是一个临时的目录所有权边界:不得把 ABox 目录请求路由到本地体验的模拟目录,release 构建在出现真实消费方之前不声明 `DeviceCatalog` 绑定。 +ABSDK 不提供设备目录发现能力。生产 ABox 设备目录由 `:android:core:devicecatalog` 按当前 profile 持久化,来源是用户明确配置或导入的真实 `DeviceName`;它不从本地体验目录复制数据,也不把测试 Fake 绑定到生产组合根。Local profile 仍由 `SimulationDeviceCatalog` 提供独立的确定性目录。 ## 7. 本地体验模拟运行时 @@ -159,8 +159,8 @@ App 组合根已通过 Hilt 单例绑定该 Bridge,绑定处以 `@CleartextAbs 两个提供方在所有构建变体中始终可用,不存在按 debug/release 区分的可用性开关。 -登录完成后进入 `authenticated` 协作路由。当前仅有占位页面,设备列表与主导航由 alZerNest 的设备导航模块接管。 +登录完成后进入 `authenticated` 协作路由。设备中心、详情、红外、情景、自动化和活动记录由对应 Android 模块提供,并通过当前 profile 路由到真实或本地体验端口。 ## 11. 后续边界 -Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。生产 ABox 设备目录由设备目录模块交付后替换 debug 构建中的临时 `FakeDeviceCatalog` 占位。历史示例中的 AsyncTask、明文密码存储、全局明文网络和散落 SDK 调用不得进入正式架构。 +Room 与设备业务 DataStore 的具体 schema/key 只在对应数据所有权明确后创建。生产 ABox 设备目录由设备目录模块维护,不能回退到 `FakeDeviceCatalog`。历史示例中的 AsyncTask、明文密码存储和散落 SDK 调用不得进入正式架构;ABSDK 厂商 JAR 的明文局域网传输风险已明确记录并由所有构建变体共同承担。 diff --git a/docs/testing/infrared-hardware-validation.md b/docs/testing/infrared-hardware-validation.md index 2d541ce..eb89865 100644 --- a/docs/testing/infrared-hardware-validation.md +++ b/docs/testing/infrared-hardware-validation.md @@ -2,39 +2,35 @@ ## 1. 硬件环境与已知设备信息 / Hardware Environment -本阶段用于真实验证的实体设备信息如下: +真实硬件验证只允许在获授权的隔离局域网中进行。文档不记录完整网关地址、IEEE 地址、凭据、令牌、红外码值或原始 SDK 响应;设备名称也应使用脱敏别名。 -- **设备名称**: 红外控制器 (例如 `红外控制器` 或厂商平台绑定的 `irDevName`) -- **IEEE 地址**: `1c1cfd001dba533c` (短地址: `d822`) -- **信号强度 (LQI/RSSI)**: `-4` -- **电量**: `100%` -- **设备类型**: 红外控制器 (Infrared Controller) -- **网关/主机**: ABox 智能主机 -- **被控实体**: 实体空调 / 电视遥控器及对应电器 +- **设备名称**: 脱敏的红外控制器别名 +- **设备类型**: 红外控制器 +- **网关/主机**: 获授权的 ABox 智能主机 +- **被控实体**: 获授权的实体空调、电视或其他电器 --- ## 2. 硬件测试记录表 / Hardware Validation Log -| 日期 (Date) | App Commit | 运行模式 (Mode) | Profile ID | 设备名称 (Device Name) | SDK Key | 操作 (Operation) | 耗时 (ms) | ABSDK Code | 脱敏消息 (Message) | 物理响应 (Physical Response) | 结论 (Result) | -|---|---|---|---|---|---|---|---|---|---|---|---| -| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Learn | 120 | 00000 | success | 模拟学习完成并入库 READY | PASS (Fake) | -| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Send | 45 | 00000 | success | 模拟发射成功 | PASS (Fake) | -| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Learn | - | - | - | 遥控器对准控制器按下目标按键 | 待真机执行 | -| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Send | - | - | - | 实体电器执行开关/调节响应 | 待真机执行 | +| 日期 | App Commit | 运行模式 | 脱敏 Profile | 脱敏设备名 | 脱敏操作标识 | 操作 | 耗时区间 | 脱敏错误分类 | 物理响应 | 结论 | +|---|---|---|---|---|---|---|---|---|---| +| 待填 | 待填 | 本地体验 | local-test | simulation-ir | test-1 | Learn/Send | 待填 | 无 | 模拟流程通过 | 仅软件验证 | +| 待填 | 待填 | ABox | profile-redacted | device-redacted | operation-redacted | Learn | 待填 | 待填 | 授权遥控器完成学习 | 待真机执行 | +| 待填 | 待填 | ABox | profile-redacted | device-redacted | operation-redacted | Send | 待填 | 待填 | 授权电器产生预期响应 | 待真机执行 | --- ## 3. 标准真机测试流程 (12 项用例) / Standard 12-Step Test Procedure -### 用例 1: 真实会话认证前置 (Authenticate Shared Bridge) +### 用例 1: 真实会话认证前置 (Authenticate Session) - **前置**: 通过登录模块对进程单例 `AbsdkBridge` 进行登录认证。 -- **步骤**: 检查进入红外控制页面前,会话 Token 是否有效。 -- **预期**: 若未认证,进入 Real 模式操作时返回 `TokenInvalid` 并提示返回登录。 +- **步骤**: 检查进入红外控制页面前,会话是否有效。 +- **预期**: 若未认证,真实模式操作返回会话无效错误并提示返回登录。 ### 用例 2: 输入并保存真实设备名 (Enter and Save irDevName) -- **步骤**: 在“红外设备名称”输入框中输入 `红外控制器` 或后台分配的真实设备名,点击“保存设备名”。 -- **预期**: 本地 Room 保存成功,页面显示“已保存设备:红外控制器”,启用“学习新按键”入口。 +- **步骤**: 在“红外设备名称”输入框中输入获授权设备名,点击“保存设备名”。 +- **预期**: 本地 Room 保存成功,页面显示脱敏设备名并启用学习入口。 ### 用例 3: 学习全新唯一按键 (Learn Unique Key) - **步骤**: 点击“学习新按键”,输入显示名称(如“空调开”),点击“开始学习”;将物理遥控器对准红外控制器按下按键。 @@ -69,17 +65,17 @@ - **预期**: App 启动或重新进入页面时,`RecoverInterruptedInfraredLearning` 将遗留的 `LEARNING` 保守恢复为 `FAILED`,提示用户重新学习。 ### 用例 11: Token 失效与重新登录 (Token Expiration) -- **步骤**: 主机端使 Token 失效或超过 2 小时后发起红外操作。 -- **预期**: 返回 `TokenInvalid`,本地会话被清除,页面提示登录失效并导航返回。 +- **步骤**: 使远端会话失效后发起红外操作。 +- **预期**: 返回会话无效错误,本地会话被清除,页面提示登录失效并导航返回。 ### 用例 12: 检查未文档化响应字段 (Inspect Undocumented Fields) -- **步骤**: 在调试构建中检查 `ABRet.getDicDatas()` 是否包含未文档化字段。 -- **安全约束**: 严禁将 Raw Payload、Token、密码或未脱敏数据打印至生产日志。 +- **步骤**: 仅在授权的隔离环境中确认响应是否包含已知字段;不得保存或传播原始响应。 +- **安全约束**: 严禁将原始响应、令牌、密码、红外码值或未脱敏网络标识打印至日志或写入测试记录。 --- ## 4. 协议与硬件事实确认记录 / Observed Facts - **SDK 阻塞模式**: 同步阻塞 HTTP POST 调用,由 `AbsdkBridge` 调度至 `Dispatchers.IO` 并通过进程级 `Mutex` 串行执行。 -- **数据隔离**: `FAKE` 模式自动使用 `profileId:infrared-fake` 隔离数据,`REAL_ABSDK` 模式使用主会话数据。 -- **安全门禁**: 真实 ABSDK 依赖明文传输,Release 构建中完全禁用硬件接口并由 `InfraredReleaseModule` 安全阻断。 +- **数据隔离**: 本地体验使用独立的模拟 profile;ABox 模式使用当前已认证 profile,二者不共享设备、遥控器或会话数据。 +- **传输事实**: 真实 ABSDK 使用厂商 JAR 中的明文 HTTP 传输。Debug 与 Release 暴露相同的提供方和硬件路由;项目通过显式传输标记和 manifest 的明文配置接受这一局域网集成风险。该配置不提供机密性或完整性。 diff --git a/docs/testing/release-lan-hardware-validation.md b/docs/testing/release-lan-hardware-validation.md new file mode 100644 index 0000000..553cb2a --- /dev/null +++ b/docs/testing/release-lan-hardware-validation.md @@ -0,0 +1,38 @@ +# 发布版局域网硬件验证协议 + +## 目的与边界 + +本协议只用于获授权的隔离局域网。使用签名的 Release APK、已确认的测试账号和明确授权的实体设备;不得把测试扩展到未授权网络或设备。 + +协议验证真实产品路径,不把本地体验或测试 Fake 的结果当作 ABox 硬件证据。每条记录使用脱敏 profile、设备和操作标识。 + +## 执行前检查 + +- 确认 APK 签名、版本和提交号,记录到测试报告。 +- 确认测试网络与设备均获授权,并记录网络的脱敏名称。 +- 先使用已知无效凭据验证登录失败,再使用授权凭据登录。 +- 检查当前 profile、会话状态和已配置设备目录;只验证用户明确配置的设备。 +- 不在报告、截图、日志或缺陷单中保存密码、令牌、原始厂商响应、红外码值、完整网关地址或完整 IEEE 地址。 + +## 验证流程 + +1. 登录失败路径:确认无效凭据停留在登录页,并显示本地化错误。 +2. 授权登录路径:确认授权凭据登录成功,进入认证后的设备中心。 +3. 设备目录:配置一个已授权的真实设备名,确认只显示当前 profile 的设备,并可进行只读列表检查。 +4. 插座操作:对一个已配置插座执行一次开或关命令,随后读取状态确认;记录结果分类,不记录原始响应。 +5. 传感器读取:读取已配置的温湿度设备,验证有数据、部分数据和无数据状态的显示边界。 +6. 红外学习:对已授权红外控制器学习一个测试按键,确认状态从学习中到完成或明确失败。 +7. 红外取消:学习过程中离开页面或取消,确认不会留下可继续发送的半成品记录。 +8. 红外发送:发送已完成的测试按键,确认授权电器产生预期响应。 +9. 情景执行:执行一个只包含已配置设备的情景,观察实际步骤结果和终态。 +10. 活动记录:确认上述真实终态写入当前 profile 的活动时间线,失败、等待确认和取消保持不同分类。 +11. 会话失效:使远端会话失效后重试一次设备操作,确认本地会话清理并返回登录流程。 +12. Local 隔离复核:切换到本地体验后,确认 ABox 设备、遥控器、活动和会话数据不出现;该步骤不作为真实硬件通过证据。 + +## 结果记录 + +每个步骤只记录:日期、Release 版本或提交号、脱敏 profile、脱敏设备名、操作类别、成功/失败/取消/不可用分类、粗粒度耗时和物理响应摘要。错误只记录已脱敏的领域错误分类;不要复制堆栈或原始 SDK 文本。 + +## 风险说明 + +真实 ABSDK 使用厂商 JAR 中的明文 HTTP 传输。Release 与 Debug 使用相同的提供方和硬件路由;明文传输不提供机密性或完整性,因此只能在隔离且获授权的局域网执行本协议。 -- 2.54.0 From 54aa74126f8a5ed4777d8f8d80c1b17f4c086c40 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Thu, 27 Aug 2026 11:45:30 +0800 Subject: [PATCH 38/40] feat(ui): complete app experience redesign --- .../ui/devicecenter/DeviceCenterScreenTest.kt | 55 ++ .../ui/preferences/SettingsScreenTest.kt | 128 ++-- .../manager/ui/scene/SceneChoiceFlowTest.kt | 40 + .../ui/socket/SocketDetailScreenTest.kt | 41 ++ .../TemperatureHumidityDetailScreenTest.kt | 43 ++ android/app/src/main/AndroidManifest.xml | 1 + .../abox/manager/di/SceneIntegrationModule.kt | 14 +- .../abox/manager/ui/ABoxManagerApp.kt | 43 +- .../defaults/ABoxDefaultContentInitializer.kt | 122 +++ .../ui/devicecenter/DeviceCenterModels.kt | 28 + .../ui/devicecenter/DeviceCenterRoute.kt | 4 + .../ui/devicecenter/DeviceCenterScreen.kt | 236 +++++- .../ui/devicecenter/DeviceCenterViewModel.kt | 135 ++++ .../ui/navigation/AuthenticatedNavigation.kt | 246 ++++--- .../manager/ui/preferences/SettingsScreen.kt | 692 +++++++++++++----- .../ui/preferences/SettingsViewModel.kt | 34 + .../ui/scene/RecordingSceneExecutor.kt | 94 +++ .../manager/ui/scene/SceneEditorScreen.kt | 51 +- .../abox/manager/ui/scene/SceneListScreen.kt | 134 +++- .../manager/ui/shell/AccountProviderDialog.kt | 224 +++--- .../abox/manager/ui/shell/MainAppShell.kt | 137 ++-- .../abox/manager/ui/socket/SocketScreen.kt | 230 ++++-- .../abox/manager/ui/socket/SocketViewModel.kt | 94 ++- .../TemperatureHumidityScreen.kt | 94 ++- .../TemperatureHumidityViewModel.kt | 31 +- .../src/main/res/values-zh-rCN/strings.xml | 123 ++++ android/app/src/main/res/values/strings.xml | 143 +++- .../devicecenter/DeviceCenterViewModelTest.kt | 11 + .../ui/preferences/SettingsViewModelTest.kt | 37 + .../abox/manager/designsystem/Components.kt | 46 +- .../abox/manager/designsystem/Theme.kt | 15 + .../manager/designsystem/icon/ABoxIcon.kt | 15 + .../renderer/ABoxRendererAdapter.kt | 5 +- .../renderer/MiuixRendererAdapter.kt | 10 +- .../manager/designsystem/tokens/ABoxTokens.kt | 14 +- .../devicecatalog/data/ConfiguredDeviceDao.kt | 3 + .../data/ConfiguredDeviceRepository.kt | 13 + .../DataStoreUiPreferencesRepository.kt | 22 + .../abox/manager/preferences/UiPreferences.kt | 37 + .../DataStoreUiPreferencesRepositoryTest.kt | 24 + .../automation/data/RoomActivityRepository.kt | 20 +- .../manager/automation/di/AutomationModule.kt | 9 + .../AndroidNotificationGateway.kt | 9 +- .../automation/ui/activity/ActivityScreen.kt | 59 +- .../ui/activity/ActivityViewModel.kt | 7 +- .../src/main/res/values-zh-rCN/strings.xml | 21 + .../src/main/res/values/strings.xml | 21 + .../infrared/hub/InfraredHubScreenTest.kt | 57 ++ .../infrared/panel/RemotePanelScreenTest.kt | 66 ++ .../data/local/RoomVirtualRemoteRepository.kt | 17 +- .../infrared/hub/InfraredCenterControls.kt | 414 +++++++++++ .../manager/infrared/hub/InfraredHubScreen.kt | 490 ++++++++++--- .../infrared/hub/InfraredHubUiState.kt | 10 + .../infrared/hub/InfraredHubViewModel.kt | 240 +++++- .../infrared/panel/RemotePanelScreen.kt | 272 ++++--- .../infrared/panel/RemotePanelViewModel.kt | 2 + .../remote/InfraredRemoteViewModel.kt | 3 +- .../remote/ui/InfraredRemoteScreen.kt | 8 +- .../infrared/ui/InfraredControlScreen.kt | 8 +- .../src/main/res/values-zh-rCN/strings.xml | 81 +- .../infrared/src/main/res/values/strings.xml | 75 ++ .../manager/domain}/ActivityEventWriter.kt | 35 +- .../manager/domain/InMemorySceneRepository.kt | 61 +- .../domain/infrared/AddVirtualRemoteKey.kt | 16 +- .../domain/infrared/CreateVirtualRemote.kt | 4 +- .../domain/infrared/InfraredKeyModels.kt | 1 + .../domain/infrared/LearnInfraredKey.kt | 1 + .../domain/infrared/LearnVirtualRemoteKey.kt | 5 + .../domain/infrared/VirtualRemoteModels.kt | 2 +- .../simulation/SimulationDeviceCatalog.kt | 12 +- 70 files changed, 4510 insertions(+), 985 deletions(-) create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt create mode 100644 android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt create mode 100644 android/feature/automation/src/main/res/values-zh-rCN/strings.xml create mode 100644 android/feature/automation/src/main/res/values/strings.xml create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt rename {android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity => domain/src/main/kotlin/com/flagship/abox/manager/domain}/ActivityEventWriter.kt (54%) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt new file mode 100644 index 0000000..ac0d2b5 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreenTest.kt @@ -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() + + @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() + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt index 60a9e50..114d3eb 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt @@ -1,26 +1,24 @@ package com.flagship.abox.manager.ui.preferences import androidx.activity.ComponentActivity -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Text -import androidx.compose.ui.unit.dp 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.platform.testTag -import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertWidthIsAtLeast -import androidx.compose.ui.test.assertHeightIsAtLeast +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 @@ -31,60 +29,100 @@ class SettingsScreenTest { val composeRule = createAndroidComposeRule() @Test - fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() { + fun interfaceStyleOpensMenuBeforeChangingRenderer() { + val actions = mutableListOf() composeRule.setContent { ABoxTheme { SettingsScreen( - state = SettingsUiState( - preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), - loaded = true, - ), - onAction = {}, + state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true), + onAction = actions::add, ) } } - composeRule.onNodeWithText("界面设置").assertIsDisplayed() - composeRule.onNodeWithText("渲染器").assertIsDisplayed() - composeRule.onNodeWithText("Material 3").assertIsSelected() - composeRule.onNodeWithText("跟随系统").assertIsSelected() - composeRule.onNodeWithText("动态取色").assertIsDisplayed() - composeRule.onNodeWithText("系统辅助功能").assertIsDisplayed() - composeRule.onNodeWithTag("settings-dynamic-color") - .assertWidthIsAtLeast(48.dp) - .assertHeightIsAtLeast(48.dp) - composeRule.onNodeWithTag("settings-blur") - .assertWidthIsAtLeast(48.dp) - .assertHeightIsAtLeast(48.dp) - composeRule.onNodeWithTag("settings-haptics") - .assertWidthIsAtLeast(48.dp) - .assertHeightIsAtLeast(48.dp) + 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 switchingRendererKeepsCurrentlyRenderedTestScreenPresent() { + fun materialThemeShowsEveryMaterialOptionInReferenceOrder() { + showTheme(DesignRenderer.MATERIAL3) + + assertVerticalOrder( + "settings-material-colors", + "settings-theme-mode", + "settings-color-style", + "settings-color-standard", + "settings-predictive-back", + "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-predictive-back", + "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) { - Column { - Text("当前测试内容") - SettingsScreen( - state = SettingsUiState(preferences = preferences, loaded = true), - onAction = { action -> - if (action is SettingsUiAction.SetRenderer) { - preferences = preferences.copy(renderer = action.value) - } - }, - modifier = Modifier.weight(1f), - ) - } + 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.waitForIdle() + composeRule.onNodeWithTag("settings-theme-entry").performClick() - composeRule.onNodeWithText("当前测试内容").assertIsDisplayed() - composeRule.onNodeWithText("Miuix").assertIsSelected() + 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 }) } } diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt new file mode 100644 index 0000000..dc18aa4 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/scene/SceneChoiceFlowTest.kt @@ -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() + + @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) + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt new file mode 100644 index 0000000..21de05e --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/socket/SocketDetailScreenTest.kt @@ -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() + + @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() + } +} diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt new file mode 100644 index 0000000..5cdea60 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityDetailScreenTest.kt @@ -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() + + @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() + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5cda260..9472773 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ Unit, onSessionAction: (SessionUiAction) -> Unit, + showAccountDialog: Boolean, + showSettings: Boolean, + settingsExperienceState: SettingsExperienceState, + onShowAccountDialogChange: (Boolean) -> Unit, + onShowSettingsChange: (Boolean) -> Unit, ) { - var showAccountDialog by remember { mutableStateOf(false) } - var showSettings by remember { mutableStateOf(false) } - if (showSettings) { - BackHandler { showSettings = false } - SettingsRoute() + BackHandler { onShowSettingsChange(false) } + SettingsRoute( + experienceState = settingsExperienceState, + onExit = { onShowSettingsChange(false) }, + ) } else { AuthenticatedNavigation( username = username, profile = profile, onLogout = onLogout, - onProviderClick = { showAccountDialog = true }, - onAvatarClick = { showAccountDialog = true }, + onProviderClick = { onShowAccountDialogChange(true) }, + onAvatarClick = { onShowAccountDialogChange(true) }, ) } @@ -111,21 +128,21 @@ private fun AuthenticatedNavHost( AccountProviderDialog( userName = username.value, providerName = profile.displayName, - onDismissRequest = { showAccountDialog = false }, + onDismissRequest = { onShowAccountDialogChange(false) }, onOpenSettings = { - showAccountDialog = false - showSettings = true + onShowAccountDialogChange(false) + onShowSettingsChange(true) }, onSwitchProvider = { - showAccountDialog = false + onShowAccountDialogChange(false) onSessionAction(SessionUiAction.SwitchProvider) }, onEndSession = { - showAccountDialog = false + onShowAccountDialogChange(false) onSessionAction(SessionUiAction.EndSession) }, onForgetCredentials = { - showAccountDialog = false + onShowAccountDialogChange(false) onSessionAction(SessionUiAction.ForgetSavedCredentials) }, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt new file mode 100644 index 0000000..416c7be --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/defaults/ABoxDefaultContentInitializer.kt @@ -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("Zigbee插座控制器") 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 + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt index e823aa3..af95ad2 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterModels.kt @@ -4,8 +4,13 @@ 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, @@ -16,7 +21,30 @@ data class DeviceCardUiModel( 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, + 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, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt index 99fbc51..353568c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterRoute.kt @@ -22,6 +22,10 @@ fun DeviceCenterRoute( 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, ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt index 4efac83..8cac803 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterScreen.kt @@ -6,6 +6,22 @@ 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 @@ -20,14 +36,20 @@ 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( @@ -35,6 +57,7 @@ fun DeviceCenterScreen( onFilterChanged: (DeviceCategoryFilter) -> Unit, onOpenDevice: (DeviceCardUiModel) -> Unit, onConfigureDevice: (String, DeviceType) -> Unit, + onQuickAction: (DeviceCardUiModel) -> Unit, modifier: Modifier = Modifier, ) { var showConfiguration by remember { mutableStateOf(false) } @@ -42,20 +65,21 @@ fun DeviceCenterScreen( modifier = modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text("设备中心", style = MaterialTheme.typography.headlineSmall) - Text("选择设备以查看状态或打开控制页面", style = MaterialTheme.typography.bodyMedium) - FilterRow(selected = state.filter, onSelected = onFilterChanged) + 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("正在加载设备", modifier = Modifier.testTag("device_center_loading")) + DeviceCenterContent.LOADING -> Text(stringResource(R.string.device_center_loading), modifier = Modifier.testTag("device_center_loading")) DeviceCenterContent.EMPTY -> ABoxEmptyState( - title = "尚未配置可管理设备", - description = "请先配置设备名称和类型", + 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("配置设备") } + Button(onClick = { showConfiguration = true }) { Text(stringResource(R.string.device_center_configure)) } }, ) - DeviceCenterContent.ERROR -> Text("设备列表加载失败", modifier = Modifier.testTag("device_center_error")) + 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), @@ -63,8 +87,12 @@ fun DeviceCenterScreen( verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("device_center_grid"), ) { - items(state.cards, key = { it.key }) { card -> - DeviceCard(card = card, onClick = { onOpenDevice(card) }) + 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) }) + } } } } @@ -90,13 +118,13 @@ private fun DeviceConfigurationDialog( var type by remember { mutableStateOf(DeviceType.SOCKET) } AlertDialog( onDismissRequest = onDismiss, - title = { Text("配置设备") }, + title = { Text(stringResource(R.string.device_center_configure_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("设备名称") }, + label = { Text(stringResource(R.string.device_center_device_name)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -118,9 +146,9 @@ private fun DeviceConfigurationDialog( } }, confirmButton = { - Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text("保存") } + Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.action_confirm)) } }, - dismissButton = { Button(onClick = onDismiss) { Text("取消") } }, + dismissButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, ) } @@ -150,17 +178,173 @@ private fun FilterChipItem( } @Composable -private fun DeviceCard(card: DeviceCardUiModel, onClick: () -> Unit) { - val icon = when (card.device.type) { - DeviceType.SOCKET -> ABoxIconKey.SOCKET - DeviceType.TEMPERATURE_HUMIDITY -> ABoxIconKey.SENSOR - DeviceType.INFRARED -> ABoxIconKey.INFRARED +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) } - ABoxCard( - title = card.displayName, - subtitle = card.typeLabel, - onClick = onClick, - modifier = Modifier.testTag("device_card_${card.device.id.value}"), - leadingContent = { ABoxIcon(icon, contentDescription = card.typeLabel) }, - ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt index 5d24ece..de15933 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModel.kt @@ -5,15 +5,26 @@ 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 @@ -21,6 +32,11 @@ import javax.inject.Inject 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 = _uiState.asStateFlow() @@ -33,11 +49,13 @@ class DeviceCenterViewModel @Inject constructor( 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) } @@ -64,6 +82,112 @@ class DeviceCenterViewModel @Inject constructor( 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) } @@ -71,8 +195,19 @@ class DeviceCenterViewModel @Inject constructor( } 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 diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index 220a30d..2f6d5db 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -2,11 +2,14 @@ package com.flagship.abox.manager.ui.navigation import android.net.Uri import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +import android.os.Build 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -14,11 +17,14 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableFloatStateOf 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.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -54,6 +60,9 @@ 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.designsystem.LocalABoxPredictiveBackEnabled +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.collect import com.flagship.abox.manager.ui.socket.SocketScreen import com.flagship.abox.manager.ui.socket.SocketUiEvent import com.flagship.abox.manager.ui.socket.SocketViewModel @@ -185,35 +194,60 @@ fun AuthenticatedNavigationStateHost( selectedDevice = selectedDevice, detailPresentation = selectedPresentation, ) + val canNavigateBack = selectedDevice != null || currentRoute != MainTab.DEVICES.route + val predictiveBackEnabled = LocalABoxPredictiveBackEnabled.current && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + var backProgress by remember { mutableFloatStateOf(0f) } - 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 + Box( + modifier = Modifier.fillMaxSize() + .clip(RoundedCornerShape((28f * backProgress).dp)) + .graphicsLayer { + val scale = 1f - 0.04f * backProgress + scaleX = scale + scaleY = scale + translationX = 28.dp.toPx() * backProgress + }, + ) { + 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 }, - ) + }, + { deviceName, type -> + savedSelection = DeviceSelection(profileId, deviceName, type) + }, + { savedSelection = null }, + ) + } - BackHandler(enabled = selectedDevice != null || navController.previousBackStackEntry != null) { - savedSelection = null - if (currentRoute != MainTab.DEVICES.route) { - navController.popBackStack() + PredictiveBackHandler(enabled = canNavigateBack && predictiveBackEnabled) { events -> + try { + events.collect { event -> backProgress = event.progress } + savedSelection = null + navController.popCurrentDestinationUnlessRoot() + } catch (cancelled: CancellationException) { + throw cancelled + } finally { + backProgress = 0f } } + + BackHandler(enabled = canNavigateBack && !predictiveBackEnabled) { + savedSelection = null + navController.popCurrentDestinationUnlessRoot() + } } @Composable @@ -246,28 +280,22 @@ fun AuthenticatedNavigation( selectionForRoute = { route -> profile.selectionForRoute(route) }, ) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection -> val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES - MainAppShell( + AuthenticatedGraphContent( + navController = navController, + profile = profile, + username = username, currentTab = currentTab, windowSizeClass = resolvedWindowSizeClass, - userName = username.value, - providerName = profile.displayName, + isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && + snapshot.currentRoute == MainTab.DEVICES.route, + selectedDevice = snapshot.selectedDevice, onTabClick = onTabClick, + onSelectDevice = onSelectDevice, + onClearDeviceSelection = onClearDeviceSelection, onProviderClick = onProviderClick, onAvatarClick = onAvatarClick, - content = { paddingValues -> - AuthenticatedGraphContent( - navController = navController, - profile = profile, - paddingValues = paddingValues, - isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && - snapshot.currentRoute == MainTab.DEVICES.route, - selectedDevice = snapshot.selectedDevice, - onSelectDevice = onSelectDevice, - onClearDeviceSelection = onClearDeviceSelection, - onLogout = onLogout, - onOpenSettings = onOpenSettings, - ) - }, + onLogout = onLogout, + onOpenSettings = onOpenSettings, ) } } @@ -277,48 +305,36 @@ fun AuthenticatedNavigation( private fun AuthenticatedGraphContent( navController: NavHostController, profile: ProviderProfile, - paddingValues: PaddingValues, + 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, ) { - Box( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues), - ) { - if (isExpandedDevicePane && selectedDevice != null) { - Row(modifier = Modifier.fillMaxSize()) { - AuthenticatedNavHost( - modifier = Modifier.weight(1f), - navController = navController, - profile = profile, - onSelectDevice = onSelectDevice, - onClearDeviceSelection = onClearDeviceSelection, - onLogout = onLogout, - onOpenSettings = onOpenSettings, - ) - DeviceDetailPane( - modifier = Modifier.weight(1f), - selection = selectedDevice, - onBack = onClearDeviceSelection, - ) - } - } else { - AuthenticatedNavHost( - modifier = Modifier.fillMaxSize(), - navController = navController, - profile = profile, - onSelectDevice = onSelectDevice, - onClearDeviceSelection = onClearDeviceSelection, - onLogout = onLogout, - onOpenSettings = onOpenSettings, - ) - } - } + 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 @@ -326,8 +342,16 @@ 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, ) { @@ -337,21 +361,65 @@ private fun AuthenticatedNavHost( modifier = modifier, ) { composable(MainTab.DEVICES.route) { - DeviceCenterRoute( - profileId = profile.id, - onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) }, - ) + 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) { - 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)) }, - ) + 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) { - ActivityRoute(profileId = profile.id) + 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() @@ -493,3 +561,9 @@ private fun NavBackStackEntry.actualRoute(): String? { } return route } + +private fun NavHostController.popCurrentDestinationUnlessRoot() { + if (currentBackStackEntry?.actualRoute() != MainTab.DEVICES.route) { + popBackStack() + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt index 3a99fe5..be914b8 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt @@ -1,227 +1,573 @@ package com.flagship.abox.manager.ui.preferences +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.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.material3.ExperimentalMaterial3Api +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.Scaffold +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 androidx.compose.material3.TopAppBar +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.ABoxCard import com.flagship.abox.manager.designsystem.ABoxErrorState -import com.flagship.abox.manager.designsystem.ABoxFilterChip -import com.flagship.abox.manager.designsystem.ABoxPreferenceGroup -import com.flagship.abox.manager.designsystem.ABoxSwitch +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.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) + + companion object { + val Saver: Saver = Saver( + save = { it.destination }, + restore = { SettingsExperienceState(it) }, + ) + } +} + @Composable fun SettingsRoute( + experienceState: SettingsExperienceState = SettingsExperienceState(), + onExit: () -> Unit = {}, viewModel: SettingsViewModel = hiltViewModel(), ) { val state = viewModel.uiState.collectAsStateWithLifecycle().value - SettingsScreen(state = state, onAction = viewModel::onAction) + 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) + BackHandler { + if (destination == SettingsDestination.THEME) experienceState.destination = SettingsDestination.HOME.name + else onExit() + } + when (destination) { + SettingsDestination.HOME -> SettingsHomeScreen( + state = state, + onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name }, + onBack = onExit, + onAction = onAction, + ) + SettingsDestination.THEME -> ThemeSettingsScreen( + state = state, + onBack = { experienceState.destination = SettingsDestination.HOME.name }, + onAction = onAction, + ) + } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( state: SettingsUiState, onAction: (SettingsUiAction) -> Unit, modifier: Modifier = Modifier, -) { - Scaffold( - modifier = modifier.fillMaxSize(), - topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) }, - ) { contentPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(contentPadding) - .verticalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - ABoxPreferenceGroup(title = stringResource(R.string.settings_style_section)) { - PreferenceRow( - title = stringResource(R.string.settings_renderer_label), - description = stringResource(R.string.settings_renderer_description), - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ABoxFilterChip( - selected = state.preferences.renderer == DesignRenderer.MATERIAL3, - label = stringResource(R.string.settings_renderer_material3), - onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MATERIAL3)) }, - ) - ABoxFilterChip( - selected = state.preferences.renderer == DesignRenderer.MIUIX, - label = stringResource(R.string.settings_renderer_miuix), - onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) }, - ) - } - } - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_theme_section)) { - PreferenceRow( - title = stringResource(R.string.settings_theme_label), - description = null, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ThemeChip(AppThemeMode.SYSTEM, state, onAction, R.string.settings_theme_system) - ThemeChip(AppThemeMode.LIGHT, state, onAction, R.string.settings_theme_light) - ThemeChip(AppThemeMode.DARK, state, onAction, R.string.settings_theme_dark) - } - } - SwitchPreference( - title = stringResource(R.string.settings_dynamic_color), - description = stringResource(R.string.settings_dynamic_color_description), - checked = state.preferences.dynamicColorEnabled, - modifier = Modifier.testTag("settings-dynamic-color"), - onCheckedChange = { onAction(SettingsUiAction.SetDynamicColor(it)) }, - ) - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_surface_section)) { - SwitchPreference( - title = stringResource(R.string.settings_blur), - description = stringResource(R.string.settings_blur_description), - checked = state.preferences.blurEnabled, - modifier = Modifier.testTag("settings-blur"), - onCheckedChange = { onAction(SettingsUiAction.SetBlur(it)) }, - ) - PreferenceRow( - title = stringResource(R.string.settings_navigation), - description = null, - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - NavigationChip( - presentation = NavigationPresentation.STANDARD, - selected = state.preferences.navigationPresentation == NavigationPresentation.STANDARD, - label = stringResource(R.string.settings_navigation_standard), - onAction = onAction, - ) - NavigationChip( - presentation = NavigationPresentation.FLOATING, - selected = state.preferences.navigationPresentation == NavigationPresentation.FLOATING, - label = stringResource(R.string.settings_navigation_floating), - onAction = onAction, - ) - } - } - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_feedback_section)) { - SwitchPreference( - title = stringResource(R.string.settings_haptics), - description = stringResource(R.string.settings_haptics_description), - checked = state.preferences.hapticsEnabled, - modifier = Modifier.testTag("settings-haptics"), - onCheckedChange = { onAction(SettingsUiAction.SetHaptics(it)) }, - ) - } - - ABoxPreferenceGroup(title = stringResource(R.string.settings_accessibility_section)) { - ABoxCard( - title = stringResource(R.string.settings_accessibility_card_title), - subtitle = stringResource(R.string.settings_accessibility_description), - ) - } - - state.error?.let { error -> - ABoxErrorState( - title = stringResource(R.string.settings_error_save), - description = error.asString(), - ) - } - } - } -} +) = SettingsHomeScreen(state, onOpenTheme = {}, onBack = {}, onAction = onAction, modifier = modifier) @Composable -private fun ThemeChip( - mode: AppThemeMode, +private fun SettingsHomeScreen( state: SettingsUiState, + onOpenTheme: () -> Unit, + onBack: () -> Unit, onAction: (SettingsUiAction) -> Unit, - labelRes: Int, + modifier: Modifier = Modifier, ) { - ABoxFilterChip( - selected = state.preferences.themeMode == mode, - label = stringResource(labelRes), - onClick = { onAction(SettingsUiAction.SetThemeMode(mode)) }, - ) -} - -@Composable -private fun NavigationChip( - presentation: NavigationPresentation, - selected: Boolean, - label: String, - onAction: (SettingsUiAction) -> Unit, -) { - ABoxFilterChip( - selected = selected, - label = label, - onClick = { onAction(SettingsUiAction.SetNavigation(presentation)) }, - ) -} - -@Composable -private fun PreferenceRow( - title: String, - description: String?, - content: @Composable () -> Unit, -) { - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text(text = title, style = MaterialTheme.typography.titleMedium) - description?.let { - Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + 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, + ) } - content() + + 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 SwitchPreference( - title: String, - description: String, - checked: Boolean, - modifier: Modifier, - onCheckedChange: (Boolean) -> Unit, +private fun ThemeSettingsScreen( + state: SettingsUiState, + onBack: () -> Unit, + onAction: (SettingsUiAction) -> Unit, ) { - Row( - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + SettingsPage( + title = stringResource(R.string.settings_theme_detail_title), + onBack = onBack, + modifier = Modifier.testTag("settings-theme-detail"), ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = title, style = MaterialTheme.typography.titleMedium) - Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + ThemePreview(state) + when (state.preferences.renderer) { + DesignRenderer.MATERIAL3 -> MaterialThemeOptions(state, onAction) + DesignRenderer.MIUIX -> MiuixThemeOptions(state, onAction) } - ABoxSwitch( - checked = checked, - onCheckedChange = onCheckedChange, - contentDescription = title, - modifier = modifier, + SettingsError(state) + } +} + +@Composable +private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + 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)) }, + ) + 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)) }, + ) + PredictiveBackSetting(state, onAction) + 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)) } + } + PredictiveBackSetting(state, onAction) + SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) } +} + +@Composable +private fun PredictiveBackSetting(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { + val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + SettingsGroup(modifier = Modifier.testTag("settings-predictive-back")) { + SettingsSwitchRow( + icon = ABoxIconKey.BACK, + title = stringResource(R.string.settings_predictive_back), + description = stringResource(R.string.settings_predictive_back_description), + checked = state.preferences.predictiveBackEnabled, + tag = "settings-predictive-back-switch", + enabled = supported, + unavailableDescription = if (supported) null else stringResource(R.string.settings_requires_android_13), + ) { onAction(SettingsUiAction.SetPredictiveBack(it)) } + } +} + +@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 EnumSettingRow( + modifier: Modifier, + icon: ABoxIconKey, + title: String, + value: String, + values: List, + label: @Composable (T) -> String, + onSelected: (T) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + SettingsGroup(modifier = modifier) { + Box(Modifier.fillMaxWidth()) { + SettingsLinkRow( + icon = icon, + title = title, + description = stringResource(R.string.settings_tap_to_choose), + value = value, + 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) { + 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 (state.preferences.dynamicColorEnabled) 0.42f else 1f), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + AccentPreference.entries.forEach { accent -> + val color = accentColor(accent) + 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 = !state.preferences.dynamicColorEnabled) { 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, onClick: () -> Unit) { + Row(modifier.fillMaxWidth().clickable(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 }) +private fun accentColor(accent: AccentPreference) = when (accent) { AccentPreference.BLUE -> Color(0xFF4F6FA9); AccentPreference.GREEN -> Color(0xFF2F7D57); AccentPreference.AMBER -> Color(0xFFA66700); AccentPreference.ROSE -> Color(0xFFA94C68); AccentPreference.VIOLET -> Color(0xFF7659A8) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt index d8bdb09..061273c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModel.kt @@ -4,7 +4,11 @@ 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 @@ -62,6 +66,30 @@ class SettingsViewModel @Inject constructor( 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) } } @@ -118,5 +146,11 @@ sealed interface 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 } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt new file mode 100644 index 0000000..4efba59 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/RecordingSceneExecutor.kt @@ -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 { + 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 { + 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) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index 22f275f..b4c6199 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -6,6 +6,9 @@ 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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.FlowRowScope import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -504,7 +507,7 @@ private fun SceneEditorContent( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneChoiceFlow { FilterChip( selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL, onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) }, @@ -520,7 +523,7 @@ private fun SceneEditorContent( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneChoiceFlow { FilterChip( selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR, onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) }, @@ -770,6 +773,20 @@ private fun SceneAction.describe(): String = when (this) { 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 private fun AddTriggerDialog( availableDevices: List, @@ -796,7 +813,7 @@ private fun AddTriggerDialog( title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) }, text = { 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 == 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)) }) @@ -817,7 +834,7 @@ private fun AddTriggerDialog( } 1, 2 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { matchingDevices.forEach { device -> FilterChip( selected = deviceName == device.name.value, @@ -826,7 +843,7 @@ private fun AddTriggerDialog( ) } } - 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.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) } @@ -896,7 +913,7 @@ private fun AddConditionDialog( title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, text = { 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 == 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)) }) @@ -905,12 +922,12 @@ private fun AddConditionDialog( when (selectedType) { 0 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { sensors.forEach { device -> FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = 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.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) } @@ -919,12 +936,12 @@ private fun AddConditionDialog( } 1 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { sensors.forEach { device -> FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = 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.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) } @@ -933,13 +950,13 @@ private fun AddConditionDialog( } 2 -> { Text(stringResource(R.string.scene_device_name_label)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { sockets.forEach { device -> FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) }) } } 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.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) }) } @@ -1044,7 +1061,7 @@ private fun AddActionDialog( title = { Text(stringResource(R.string.scene_dialog_add_action_title)) }, text = { 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 == 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)) }) @@ -1053,7 +1070,7 @@ private fun AddActionDialog( when (actionType) { 0 -> { Text(stringResource(R.string.scene_select_socket)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device -> FilterChip( selected = socketName == device.name.value, @@ -1063,7 +1080,7 @@ private fun AddActionDialog( } } 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.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) }) } @@ -1073,7 +1090,7 @@ private fun AddActionDialog( Text(stringResource(R.string.scene_no_virtual_remotes)) } else { Text(stringResource(R.string.scene_select_virtual_remote)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { availableRemotes.forEachIndexed { idx, remote -> FilterChip( selected = selectedRemoteIndex == idx, @@ -1084,7 +1101,7 @@ private fun AddActionDialog( } Text(stringResource(R.string.scene_select_remote_button)) if (keyBindings.isNotEmpty()) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + SceneChoiceFlow { keyBindings.take(4).forEach { binding -> val label = binding.displayName.ifBlank { binding.buttonId } FilterChip( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index a2659fd..1e182de 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons 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.material3.AlertDialog import androidx.compose.material3.CardDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FloatingActionButtonDefaults +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -49,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -71,6 +74,7 @@ import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -86,6 +90,23 @@ fun SceneListScreen( val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle() var showFabMenu by remember { mutableStateOf(false) } + var fabExpanded by remember { mutableStateOf(true) } + val listState = rememberLazyListState() + + LaunchedEffect(listState) { + var previousIndex = 0 + var previousOffset = 0 + snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset } + .distinctUntilChanged() + .collect { (index, offset) -> + val movingUp = index > previousIndex || (index == previousIndex && offset > previousOffset) + val movingDown = index < previousIndex || (index == previousIndex && offset < previousOffset) + if (movingUp) fabExpanded = false + if (movingDown || (index == 0 && offset == 0)) fabExpanded = true + previousIndex = index + previousOffset = offset + } + } val context = LocalContext.current LaunchedEffect(Unit) { @@ -99,37 +120,30 @@ fun SceneListScreen( Scaffold( containerColor = Color.Transparent, floatingActionButton = { - Box { - ExtendedFloatingActionButton( - onClick = { showFabMenu = true }, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text(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() - }, + ExtendedFloatingActionButton( + onClick = { showFabMenu = true }, + expanded = fabExpanded, + icon = { + Icon( + Icons.Filled.Add, + contentDescription = stringResource(R.string.scene_create_action), ) - DropdownMenuItem( - text = { Text(stringResource(R.string.scene_create_automatic)) }, - leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, - onClick = { - showFabMenu = false - onAddAutomatic() - }, - ) - } - } + }, + text = { Text(stringResource(R.string.scene_create_action), fontWeight = FontWeight.Bold) }, + shape = RoundedCornerShape(18.dp), + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + elevation = FloatingActionButtonDefaults.elevation( + defaultElevation = 6.dp, + pressedElevation = 10.dp, + focusedElevation = 8.dp, + hoveredElevation = 8.dp, + ), + ) }, ) { innerPadding -> LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( start = 16.dp, @@ -213,6 +227,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 diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt index c418397..45de938 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt @@ -3,20 +3,27 @@ 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.Logout -import androidx.compose.material.icons.outlined.Refresh import androidx.compose.material.icons.outlined.Settings 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 @@ -25,17 +32,14 @@ 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.ABoxIconButton import com.flagship.abox.manager.designsystem.ABoxUserAvatar -import com.flagship.abox.manager.designsystem.icon.ABoxIconKey - -private enum class DestructiveAction { - END_SESSION, - FORGET_CREDENTIALS, -} +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountProviderDialog( userName: String, @@ -47,136 +51,106 @@ fun AccountProviderDialog( onForgetCredentials: () -> Unit, modifier: Modifier = Modifier, ) { - var confirmation by remember { mutableStateOf(null) } + var confirmLogout by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false) - if (confirmation == null) { - AlertDialog( - onDismissRequest = onDismissRequest, - modifier = modifier.testTag("account_dialog"), - title = { + 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(), + modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - Text("账户与提供方") - ABoxIconButton( - key = ABoxIconKey.CLOSE, - contentDescription = "关闭账户对话框", - onClick = onDismissRequest, - modifier = Modifier.testTag("account_dialog_close"), - ) - } - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - ABoxUserAvatar(name = userName, onClick = {}, size = 64.dp) - Text( - text = userName.ifBlank { "当前账户" }, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = providerName, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Surface( - modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), - shape = MaterialTheme.shapes.medium, - ) { - Row( - modifier = Modifier.fillMaxWidth().padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Icon( - imageVector = Icons.Outlined.CloudDone, - contentDescription = "连接方式", - modifier = Modifier.size(24.dp), - ) - Column(modifier = Modifier.weight(1f)) { - Text("当前连接方式", style = MaterialTheme.typography.labelMedium) - Text(providerName, style = MaterialTheme.typography.bodyLarge) - } - TextButton( - onClick = onSwitchProvider, - modifier = Modifier.testTag("account_dialog_switch_provider"), - ) { - Icon(Icons.Outlined.Refresh, "切换提供方") - Text("切换") - } - } - } - AccountActionButton( - icon = Icons.Outlined.Settings, - label = "界面设置", - description = "调整主题、渲染器和交互偏好", - tag = "account_dialog_open_settings", - onClick = onOpenSettings, - ) - AccountActionButton( - icon = Icons.Outlined.Logout, - label = "退出当前会话", - description = "结束当前提供方会话", - tag = "account_dialog_end_session", - onClick = { confirmation = DestructiveAction.END_SESSION }, - ) - TextButton( - onClick = { confirmation = DestructiveAction.FORGET_CREDENTIALS }, - modifier = Modifier.fillMaxWidth().testTag("account_dialog_forget_credentials"), - ) { - Text("清除保存的凭据", color = MaterialTheme.colorScheme.error) + 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) } } - }, - confirmButton = {}, - ) - } else { - val action = confirmation ?: return - val isForget = action == DestructiveAction.FORGET_CREDENTIALS + } + + AccountSheetAction( + icon = Icons.Outlined.Settings, + 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.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 = { confirmation = null }, - title = { Text(if (isForget) "清除保存的凭据" else "退出当前会话") }, - text = { - Text( - if (isForget) { - "确定清除当前提供方保存的登录凭据吗?" - } else { - "确定退出当前会话吗?" - }, - ) - }, + onDismissRequest = { confirmLogout = false }, + title = { Text(stringResource(R.string.account_logout)) }, + text = { Text(stringResource(R.string.account_logout_confirm)) }, confirmButton = { ABoxButton( onClick = { - confirmation = null - if (isForget) onForgetCredentials() else onEndSession() + confirmLogout = false + onEndSession() }, modifier = Modifier.testTag("account_dialog_confirm"), - ) { - Text("确定") - } - }, - dismissButton = { - TextButton(onClick = { confirmation = null }) { Text("取消") } + ) { Text(stringResource(R.string.account_logout)) } }, + dismissButton = { TextButton(onClick = { confirmLogout = false }) { Text(stringResource(R.string.action_cancel)) } }, ) } } @Composable -private fun AccountActionButton( +private fun AccountSheetAction( icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, description: String, tag: String, + dangerous: Boolean = false, onClick: () -> Unit, ) { TextButton( @@ -184,14 +158,18 @@ private fun AccountActionButton( modifier = Modifier.fillMaxWidth().testTag(tag), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - Icon(icon, contentDescription = label) - Column(modifier = Modifier.weight(1f)) { - Text(label) - Text(description, style = MaterialTheme.typography.bodySmall) + 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) } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt index 744939c..79f1133 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/MainAppShell.kt @@ -25,6 +25,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -35,6 +36,10 @@ import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxNavigationBar import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem import com.flagship.abox.manager.designsystem.ABoxUserAvatar +import com.flagship.abox.manager.designsystem.LocalABoxNavigationPresentation +import com.flagship.abox.manager.designsystem.LocalABoxRenderer +import com.flagship.abox.manager.preferences.DesignRenderer +import com.flagship.abox.manager.preferences.NavigationPresentation /** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */ @Composable @@ -46,59 +51,103 @@ fun MainAppShell( onTabClick: (MainTab) -> Unit, onProviderClick: () -> Unit, onAvatarClick: () -> Unit, + showNavigationChrome: Boolean = true, content: @Composable (PaddingValues) -> Unit, ) { val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL + val renderer = LocalABoxRenderer.current + val floating = renderer == DesignRenderer.MIUIX && + LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail Scaffold( modifier = Modifier.fillMaxSize(), topBar = { - ProviderTopBar( - currentTab = currentTab, - providerName = providerName, - userName = userName, - onProviderClick = onProviderClick, - onAvatarClick = onAvatarClick, - ) + if (showNavigationChrome) { + ProviderTopBar( + currentTab = currentTab, + providerName = providerName, + userName = userName, + onProviderClick = onProviderClick, + onAvatarClick = onAvatarClick, + ) + } }, bottomBar = { - if (!useRail) { - 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( - imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon, - contentDescription = stringResource(tab.labelRes), - ) - }, - label = { Text(stringResource(tab.labelRes)) }, - ) - } - } + if (showNavigationChrome && !useRail) { + MainBottomBar(currentTab, onTabClick, renderer, floating) } }, ) { innerPadding -> Row( modifier = Modifier.fillMaxSize(), ) { - if (useRail) { + if (showNavigationChrome && useRail) { MainNavigationRail( currentTab = currentTab, onTabClick = onTabClick, ) } - Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + 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, @@ -108,24 +157,24 @@ private fun ProviderTopBar( onAvatarClick: () -> Unit, ) { Box( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .padding(horizontal = 16.dp, vertical = 8.dp), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(32.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), - tonalElevation = 2.dp, + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), ) { - Row( - modifier = Modifier - .fillMaxWidth() - .height(64.dp) - .padding(horizontal = 8.dp), - verticalAlignment = Alignment.CenterVertically, + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(32.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + tonalElevation = 2.dp, ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { Row( modifier = Modifier .clip(RoundedCornerShape(24.dp)) @@ -170,8 +219,8 @@ private fun ProviderTopBar( contentDescription = stringResource(R.string.action_account), ) } + } } - } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt index 8bd9dc5..b11ebdf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt @@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.socket import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -38,6 +39,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -50,13 +53,14 @@ import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.designsystem.ABoxStatus +import com.flagship.abox.manager.designsystem.ABoxStatusBanner import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.SocketPowerState -import java.text.SimpleDateFormat +import java.text.DateFormat import java.util.Date -import java.util.Locale @Composable fun SocketRoute( @@ -83,10 +87,30 @@ fun SocketScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + var showDeviceNameDialog by remember { mutableStateOf(false) } ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), title = state.deviceName?.value ?: stringResource(R.string.socket_title), onBack = onBackClick, + subtitle = stringResource(R.string.socket_detail_subtitle), + icon = ABoxIconKey.SOCKET, + action = { + Row { + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = stringResource(R.string.socket_name_settings), + onClick = { showDeviceNameDialog = true }, + enabled = !state.isOperating, + ) + ABoxIconButton( + key = ABoxIconKey.REFRESH, + contentDescription = stringResource(R.string.action_refresh), + onClick = { onEvent(SocketUiEvent.Refresh) }, + enabled = !state.isLoading && !state.isOperating, + ) + } + }, ) { paddingValues -> Column( modifier = Modifier @@ -96,17 +120,6 @@ fun SocketScreen( .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - ABoxIconButton( - key = ABoxIconKey.REFRESH, - contentDescription = "刷新插座状态", - onClick = { onEvent(SocketUiEvent.Refresh) }, - enabled = !state.isLoading && !state.isOperating, - ) - } if (state.isOffline) { OfflineBanner( onRetry = { onEvent(SocketUiEvent.Refresh) }, @@ -122,20 +135,28 @@ fun SocketScreen( ) } + Box(modifier = Modifier.fillMaxWidth().height(64.dp)) { + SocketOperationBanner(operation = state.operation) + } + // Central Status Card Card( modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), + .padding(top = 8.dp, bottom = 18.dp), 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, ) { Column( modifier = Modifier .fillMaxWidth() - .padding(32.dp), + .padding(horizontal = 24.dp, vertical = 32.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -143,13 +164,16 @@ fun SocketScreen( powerState = state.powerState, isOffline = state.isOffline, 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 = stringResource(R.string.socket_status_label), - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -160,7 +184,7 @@ fun SocketScreen( state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off) else -> stringResource(R.string.socket_status_unknown) }, - style = MaterialTheme.typography.headlineMedium, + style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = when { state.isOffline -> MaterialTheme.colorScheme.error @@ -171,7 +195,9 @@ fun SocketScreen( ) 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 = stringResource(R.string.socket_confirmed_at, formattedTime), style = MaterialTheme.typography.bodySmall, @@ -182,35 +208,12 @@ 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 = if (state.isPowerOn) { - stringResource(R.string.socket_action_turn_off) - } else { - stringResource(R.string.socket_action_turn_on) - }, - style = MaterialTheme.typography.titleMedium, - ) - } - } - - Spacer(modifier = Modifier.height(12.dp)) - - // Dedicated Segmented Action Buttons + Text( + text = stringResource(R.string.socket_direct_control), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp), + ) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp), @@ -218,22 +221,76 @@ fun SocketScreen( OutlinedButton( onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) }, enabled = state.isActionEnabled && state.powerState != SocketPowerState.ON, - modifier = Modifier.weight(1f).height(48.dp), + modifier = Modifier.weight(1f).height(56.dp), ) { + ABoxIcon(ABoxIconKey.POWER, "开启插座", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.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), + modifier = Modifier.weight(1f).height(56.dp), ) { + ABoxIcon(ABoxIconKey.POWER, "关闭插座", modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) Text(stringResource(R.string.socket_action_turn_off)) } } + + Text( + text = stringResource(R.string.socket_confirmation_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth().padding(top = 14.dp), + ) } } + if (showDeviceNameDialog) { + DeviceNameDialog( + currentName = state.deviceName?.value.orEmpty(), + title = stringResource(R.string.socket_name_settings), + description = stringResource(R.string.gateway_device_name_socket_desc), + onDismiss = { showDeviceNameDialog = false }, + onConfirm = { name -> + showDeviceNameDialog = false + onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(name))) + }, + ) + } + +} + +@Composable +private fun DeviceNameDialog( + currentName: String, + title: String, + description: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var value by remember(currentName) { mutableStateOf(currentName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(description, style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.gateway_device_name)) }, + singleLine = true, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + ) } @Composable @@ -241,6 +298,9 @@ private fun PowerIndicatorBadge( powerState: SocketPowerState?, isOffline: Boolean, isLoading: Boolean, + isOperating: Boolean, + enabled: Boolean, + onClick: () -> Unit, modifier: Modifier = Modifier, ) { val indicatorColor = when { @@ -252,24 +312,74 @@ private fun PowerIndicatorBadge( Box( modifier = modifier - .size(96.dp) + .size(160.dp) .clip(CircleShape) .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, ) { - if (isLoading) { - ABoxLoadingIndicator(modifier = Modifier.size(36.dp)) - } else { - ABoxIcon( - key = if (powerState == SocketPowerState.ON) ABoxIconKey.POWER else ABoxIconKey.SOCKET, - contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭", - modifier = Modifier.size(40.dp), + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (isLoading || isOperating) { + ABoxLoadingIndicator(modifier = Modifier.size(44.dp)) + } else { + ABoxIcon( + key = ABoxIconKey.POWER, + contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭", + modifier = Modifier.size(52.dp), + ) + } + Spacer(Modifier.height(10.dp)) + Text( + text = when { + isOperating -> stringResource(R.string.device_status_switching) + powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on) + powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off) + else -> stringResource(R.string.device_status_unavailable) + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, ) } } } +@Composable +private fun SocketOperationBanner( + operation: SocketOperationState, + modifier: Modifier = Modifier, +) { + when (operation) { + is SocketOperationState.PendingCommand -> ABoxStatusBanner( + state = ABoxStatus.PENDING, + contentDescription = "正在发送电源指令", + modifier = modifier, + ) + is SocketOperationState.RefreshingConfirmation -> ABoxStatusBanner( + state = ABoxStatus.PENDING, + contentDescription = "正在确认插座状态", + modifier = modifier, + ) + is SocketOperationState.AwaitingConfirmation -> ABoxStatusBanner( + state = ABoxStatus.WARNING, + contentDescription = "指令已发送,等待状态确认", + modifier = modifier.testTag("socket_awaiting_confirmation"), + ) + is SocketOperationState.Failed -> ABoxStatusBanner( + state = ABoxStatus.ERROR, + contentDescription = "插座操作失败", + modifier = modifier, + ) + SocketOperationState.Unavailable -> ABoxStatusBanner( + state = ABoxStatus.OFFLINE, + contentDescription = "插座当前不可用", + modifier = modifier, + ) + SocketOperationState.IdleConfirmed -> Unit + } +} + @Composable private fun OfflineBanner( onRetry: () -> Unit, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt index 264828e..0b687c6 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt @@ -9,13 +9,18 @@ import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository import com.flagship.abox.manager.ui.common.DomainErrorMapper +import com.flagship.abox.manager.domain.ActivityEventWriter +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import java.net.URLDecoder import java.nio.charset.StandardCharsets import javax.inject.Inject @@ -23,6 +28,8 @@ import javax.inject.Inject @HiltViewModel class SocketViewModel @Inject constructor( private val socketGateway: SocketGateway, + private val configuredDeviceRepository: ConfiguredDeviceRepository, + private val activityWriter: ActivityEventWriter, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -64,16 +71,19 @@ class SocketViewModel @Inject constructor( is SocketUiEvent.ChangeDeviceName -> { val trimmed = event.deviceName.value.trim() - if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) { - _state.update { - it.copy( - deviceName = DeviceName(trimmed), - powerState = null, - errorMessage = null, - lastConfirmedTimeMillis = null, - ) + val current = _state.value + val profileId = current.profileId + val oldName = current.deviceName + if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) { + viewModelScope.launch { + when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) { + is DomainResult.Success -> { + _state.update { it.copy(deviceName = DeviceName(trimmed), powerState = null, errorMessage = null, lastConfirmedTimeMillis = null) } + loadStatus() + } + is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) } + } } - loadStatus() } } @@ -146,24 +156,63 @@ class SocketViewModel @Inject constructor( return } + _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) } viewModelScope.launch { - _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) } when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) { is DomainResult.Success -> { - _state.update { it.copy(operation = SocketOperationState.RefreshingConfirmation(targetState)) } - when (val confirmation = socketGateway.getStatus(profileId, deviceName)) { - is DomainResult.Success -> _state.update { - it.copy(powerState = confirmation.value.powerState, isOperating = false, isOffline = false, - errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(), - operation = SocketOperationState.IdleConfirmed) - } - is DomainResult.Failure -> _state.update { - it.copy(isOperating = false, errorMessage = DomainErrorMapper.map(confirmation.error), - operation = SocketOperationState.AwaitingConfirmation(targetState)) + activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED) + _state.update { + it.copy( + isOperating = true, + isOffline = false, + errorMessage = null, + operation = SocketOperationState.RefreshingConfirmation(targetState), + ) + } + var confirmationError: DomainError? = null + repeat(CONFIRMATION_ATTEMPTS) { attempt -> + if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS) + when (val confirmation = socketGateway.getStatus(profileId, deviceName)) { + is DomainResult.Success -> if (confirmation.value.powerState == targetState) { + activityWriter.recordDevice( + profileId, + deviceName, + ActivityEventKind.SOCKET_CONFIRMATION, + ActivityOutcome.SUCCEEDED, + ) + _state.update { + it.copy( + powerState = targetState, + isOperating = false, + isOffline = false, + errorMessage = null, + lastConfirmedTimeMillis = System.currentTimeMillis(), + operation = SocketOperationState.IdleConfirmed, + ) + } + return@launch + } + is DomainResult.Failure -> confirmationError = confirmation.error } } + activityWriter.recordDevice( + profileId, + deviceName, + ActivityEventKind.SOCKET_CONFIRMATION, + ActivityOutcome.AWAITING_CONFIRMATION, + confirmationError?.externalCode, + ) + _state.update { + it.copy( + isOperating = false, + isOffline = confirmationError is DomainError.DeviceOffline, + errorMessage = confirmationError?.let(DomainErrorMapper::map), + operation = SocketOperationState.AwaitingConfirmation(targetState), + ) + } } is DomainResult.Failure -> { + activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.FAILED, result.error.externalCode) val isOffline = result.error is DomainError.DeviceOffline _state.update { it.copy( @@ -177,4 +226,9 @@ class SocketViewModel @Inject constructor( } } } + + private companion object { + const val CONFIRMATION_ATTEMPTS = 4 + const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L + } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt index 67796fe..5132078 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt @@ -31,6 +31,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -45,9 +47,8 @@ import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.DeviceName -import java.text.SimpleDateFormat +import java.text.DateFormat import java.util.Date -import java.util.Locale @Composable fun TemperatureHumidityRoute( @@ -74,10 +75,22 @@ fun TemperatureHumidityScreen( modifier: Modifier = Modifier, ) { val scrollState = rememberScrollState() + val configuration = LocalConfiguration.current + var showDeviceNameDialog by remember { mutableStateOf(false) } ABoxDeviceDetailScaffold( modifier = modifier.fillMaxSize(), title = state.deviceName?.value ?: stringResource(R.string.th_title), onBack = onBackClick, + subtitle = stringResource(R.string.device_sensor_subtitle), + icon = ABoxIconKey.SENSOR, + action = { + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = stringResource(R.string.sensor_name_settings), + onClick = { showDeviceNameDialog = true }, + enabled = !state.isLoading && !state.isRefreshing, + ) + }, ) { paddingValues -> Column( modifier = Modifier @@ -87,17 +100,6 @@ fun TemperatureHumidityScreen( .verticalScroll(scrollState), horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - ABoxIconButton( - key = ABoxIconKey.REFRESH, - contentDescription = "刷新温湿度数据", - onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) }, - enabled = !state.isLoading && !state.isRefreshing, - ) - } if (state.isOffline) { OfflineBanner( onRetry = { onEvent(TemperatureHumidityUiEvent.Refresh) }, @@ -137,10 +139,16 @@ fun TemperatureHumidityScreen( ) } else { // Telemetry Cards Row / Column + Text( + text = stringResource(R.string.sensor_current_readings), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp), + ) Row( modifier = Modifier .fillMaxWidth() - .padding(vertical = 12.dp), + .padding(bottom = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { TelemetryMetricCard( @@ -148,6 +156,7 @@ fun TemperatureHumidityScreen( value = state.temperature, unit = stringResource(R.string.th_unit_celsius), icon = ABoxIconKey.TEMPERATURE, + testTag = "sensor_metric_temperature", modifier = Modifier.weight(1f), ) @@ -156,12 +165,15 @@ fun TemperatureHumidityScreen( value = state.humidity, unit = stringResource(R.string.th_unit_percent), icon = ABoxIconKey.HUMIDITY, + testTag = "sensor_metric_humidity", modifier = Modifier.weight(1f), ) } state.lastUpdatedTimeMillis?.let { timeMillis -> - val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis)) + val formattedTime = remember(timeMillis, configuration) { + DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis)) + } Text( text = stringResource(R.string.th_last_updated, formattedTime), style = MaterialTheme.typography.bodySmall, @@ -175,7 +187,7 @@ fun TemperatureHumidityScreen( // Read-Only Informational Card Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("sensor_monitoring_notice"), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), ), @@ -187,13 +199,13 @@ fun TemperatureHumidityScreen( .padding(16.dp), ) { Text( - text = stringResource(R.string.th_read_only_notice), + text = stringResource(R.string.sensor_monitoring_title), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = stringResource(R.string.th_read_only_description), + text = stringResource(R.string.sensor_monitoring_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), modifier = Modifier.padding(top = 4.dp), @@ -201,7 +213,7 @@ fun TemperatureHumidityScreen( } } - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Refresh Button ABoxButton( @@ -209,7 +221,7 @@ fun TemperatureHumidityScreen( enabled = !state.isLoading && !state.isRefreshing && state.profileId != null, modifier = Modifier .fillMaxWidth() - .height(52.dp), + .height(56.dp), ) { if (state.isRefreshing || state.isLoading) { ABoxLoadingIndicator(modifier = Modifier.size(22.dp)) @@ -225,6 +237,45 @@ fun TemperatureHumidityScreen( } } + if (showDeviceNameDialog) { + SensorDeviceNameDialog( + currentName = state.deviceName?.value.orEmpty(), + onDismiss = { showDeviceNameDialog = false }, + onConfirm = { name -> + showDeviceNameDialog = false + onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(name))) + }, + ) + } + +} + +@Composable +private fun SensorDeviceNameDialog( + currentName: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var value by remember(currentName) { mutableStateOf(currentName) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.sensor_name_settings)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.gateway_device_name_sensor_desc), style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text(stringResource(R.string.gateway_device_name)) }, + singleLine = true, + ) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } }, + ) } @Composable @@ -233,11 +284,12 @@ private fun TelemetryMetricCard( value: String?, unit: String, icon: ABoxIconKey, + testTag: String, modifier: Modifier = Modifier, ) { if (value == null) return Card( - modifier = modifier, + modifier = modifier.testTag(testTag), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt index bc5c8e8..bd72512 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt @@ -8,7 +8,11 @@ import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository import com.flagship.abox.manager.ui.common.DomainErrorMapper +import com.flagship.abox.manager.domain.ActivityEventWriter +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivityOutcome import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -22,6 +26,8 @@ import javax.inject.Inject @HiltViewModel class TemperatureHumidityViewModel @Inject constructor( private val thGateway: TemperatureHumidityGateway, + private val configuredDeviceRepository: ConfiguredDeviceRepository, + private val activityWriter: ActivityEventWriter, savedStateHandle: SavedStateHandle, ) : ViewModel() { @@ -62,18 +68,19 @@ class TemperatureHumidityViewModel @Inject constructor( is TemperatureHumidityUiEvent.ChangeDeviceName -> { val trimmed = event.deviceName.value.trim() - if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) { - _state.update { - it.copy( - deviceName = DeviceName(trimmed), - temperature = null, - humidity = null, - hasNoData = false, - errorMessage = null, - lastUpdatedTimeMillis = null, - ) + val current = _state.value + val profileId = current.profileId + val oldName = current.deviceName + if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) { + viewModelScope.launch { + when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) { + is DomainResult.Success -> { + _state.update { it.copy(deviceName = DeviceName(trimmed), temperature = null, humidity = null, hasNoData = false, errorMessage = null, lastUpdatedTimeMillis = null) } + loadReading(isRefresh = false) + } + is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) } + } } - loadReading(isRefresh = false) } } @@ -100,6 +107,7 @@ class TemperatureHumidityViewModel @Inject constructor( when (val result = thGateway.getReading(profileId, deviceName)) { is DomainResult.Success -> { + if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.SUCCEEDED) val reading = result.value val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank() _state.update { @@ -117,6 +125,7 @@ class TemperatureHumidityViewModel @Inject constructor( } is DomainResult.Failure -> { + if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.FAILED, result.error.externalCode) val isOffline = result.error is DomainError.DeviceOffline val hasNoData = result.error is DomainError.NoData _state.update { diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 6edd76b..72c82f4 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -259,4 +259,127 @@ 操作执行失败,请重试。 暂无可用数据。 发生未知错误。 + + + 界面设置 + 样式 + 渲染器 + 选择应用使用的界面渲染方式 + Material 3 + Miuix + 主题 + 主题模式 + 跟随系统 + 浅色 + 深色 + 动态取色 + 使用系统壁纸颜色生成界面配色 + 界面效果 + 模糊效果 + 在支持的界面中启用背景模糊 + 导航样式 + 标准导航栏 + 悬浮导航栏 + 交互反馈 + 触觉反馈 + 操作时提供轻微震动反馈 + 系统辅助功能 + 遵循系统辅助功能设置 + 动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。 + 无法保存界面设置,请重试 + 界面风格 + 主题 + 配色、明暗、视觉效果与缩放 + 主题 + Material 3 + Miuix + 让底部导航独立悬浮在页面内容上方 + 强调色 + 界面缩放 + 调整界面元素的显示比例 + 色彩风格 + Tonal Spot + Expressive + Fidelity + 色彩规范 + Spec 2021 + Spec 2025 + 液态玻璃 + 为悬浮底栏启用半透明玻璃质感 + 预测性返回手势 + 侧滑返回时预览即将返回的页面 + 点击选择 + 需要 Android 13 或更高版本 + 请先启用悬浮底栏 + + + 已连接终端设备 + 选择需要控制或查看状态的终端设备 + 正在加载设备 + 尚未配置可管理设备 + 请先配置网关设备名称和类型 + 配置设备 + 设备列表加载失败 + 配置设备 + 设备名称 + 状态不可用 + 离线 + 正在切换 + 等待状态确认 + 已开启 + 已关闭 + 状态已确认 + 等待首次确认 + 控制面板 + 环境传感器 · 只读监控 + 实时设备读数 + 详情 + 无数据 + 红外控制器 · %1$d 个虚拟遥控器 + 已挂载:%1$s + 暂无遥控器 + 硬件槽位 %1$d/%2$d + 槽位状态不可用 + 红外中枢 + 当前账户 + %1$s,您好 + 连接方式 + 界面设置 + 主题、渲染风格与交互偏好 + 退出当前会话 + 结束当前连接并返回登录流程 + 确定退出当前会话吗? + 新建指令 + 选择指令的启动方式 + 手动点击运行,适合常用的一键设备组合。 + 根据时间、设备状态或传感器条件自动执行。 + 智能插座 · 状态确认控制 + 设置插座设备名称 + 设置传感器设备名称 + 网关设备名称 + 用于匹配和控制网关设备,必须与网关中的名称完全一致。 + 用于匹配和读取网关设备,必须与网关中的名称完全一致。 + 保存设备名称 + 直接控制 + 指令只有在网关回读到结果状态后才会确认。等待确认时可使用刷新操作。 + 当前读数 + 实时环境监控 + 数据直接来自当前设备。缺失指标保持不显示,不使用估算值或占位数据。 + 红外遥控中心 + 红外设备设置 + 遥控器、槽位与网关设备名称 + 虚拟遥控器 + %1$d 个遥控器 + 添加遥控器 + 硬件槽位 + 网关设备名称 + 用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。 + 保存设备名称 + 编辑遥控器 + 调整遥控器顺序 + 上移 + 下移 + 删除遥控器 + 面板布局 + 传输模式 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 20b7022..8ccbd56 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -261,30 +261,125 @@ An unexpected error occurred. - 界面设置 - 样式 - 渲染器 - 选择应用使用的界面渲染方式 + Interface settings + Style + Renderer + Choose the interface renderer used by the app Material 3 Miuix - 主题 - 主题模式 - 跟随系统 - 浅色 - 深色 - 动态取色 - 使用系统壁纸颜色生成界面配色 - 界面效果 - 模糊效果 - 在支持的界面中启用背景模糊 - 导航样式 - 标准导航栏 - 悬浮导航栏 - 交互反馈 - 触觉反馈 - 操作时提供轻微震动反馈 - 系统辅助功能 - 遵循系统辅助功能设置 - 动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。 - 无法保存界面设置,请重试 + Theme + Theme mode + Follow system + Light + Dark + Dynamic color + Generate interface colors from the system wallpaper + Surface effects + Blur effects + Enable background blur on supported screens + Navigation style + Standard navigation + Floating navigation + Interaction feedback + Haptic feedback + Provide subtle vibration feedback for actions + System accessibility + Follow system accessibility settings + Animation, font size, contrast and other accessibility options are controlled by the system. + Unable to save interface settings. Try again. + Interface style + Theme + Colors, brightness, effects and scale + Theme + Material 3 + Miuix + Use a detached navigation surface above page content + Accent color + Interface scale + Adjust the size of interface elements + Color style + Tonal Spot + Expressive + Fidelity + Color standard + Spec 2021 + Spec 2025 + Liquid glass + Apply a translucent glass surface to floating navigation + Predictive back gesture + Preview the destination while swiping back + Tap to choose + Requires Android 13 or later + Enable floating navigation first + + + Connected devices + Select a device to control it or view its current state + Loading devices + No manageable devices configured + Configure a gateway device name and type first + Configure device + Unable to load devices + Configure device + Device name + Status unavailable + Offline + Switching + Awaiting confirmation + On + Off + State confirmed + Waiting for first confirmation + Control panel + Environment sensor · Read-only monitoring + Live device reading + Details + No data + Infrared controller · %1$d virtual remotes + Mounted: %1$s + No remotes + Hardware slots %1$d/%2$d + Slot state unavailable + Infrared center + Current account + Hello, %1$s + Connection method + Interface settings + Theme, renderer and interaction preferences + Sign out + End the current connection and return to sign-in + Sign out of the current session? + New command + Choose how the command starts + Run manually for frequently used device combinations. + Run automatically from time, device, or sensor conditions. + Smart socket · confirmed control + Set socket device name + Set sensor device name + Gateway device name + Used to match and control the gateway device. It must exactly match the gateway name. + Used to match and read the gateway device. It must exactly match the gateway name. + Save device name + Direct control + A command is confirmed only after the gateway reports the resulting state. Refresh while confirmation is pending. + Current readings + Live environment monitoring + Values come directly from the current device. Missing metrics remain absent; no estimates or placeholders are used. + Infrared remote center + Infrared device settings + Remotes, slots, and gateway device name + Virtual remotes + %1$d remotes + Add remote + Hardware slots + Gateway device name + Used to match the infrared controller in the gateway. It must exactly match the gateway name. + Save device name + Edit remote + Reorder remote + Move up + Move down + Delete remote + Panel layout + Transmission mode diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt index c13b47f..880a051 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/devicecenter/DeviceCenterViewModelTest.kt @@ -16,6 +16,7 @@ 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 @@ -54,6 +55,16 @@ class DeviceCenterViewModelTest { 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) diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt index 9de023e..5037e32 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/preferences/SettingsViewModelTest.kt @@ -2,7 +2,11 @@ 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 @@ -83,6 +87,39 @@ class SettingsViewModelTest { 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(), diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index a47ef34..9b38645 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -476,13 +477,19 @@ 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().padding(horizontal = 8.dp, vertical = 8.dp), + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { ABoxIconButton( @@ -490,11 +497,38 @@ fun ABoxDeviceDetailScaffold( contentDescription = "返回", onClick = onBack, ) - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - modifier = Modifier.padding(start = 8.dp), - ) + 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, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index bed7e71..0a1b683 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -12,6 +12,8 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import com.flagship.abox.manager.designsystem.renderer.ABoxRendererAdapters import com.flagship.abox.manager.preferences.DesignRenderer import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens @@ -20,6 +22,7 @@ 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 val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors } @@ -29,6 +32,9 @@ val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shap val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 } val LocalABoxReducedMotion = staticCompositionLocalOf { false } val LocalABoxBlurEnabled = staticCompositionLocalOf { false } +val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD } +val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false } +val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false } @Composable fun ABoxTheme( @@ -78,8 +84,14 @@ internal fun ABoxThemeContent( 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, @@ -87,6 +99,9 @@ internal fun ABoxThemeContent( LocalABoxRenderer provides resolution.renderer, LocalABoxReducedMotion provides reducedMotion, LocalABoxBlurEnabled provides resolution.blurEnabled, + LocalABoxNavigationPresentation provides preferences.navigationPresentation, + LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled, + LocalABoxPredictiveBackEnabled provides preferences.predictiveBackEnabled, ) { renderer.Render( resolution = resolution, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt index 217b822..bbda6fe 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/icon/ABoxIcon.kt @@ -11,6 +11,11 @@ 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 @@ -51,6 +56,11 @@ enum class ABoxIconKey { WARNING, ERROR, UNKNOWN_DEVICE, + SETTINGS, + THEME, + EFFECTS, + FEEDBACK, + SCALE, } /** Design-system-owned device categories; this module does not depend on domain models. */ @@ -94,6 +104,11 @@ object ABoxIconMapper { 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 } } diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt index e129305..7fbe760 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/ABoxRendererAdapter.kt @@ -6,10 +6,7 @@ import com.flagship.abox.manager.preferences.DesignRenderer internal interface ABoxRendererAdapter { @Composable - fun Render( - resolution: ABoxThemeResolution, - content: @Composable () -> Unit, - ) + fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit) } internal object ABoxRendererAdapters { diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt index 394a3dd..cf481c6 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/renderer/MiuixRendererAdapter.kt @@ -13,11 +13,11 @@ internal object MiuixRendererAdapter : ABoxRendererAdapter { content: @Composable () -> Unit, ) { val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme() - MiuixTheme( - colors = colors, - ) { - Material3RendererAdapter.Render( - resolution = resolution, + 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, ) } diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt index 004b947..a21e57f 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color 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.DesignRenderer import com.flagship.abox.manager.preferences.UiPreferences @@ -121,7 +122,7 @@ object ABoxThemeResolver { val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) { dynamicColorTokens } else { - brandTokens + brandTokens.withAccent(preferences.accentPreference, isDarkTheme) } val shapeTokens = when (preferences.renderer) { DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes @@ -137,3 +138,14 @@ object ABoxThemeResolver { ) } } + +private fun ABoxColorTokens.withAccent(accent: AccentPreference, dark: Boolean): ABoxColorTokens { + val selected = when (accent) { + AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF385F9D) + AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF247A4B) + AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFF8A5800) + AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA43C59) + AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF6D52A2) + } + return copy(primary = selected, deviceSocket = selected) +} diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt index ffe44d0..45b2afd 100644 --- a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceDao.kt @@ -16,4 +16,7 @@ interface ConfiguredDeviceDao { @Query("DELETE FROM configured_devices WHERE profileId = :profileId AND deviceName = :deviceName") suspend fun remove(profileId: String, deviceName: String): Int + + @Query("UPDATE configured_devices SET id = :newId, deviceName = :newName WHERE profileId = :profileId AND deviceName = :oldName") + suspend fun rename(profileId: String, oldName: String, newName: String, newId: String): Int } diff --git a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt index c29616f..c618af4 100644 --- a/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt +++ b/android/core/devicecatalog/src/main/kotlin/com/flagship/abox/manager/devicecatalog/data/ConfiguredDeviceRepository.kt @@ -15,6 +15,8 @@ interface ConfiguredDeviceRepository { fun observe(profileId: ProviderProfileId): Flow>> suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult + suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult = + DomainResult.Failure(DomainError.InvalidRequest(detail = "Device rename is not supported")) } class RoomConfiguredDeviceRepository( @@ -44,6 +46,17 @@ class RoomConfiguredDeviceRepository( } catch (error: Exception) { DomainResult.Failure(DomainError.Unknown(detail = error.message ?: "Unable to remove device")) } + + override suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult { + if (newName.value.isBlank()) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name is required")) + return try { + val changed = dao.rename(profileId.value, oldName.value, newName.value, "${profileId.value}:${newName.value}") + if (changed == 1) DomainResult.Success(Unit) + else DomainResult.Failure(DomainError.InvalidRequest(detail = "Device was not found")) + } catch (error: Exception) { + DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name")) + } + } } private fun ConfiguredDeviceEntity.toDomain() = DeviceDescriptor( diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt index 9127d88..e2f57e7 100644 --- a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepository.kt @@ -64,6 +64,16 @@ class DataStoreUiPreferencesRepository internal constructor( navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD) ?: NavigationPresentation.STANDARD, hapticsEnabled = preferences[HAPTICS] ?: true, + interfaceScale = preferences[INTERFACE_SCALE]?.enumOrDefault(InterfaceScale.STANDARD) + ?: InterfaceScale.STANDARD, + accentPreference = preferences[ACCENT]?.enumOrDefault(AccentPreference.BLUE) + ?: AccentPreference.BLUE, + colorStyle = preferences[COLOR_STYLE]?.enumOrDefault(ColorStyle.TONAL_SPOT) + ?: ColorStyle.TONAL_SPOT, + colorStandard = preferences[COLOR_STANDARD]?.enumOrDefault(ColorStandard.SPEC_2021) + ?: ColorStandard.SPEC_2021, + liquidGlassEnabled = preferences[LIQUID_GLASS] ?: false, + predictiveBackEnabled = preferences[PREDICTIVE_BACK] ?: false, ) private fun MutablePreferences.putAll(preferences: UiPreferences) { @@ -73,6 +83,12 @@ class DataStoreUiPreferencesRepository internal constructor( this[BLUR] = preferences.blurEnabled this[NAVIGATION] = preferences.navigationPresentation.name this[HAPTICS] = preferences.hapticsEnabled + this[INTERFACE_SCALE] = preferences.interfaceScale.name + this[ACCENT] = preferences.accentPreference.name + this[COLOR_STYLE] = preferences.colorStyle.name + this[COLOR_STANDARD] = preferences.colorStandard.name + this[LIQUID_GLASS] = preferences.liquidGlassEnabled + this[PREDICTIVE_BACK] = preferences.predictiveBackEnabled } private inline fun > String.enumOrDefault(default: T): T = @@ -89,5 +105,11 @@ class DataStoreUiPreferencesRepository internal constructor( val BLUR = booleanPreferencesKey("blur") val NAVIGATION = stringPreferencesKey("navigation") val HAPTICS = booleanPreferencesKey("haptics") + val INTERFACE_SCALE = stringPreferencesKey("interface_scale") + val ACCENT = stringPreferencesKey("accent") + val COLOR_STYLE = stringPreferencesKey("color_style") + val COLOR_STANDARD = stringPreferencesKey("color_standard") + val LIQUID_GLASS = booleanPreferencesKey("liquid_glass") + val PREDICTIVE_BACK = booleanPreferencesKey("predictive_back") } } diff --git a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt index 429f7c3..d6222c5 100644 --- a/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt +++ b/android/core/preferences/src/main/kotlin/com/flagship/abox/manager/preferences/UiPreferences.kt @@ -16,6 +16,31 @@ enum class NavigationPresentation { FLOATING, } +enum class InterfaceScale(val percent: Int) { + COMPACT(85), + STANDARD(100), + LARGE(115), +} + +enum class AccentPreference { + BLUE, + GREEN, + AMBER, + ROSE, + VIOLET, +} + +enum class ColorStyle { + TONAL_SPOT, + EXPRESSIVE, + FIDELITY, +} + +enum class ColorStandard { + SPEC_2021, + SPEC_2025, +} + enum class PreferenceField { RENDERER, THEME_MODE, @@ -23,6 +48,12 @@ enum class PreferenceField { BLUR, NAVIGATION, HAPTICS, + INTERFACE_SCALE, + ACCENT, + COLOR_STYLE, + COLOR_STANDARD, + LIQUID_GLASS, + PREDICTIVE_BACK, } data class UiPreferences( @@ -32,4 +63,10 @@ data class UiPreferences( val blurEnabled: Boolean = false, val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD, val hapticsEnabled: Boolean = true, + val interfaceScale: InterfaceScale = InterfaceScale.STANDARD, + val accentPreference: AccentPreference = AccentPreference.BLUE, + val colorStyle: ColorStyle = ColorStyle.TONAL_SPOT, + val colorStandard: ColorStandard = ColorStandard.SPEC_2021, + val liquidGlassEnabled: Boolean = false, + val predictiveBackEnabled: Boolean = false, ) diff --git a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt index bc71d3e..46ba8ca 100644 --- a/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt +++ b/android/core/preferences/src/test/kotlin/com/flagship/abox/manager/preferences/DataStoreUiPreferencesRepositoryTest.kt @@ -45,6 +45,24 @@ class DataStoreUiPreferencesRepositoryTest { assertEquals(UiPreferencesWriteResult.Success, repository.update { it.copy(hapticsEnabled = false) }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(interfaceScale = InterfaceScale.LARGE) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(accentPreference = AccentPreference.GREEN) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(colorStyle = ColorStyle.EXPRESSIVE) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(colorStandard = ColorStandard.SPEC_2025) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(liquidGlassEnabled = true) + }) + assertEquals(UiPreferencesWriteResult.Success, repository.update { + it.copy(predictiveBackEnabled = false) + }) assertEquals( UiPreferences( @@ -54,6 +72,12 @@ class DataStoreUiPreferencesRepositoryTest { blurEnabled = true, navigationPresentation = NavigationPresentation.FLOATING, hapticsEnabled = false, + interfaceScale = InterfaceScale.LARGE, + accentPreference = AccentPreference.GREEN, + colorStyle = ColorStyle.EXPRESSIVE, + colorStandard = ColorStandard.SPEC_2025, + liquidGlassEnabled = true, + predictiveBackEnabled = false, ), repository.observe().first(), ) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt index 5e30a10..fe1fd9f 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/data/RoomActivityRepository.kt @@ -37,13 +37,25 @@ private fun ActivityEventEntity.toDomain() = ActivityEvent( id = ActivityEventId(id), profileId = ProviderProfileId(profileId), occurredAtEpochMillis = occurredAtEpochMillis, - kind = ActivityEventKind.valueOf(kind), - source = ActivitySource.valueOf(source), - outcome = ActivityOutcome.valueOf(outcome), - target = ActivityTarget(ActivityTargetType.valueOf(targetType), targetStableId, targetDisplayName), + kind = kind.toActivityEventKind(), + source = source.toActivitySource(), + outcome = outcome.toActivityOutcome(), + target = ActivityTarget(targetType.toActivityTargetType(), targetStableId, targetDisplayName), errorCode = errorCode, ) +private fun String.toActivityEventKind(): ActivityEventKind = + runCatching { ActivityEventKind.valueOf(this) }.getOrDefault(ActivityEventKind.SCENE_ACTION) + +private fun String.toActivitySource(): ActivitySource = + runCatching { ActivitySource.valueOf(this) }.getOrDefault(ActivitySource.SYSTEM) + +private fun String.toActivityOutcome(): ActivityOutcome = + runCatching { ActivityOutcome.valueOf(this) }.getOrDefault(ActivityOutcome.FAILED) + +private fun String.toActivityTargetType(): ActivityTargetType = + runCatching { ActivityTargetType.valueOf(this) }.getOrDefault(ActivityTargetType.SCENE_ACTION) + private fun ActivityEvent.toEntity() = ActivityEventEntity( profileId = profileId.value, id = id.value, diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt index 19b142a..6d5d42c 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/di/AutomationModule.kt @@ -6,6 +6,7 @@ import com.flagship.abox.manager.automation.data.AutomationDatabase import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository import com.flagship.abox.manager.automation.data.RoomActivityRepository import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository +import com.flagship.abox.manager.domain.ActivityEventWriter import com.flagship.abox.manager.domain.ActivityRepository import com.flagship.abox.manager.domain.NotificationGateway import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway @@ -15,6 +16,9 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) @@ -28,6 +32,11 @@ object AutomationModule { @Singleton fun provideActivityRepository(database: AutomationDatabase): ActivityRepository = RoomActivityRepository(database.automationDao()) + @Provides + @Singleton + fun provideActivityEventWriter(repository: ActivityRepository): ActivityEventWriter = + ActivityEventWriter(repository, CoroutineScope(SupervisorJob() + Dispatchers.IO)) + @Provides @Singleton fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao()) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt index a0a2258..329d536 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/notification/AndroidNotificationGateway.kt @@ -5,6 +5,7 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager +import android.annotation.SuppressLint import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -25,7 +26,9 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT), ) return try { - NotificationManagerCompat.from(context).notify( + @SuppressLint("MissingPermission") + fun postNotification() { + NotificationManagerCompat.from(context).notify( profileId.value.hashCode(), NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_dialog_info) @@ -33,7 +36,9 @@ class AndroidNotificationGateway(private val context: Context) : NotificationGat .setContentText(message) .setAutoCancel(true) .build(), - ) + ) + } + postNotification() DomainResult.Success(Unit) } catch (_: SecurityException) { DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted")) diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt index 0518550..b8515cb 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityScreen.kt @@ -3,18 +3,26 @@ package com.flagship.abox.manager.automation.ui.activity import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.flagship.abox.manager.domain.ActivityEvent import com.flagship.abox.manager.domain.ActivityOutcome +import com.flagship.abox.manager.domain.ActivityEventKind +import com.flagship.abox.manager.domain.ActivitySource +import com.flagship.abox.manager.automation.R import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter @@ -27,16 +35,19 @@ fun ActivityScreen( zoneId: ZoneId = ZoneId.systemDefault(), ) { Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text("活动记录", style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.activity_title), style = MaterialTheme.typography.headlineSmall) if (!state.loading && state.groups.isEmpty()) { - Text("暂无活动记录", modifier = Modifier.testTag("activity_empty")) + Text( + if (state.error) stringResource(R.string.activity_error) else stringResource(R.string.activity_empty), + modifier = Modifier.testTag("activity_empty"), + ) } else { LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("activity_timeline")) { state.groups.forEach { group -> item { Text(group.label, style = MaterialTheme.typography.titleMedium) } - items(group.events, key = { it.id }) { event -> ActivityEventRow(event, zoneId) } + items(group.events, key = { it.id.value }) { event -> ActivityEventRow(event, zoneId) } } - if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text("加载更早记录") } } + if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text(stringResource(R.string.activity_load_earlier)) } } } } } @@ -45,13 +56,39 @@ fun ActivityScreen( @Composable private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) { val outcome = when (event.outcome) { - ActivityOutcome.SUCCEEDED -> "成功" - ActivityOutcome.FAILED -> "失败" - ActivityOutcome.CANCELLED -> "已取消" - ActivityOutcome.AWAITING_CONFIRMATION -> "等待确认" + ActivityOutcome.SUCCEEDED -> R.string.activity_outcome_success + ActivityOutcome.FAILED -> R.string.activity_outcome_failed + ActivityOutcome.CANCELLED -> R.string.activity_outcome_cancelled + ActivityOutcome.AWAITING_CONFIRMATION -> R.string.activity_outcome_awaiting } - Column(modifier = Modifier.fillMaxSize().testTag("activity_event_${event.id.value}")) { - Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge) - Text("$outcome · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}") + val kind = when (event.kind) { + ActivityEventKind.SOCKET_COMMAND -> R.string.activity_kind_socket_command + ActivityEventKind.SOCKET_CONFIRMATION -> R.string.activity_kind_socket_confirmation + ActivityEventKind.SENSOR_REFRESH -> R.string.activity_kind_sensor_refresh + ActivityEventKind.INFRARED_SEND -> R.string.activity_kind_infrared_send + ActivityEventKind.INFRARED_LEARN -> R.string.activity_kind_infrared_learn + ActivityEventKind.SCENE_EXECUTION -> R.string.activity_kind_scene_execution + ActivityEventKind.SCENE_ACTION -> R.string.activity_kind_scene_action + else -> R.string.activity_title + } + val source = when (event.source) { + ActivitySource.USER -> R.string.activity_source_user + ActivitySource.AUTOMATION -> R.string.activity_source_automation + ActivitySource.SYSTEM -> R.string.activity_source_system + } + Card( + modifier = Modifier.fillMaxWidth().testTag("activity_event_${event.id.value}"), + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(kind), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge) + Text( + "${stringResource(outcome)} · ${stringResource(source)} · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}", + style = MaterialTheme.typography.bodyMedium, + ) + event.errorCode?.let { Text(stringResource(R.string.activity_error_code, it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } } } diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt index 23cafe1..a274729 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt +++ b/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/ui/activity/ActivityViewModel.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,12 +44,14 @@ class ActivityViewModel @Inject constructor( observation?.cancel() _state.value = ActivityUiState(profileId = profileId) observation = viewModelScope.launch { - repository.observeLatest(profileId, 100).collectLatest { result -> + repository.observeLatest(profileId, 100) + .catch { _state.value = _state.value.copy(loading = false, error = true) } + .collectLatest { result -> when (result) { is DomainResult.Success -> publish(result.value, zoneId) is DomainResult.Failure -> _state.value = _state.value.copy(loading = false, error = true) } - } + } } } diff --git a/android/feature/automation/src/main/res/values-zh-rCN/strings.xml b/android/feature/automation/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..e5ba976 --- /dev/null +++ b/android/feature/automation/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,21 @@ + + 活动记录 + 暂无活动记录 + 活动记录加载失败 + 加载更早记录 + 手动操作 + 自动化 + 系统 + 成功 + 失败 + 已取消 + 等待确认 + 插座控制 + 插座状态确认 + 传感器刷新 + 红外发送 + 红外学习 + 场景执行 + 场景动作 + 错误码:%1$s + diff --git a/android/feature/automation/src/main/res/values/strings.xml b/android/feature/automation/src/main/res/values/strings.xml new file mode 100644 index 0000000..d97cdbd --- /dev/null +++ b/android/feature/automation/src/main/res/values/strings.xml @@ -0,0 +1,21 @@ + + Activity + No activity yet + Activity could not be loaded. + Load earlier activity + Manual + Automation + System + Succeeded + Failed + Cancelled + Awaiting confirmation + Socket command + Socket confirmation + Sensor refresh + Infrared signal + Infrared learning + Scene execution + Scene action + Error code: %1$s + diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt new file mode 100644 index 0000000..4669901 --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreenTest.kt @@ -0,0 +1,57 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +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 org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredHubScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun rendersHardwareSummaryAndRemoteCollection() { + val profileId = ProviderProfileId("p1") + val remote = VirtualRemote( + id = VirtualRemoteId("remote-1"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + displayName = "电视遥控器", + type = RemoteDeviceType.TV, + transmissionMode = TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) + + composeRule.setContent { + InfraredHubScreen( + state = InfraredHubUiState( + profileId = profileId, + savedDeviceName = DeviceName("ir-1"), + remotes = listOf(remote), + loading = false, + ), + onBack = {}, + onNavigateToSettings = {}, + onOpenRemote = {}, + onDeleteRemote = {}, + onCreateRemote = { _, _, _, _, _ -> }, + onSessionInvalid = {}, + onMessageShown = {}, + ) + } + + composeRule.onNodeWithTag("infrared_hardware_summary").assertIsDisplayed() + composeRule.onNodeWithTag("infrared_remote_collection").assertIsDisplayed() + } +} diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt new file mode 100644 index 0000000..a264766 --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreenTest.kt @@ -0,0 +1,66 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +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 org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RemotePanelScreenTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun acPanelUsesTemperatureStepperSurface() { + composeRule.setContent { + RemotePanelScreen( + state = RemotePanelUiState(remote = remote(PanelLayoutStyle.AC_COMPACT, RemoteDeviceType.AIR_CONDITIONER), loading = false), + onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {}, + onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {}, + onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {}, + onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {}, + ) + } + + composeRule.onNodeWithTag("ac_temperature_stepper").assertIsDisplayed() + composeRule.onNodeWithTag("ac_mode_selector").assertIsDisplayed() + } + + @Test + fun tvPanelUsesDirectionalPadAndRockers() { + composeRule.setContent { + RemotePanelScreen( + state = RemotePanelUiState(remote = remote(PanelLayoutStyle.TV_NAV, RemoteDeviceType.TV), loading = false), + onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {}, + onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {}, + onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {}, + onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {}, + ) + } + + composeRule.onNodeWithTag("tv_direction_pad").assertIsDisplayed() + composeRule.onNodeWithTag("tv_volume_channel_rockers").assertIsDisplayed() + } + + private fun remote(layout: PanelLayoutStyle, type: RemoteDeviceType) = VirtualRemote( + id = VirtualRemoteId("remote-${layout.name}"), + profileId = ProviderProfileId("p1"), + deviceName = DeviceName("ir-1"), + displayName = "测试遥控器", + type = type, + layoutStyle = layout, + transmissionMode = if (layout == PanelLayoutStyle.AC_COMPACT) TransmissionMode.DYNAMIC_SCRATCH else TransmissionMode.STATIC_SLOTS, + createdAtEpochMillis = 1L, + updatedAtEpochMillis = 1L, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt index 1a40b17..fafb030 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt @@ -211,11 +211,26 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi displayName = displayName, assignedSlot = assignedSlot, rawHexCode = rawHexCode, - status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY), + status = resolvedStatus(), lastErrorCode = lastErrorCode, lastErrorDetail = lastErrorDetail, ) +private fun RemoteKeyBindingEntity.resolvedStatus(): InfraredKeyStatus { + val parsed = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.UNLEARNED) + return if ( + parsed == InfraredKeyStatus.FAILED && + lastErrorCode == null && + lastErrorDetail == null && + assignedSlot == null && + rawHexCode == null + ) { + InfraredKeyStatus.UNLEARNED + } else { + parsed + } +} + private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity( id = id, remoteId = remoteId.value, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt new file mode 100644 index 0000000..a41f19f --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredCenterControls.kt @@ -0,0 +1,414 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +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.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Home +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Menu +import androidx.compose.material.icons.outlined.PowerSettingsNew +import androidx.compose.material.icons.outlined.Remove +import androidx.compose.material.icons.outlined.Tv +import androidx.compose.material.icons.outlined.VolumeOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote + +@Composable +internal fun InfraredCenterControlSurface( + state: InfraredHubUiState, + onButtonClick: (String) -> Unit, + onAddCustomButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onToggleAcPower: () -> Unit, + onAdjustAcTemp: (Int) -> Unit, + onSetAcMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleAcVerticalSwing: () -> Unit, + onToggleAcHorizontalSwing: () -> Unit, + onToggleAcEco: () -> Unit, + modifier: Modifier = Modifier, +) { + val remote = state.selectedRemote ?: return + when (remote.layoutStyle) { + PanelLayoutStyle.AC_COMPACT -> CenterAcPanel( + state = state, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleVerticalSwing = onToggleAcVerticalSwing, + onToggleHorizontalSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + modifier = modifier, + ) + PanelLayoutStyle.TV_NAV -> CenterTvPanel(state, onButtonClick, modifier) + PanelLayoutStyle.MEDIA_PLAYER -> CenterMediaPanel(state, onButtonClick, modifier) + PanelLayoutStyle.GRID -> CenterGridPanel( + remote = remote, + bindings = state.bindings, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onAddButton = onAddCustomButton, + onDeleteButton = onDeleteButton, + modifier = modifier, + ) + } +} + +@Composable +private fun CenterAcPanel( + state: InfraredHubUiState, + onTogglePower: () -> Unit, + onAdjustTemp: (Int) -> Unit, + onSetMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleVerticalSwing: () -> Unit, + onToggleHorizontalSwing: () -> Unit, + onToggleEco: () -> Unit, + modifier: Modifier, +) { + val ac = state.acState + Column( + modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier.fillMaxWidth(), + ) { + Box(Modifier.fillMaxWidth().padding(20.dp)) { + IconButton( + onClick = onTogglePower, + modifier = Modifier.align(Alignment.TopEnd).background( + if (ac.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline, + CircleShape, + ), + ) { + Icon(Icons.Outlined.PowerSettingsNew, "空调电源", tint = Color.White) + } + Row( + Modifier.fillMaxWidth().padding(top = 38.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + StepButton(Icons.Outlined.Remove, "降低温度") { onAdjustTemp(-1) } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("${ac.temperature}°", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold) + Text(stringResource(R.string.infrared_set_temperature), style = MaterialTheme.typography.labelMedium) + } + StepButton(Icons.Outlined.Add, "升高温度") { onAdjustTemp(1) } + } + } + } + CompactOptionGroup(stringResource(R.string.infrared_running_mode)) { + AcMode.entries.forEach { mode -> + FilterChip(ac.mode == mode, { onSetMode(mode) }, { Text(mode.centerLabel()) }) + } + } + CompactOptionGroup(stringResource(R.string.infrared_fan_speed)) { + AcFanSpeed.entries.forEach { speed -> + FilterChip(ac.fanSpeed == speed, { onSetFanSpeed(speed) }, { Text(speed.centerLabel()) }) + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + CompactToggle(stringResource(R.string.infrared_vertical_swing), ac.verticalSwing, onToggleVerticalSwing, Modifier.weight(1f)) + CompactToggle(stringResource(R.string.infrared_horizontal_swing), ac.horizontalSwing, onToggleHorizontalSwing, Modifier.weight(1f)) + CompactToggle(stringResource(R.string.infrared_eco), ac.eco, onToggleEco, Modifier.weight(1f)) + } + Text( + stringResource(R.string.infrared_transmission_notice), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun CenterTvPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) { + Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + RemoteIconKey("TV_POWER", stringResource(R.string.infrared_key_tv_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick) + RemoteIconKey("TV_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick) + RemoteIconKey("TV_SOURCE", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Tv, state, onButtonClick) + } + Card( + shape = RoundedCornerShape(6.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + modifier = Modifier.fillMaxWidth(), + ) { + Box(Modifier.fillMaxWidth().height(260.dp), contentAlignment = Alignment.Center) { + Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(220.dp)) { + Box(Modifier.size(220.dp), contentAlignment = Alignment.Center) { + DirectionKey("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, stringResource(R.string.infrared_up), Alignment.TopCenter, state, onButtonClick) + DirectionKey("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, stringResource(R.string.infrared_down), Alignment.BottomCenter, state, onButtonClick) + DirectionKey("TV_NAV_LEFT", Icons.AutoMirrored.Outlined.KeyboardArrowLeft, stringResource(R.string.infrared_left), Alignment.CenterStart, state, onButtonClick) + DirectionKey("TV_NAV_RIGHT", Icons.AutoMirrored.Outlined.KeyboardArrowRight, stringResource(R.string.infrared_right), Alignment.CenterEnd, state, onButtonClick) + Button(onClick = { onButtonClick("TV_OK") }, shape = CircleShape, modifier = Modifier.size(72.dp)) { Text("OK") } + } + } + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RemoteTextKey("TV_BACK", stringResource(R.string.infrared_key_tv_back), state, onButtonClick, Modifier.weight(1f)) + RemoteIconTextKey("TV_HOME", stringResource(R.string.infrared_key_tv_home), Icons.Outlined.Home, state, onButtonClick, Modifier.weight(1f)) + RemoteIconTextKey("TV_MENU", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Menu, state, onButtonClick, Modifier.weight(1f)) + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Rocker(stringResource(R.string.infrared_volume), "TV_VOL_UP", "TV_VOL_DOWN", state, onButtonClick, Modifier.weight(1f)) + Rocker(stringResource(R.string.infrared_channel), "TV_CH_UP", "TV_CH_DOWN", state, onButtonClick, Modifier.weight(1f)) + } + } +} + +@Composable +private fun CenterMediaPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) { + Column(modifier.padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + RemoteIconKey("MEDIA_POWER", stringResource(R.string.infrared_key_ac_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick) + RemoteIconKey("MEDIA_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick) + } + Text(stringResource(R.string.infrared_media_panel), style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.CenterHorizontally)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("MEDIA_PREV" to "上一项", "MEDIA_PLAY_PAUSE" to "播放/暂停", "MEDIA_NEXT" to "下一项").forEach { (id, label) -> + RemoteTextKey(id, label, state, onButtonClick, Modifier.weight(1f)) + } + } + } +} + +@Composable +private fun CenterGridPanel( + remote: VirtualRemote, + bindings: List, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, + onAddButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + modifier: Modifier, +) { + var showAddDialog by remember(remote.id) { mutableStateOf(false) } + Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + stringResource(R.string.infrared_remote_keys, remote.displayName), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Button(onClick = { showAddDialog = true }, shape = RoundedCornerShape(6.dp)) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Text(stringResource(R.string.infrared_add_button), modifier = Modifier.padding(start = 6.dp)) + } + } + if (bindings.isEmpty()) { + Text(stringResource(R.string.infrared_no_buttons_manage), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + LazyVerticalGrid(columns = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(bindings, key = { it.id }) { binding -> + Card( + modifier = Modifier.fillMaxWidth().clickable { onButtonClick(binding.buttonId) }, + shape = RoundedCornerShape(6.dp), + ) { + Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + binding.displayName, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + when { + learningButtonId == binding.buttonId -> stringResource(R.string.infrared_button_learning) + sendingButtonId == binding.buttonId -> stringResource(R.string.infrared_button_sending) + binding.status == InfraredKeyStatus.READY -> stringResource(R.string.infrared_button_ready) + binding.status == InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_button_failed) + binding.status == InfraredKeyStatus.UNLEARNED -> stringResource(R.string.infrared_button_unlearned) + else -> stringResource(R.string.infrared_button_unlearned) + }, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton( + onClick = { onDeleteButton(binding.buttonId) }, + modifier = Modifier.size(48.dp), + ) { + Icon(Icons.Outlined.Remove, contentDescription = stringResource(R.string.infrared_delete_button)) + } + } + } + } + } + } + } + if (showAddDialog) { + AddGridButtonDialog( + onDismiss = { showAddDialog = false }, + onConfirm = { name -> + onAddButton(name) + showAddDialog = false + }, + ) + } +} + +@Composable +private fun AddGridButtonDialog( + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.infrared_add_button_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.infrared_add_button_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text(stringResource(R.string.infrared_button_name)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) { + Text(stringResource(R.string.infrared_add_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) +} + +@Composable private fun StepButton(icon: ImageVector, description: String, onClick: () -> Unit) { + IconButton(onClick = onClick, modifier = Modifier.size(52.dp).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(6.dp))) { + Icon(icon, description) + } +} + +@Composable private fun CompactOptionGroup(title: String, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) { + Card(shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), content = content) + } + } +} + +@Composable private fun CompactToggle(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier) { + Surface( + modifier = modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(6.dp), + color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + ) { Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 14.dp)) } +} + +@Composable private fun RemoteIconKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(84.dp).clickable { onClick(id) }) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(20.dp)) else Icon(icon, label) + Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp)) + } + } +} + +@Composable private fun BoxScope.DirectionKey(id: String, icon: ImageVector, description: String, alignment: Alignment, state: InfraredHubUiState, onClick: (String) -> Unit) { + IconButton(onClick = { onClick(id) }, modifier = Modifier.align(alignment).padding(10.dp)) { + if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Icon(icon, description, Modifier.size(30.dp)) + } +} + +@Composable private fun RemoteTextKey(id: String, label: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) { + Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) { + if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Text(label) + } +} + +@Composable private fun RemoteIconTextKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) { + Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) { + Icon(icon, label, Modifier.size(18.dp)); Text(label, Modifier.padding(start = 5.dp)) + } +} + +@Composable private fun Rocker(title: String, upId: String, downId: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) { + Card(modifier, shape = RoundedCornerShape(6.dp)) { + Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold) + IconButton({ onClick(upId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Add, "提高$title") } + IconButton({ onClick(downId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Remove, "降低$title") } + } + } +} + +private fun AcMode.centerLabel() = when (this) { AcMode.COOL -> "制冷"; AcMode.HEAT -> "制热"; AcMode.AUTO -> "自动"; AcMode.DRY -> "除湿"; AcMode.FAN -> "送风" } +private fun AcFanSpeed.centerLabel() = when (this) { AcFanSpeed.AUTO -> "自动"; AcFanSpeed.LOW -> "低速"; AcFanSpeed.MEDIUM -> "中速"; AcFanSpeed.HIGH -> "高速" } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index b8bb0a7..ef9d92f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -2,6 +2,8 @@ package com.flagship.abox.manager.infrared.hub import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -16,6 +18,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog @@ -24,6 +30,8 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.Icon import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults @@ -40,6 +48,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.SwapVert +import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -50,7 +61,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -60,6 +72,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxIconButton +import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold +import com.flagship.abox.manager.designsystem.icon.ABoxIcon import com.flagship.abox.manager.designsystem.icon.ABoxIconKey import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle @@ -71,6 +85,8 @@ import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.UiText +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode @Composable fun InfraredHubRoute( @@ -89,6 +105,21 @@ fun InfraredHubRoute( onOpenRemote = onOpenRemote, onDeleteRemote = viewModel::deleteRemote, onCreateRemote = viewModel::createRemote, + onSaveDeviceName = viewModel::saveDeviceName, + onMoveRemote = viewModel::moveRemote, + onUpdateRemote = viewModel::updateRemote, + onSelectRemote = viewModel::selectRemote, + onToggleLearnMode = viewModel::toggleLearnMode, + onButtonClick = viewModel::onButtonClick, + onAddCustomButton = viewModel::addCustomButton, + onDeleteButton = viewModel::deleteButton, + onToggleAcPower = viewModel::toggleAcPower, + onAdjustAcTemp = viewModel::adjustAcTemperature, + onSetAcMode = viewModel::setAcMode, + onSetFanSpeed = viewModel::setAcFanSpeed, + onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing, + onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing, + onToggleAcEco = viewModel::toggleAcEco, onSessionInvalid = onSessionInvalid, onMessageShown = viewModel::clearMessage, ) @@ -103,12 +134,30 @@ internal fun InfraredHubScreen( onOpenRemote: (String) -> Unit, onDeleteRemote: (VirtualRemoteId) -> Unit, onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit, + onSaveDeviceName: (String) -> Unit = {}, + onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> }, + onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> }, onSessionInvalid: () -> Unit, onMessageShown: () -> Unit, + onSelectRemote: (VirtualRemoteId) -> Unit = {}, + onToggleLearnMode: () -> Unit = {}, + onButtonClick: (String) -> Unit = {}, + onAddCustomButton: (String) -> Unit = {}, + onDeleteButton: (String) -> Unit = {}, + onToggleAcPower: () -> Unit = {}, + onAdjustAcTemp: (Int) -> Unit = {}, + onSetAcMode: (AcMode) -> Unit = {}, + onSetFanSpeed: (AcFanSpeed) -> Unit = {}, + onToggleAcVerticalSwing: () -> Unit = {}, + onToggleAcHorizontalSwing: () -> Unit = {}, + onToggleAcEco: () -> Unit = {}, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current var showCreateDialog by remember { mutableStateOf(false) } + var showManagement by remember { mutableStateOf(false) } + + BackHandler(enabled = showManagement) { showManagement = false } LaunchedEffect(state.message) { state.message?.let { msg -> @@ -116,7 +165,7 @@ internal fun InfraredHubScreen( is HubUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -124,7 +173,7 @@ internal fun InfraredHubScreen( is HubUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -137,99 +186,114 @@ internal fun InfraredHubScreen( } } - Scaffold( - topBar = { - TopAppBar( - title = { - Column { - Text( - text = stringResource(R.string.infrared_hub_title), - fontWeight = FontWeight.Bold, - ) - state.providerDisplayName?.let { - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - }, - navigationIcon = { - ABoxIconButton(ABoxIconKey.BACK, "返回", onBack) - }, - actions = { - ABoxIconButton( - key = ABoxIconKey.EDIT, - contentDescription = "打开红外设置", - onClick = onNavigateToSettings, - modifier = Modifier.testTag("hub_settings_button"), - ) - }, - ) + ABoxDeviceDetailScaffold( + title = state.savedDeviceName.value, + subtitle = if (showManagement) stringResource(R.string.infrared_settings_subtitle) else stringResource(R.string.infrared_center_subtitle), + icon = ABoxIconKey.INFRARED, + onBack = if (showManagement) ({ showManagement = false }) else onBack, + action = { + if (!showManagement) { + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = stringResource(R.string.infrared_manage_action), + onClick = { showManagement = true }, + modifier = Modifier.testTag("hub_settings_button"), + ) + } }, - snackbarHost = { SnackbarHost(snackbarHostState) }, ) { innerPadding -> ABoxSurface( modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { - Column( + if (showManagement) { + InfraredManagementScreen( + state = state, + onAddRemote = { showCreateDialog = true }, + onDeleteRemote = onDeleteRemote, + onSaveDeviceName = onSaveDeviceName, + onMoveRemote = onMoveRemote, + onUpdateRemote = onUpdateRemote, + ) + } else Column( modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - // 1. 硬件槽位配额看板 (100 槽位预算) - QuotaSummaryCard(quota = state.slotQuota) - - // 2. 遥控器列表标题与新建按钮 - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.infrared_hub_my_remotes), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - ) - - Button( - onClick = { showCreateDialog = true }, - modifier = Modifier.testTag("hub_add_remote_button"), - ) { - Text(stringResource(R.string.infrared_hub_add_remote)) - } - } - - // 3. 遥控器网格卡片 if (state.remotes.isEmpty()) { Box( modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center, ) { - Text( - text = stringResource(R.string.infrared_hub_no_remotes), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) { + ABoxIcon(ABoxIconKey.INFRARED, stringResource(R.string.infrared_hub_no_remotes), modifier = Modifier.size(48.dp)) + Text(stringResource(R.string.infrared_hub_no_remotes), style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.infrared_no_remotes_hint), color = MaterialTheme.colorScheme.onSurfaceVariant) + Button(onClick = { showManagement = true }) { Text(stringResource(R.string.infrared_open_management)) } + } } } else { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxWidth().weight(1f), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + LazyRow( + modifier = Modifier.fillMaxWidth().testTag("infrared_remote_collection"), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(state.remotes, key = { it.id.value }) { remote -> - VirtualRemoteCard( - remote = remote, - onClick = { onOpenRemote(remote.id.value) }, - onDelete = { onDeleteRemote(remote.id) }, + FilterChip( + selected = state.selectedRemoteId == remote.id, + onClick = { onSelectRemote(remote.id) }, + label = { Text(remote.displayName) }, + leadingIcon = { + ABoxIcon( + key = remoteIcon(remote.type), + contentDescription = remote.displayName, + modifier = Modifier.size(18.dp), + ) + }, ) } } + + state.selectedRemote?.let { remote -> + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(remote.displayName, fontWeight = FontWeight.Bold) + Text( + text = "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (remote.type == RemoteDeviceType.CUSTOM) { + FilterChip( + selected = state.isLearnMode, + onClick = onToggleLearnMode, + label = { Text(if (state.isLearnMode) stringResource(R.string.infrared_recording) else stringResource(R.string.infrared_learn_mode)) }, + ) + } + } + } + InfraredCenterControlSurface( + state = state, + onButtonClick = onButtonClick, + onAddCustomButton = onAddCustomButton, + onDeleteButton = onDeleteButton, + onToggleAcPower = onToggleAcPower, + onAdjustAcTemp = onAdjustAcTemp, + onSetAcMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleAcVerticalSwing = onToggleAcVerticalSwing, + onToggleAcHorizontalSwing = onToggleAcHorizontalSwing, + onToggleAcEco = onToggleAcEco, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } } } } @@ -244,6 +308,226 @@ internal fun InfraredHubScreen( }, ) } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier.fillMaxSize().padding(bottom = 16.dp), + ) +} + +@Composable +private fun StableSingleLineField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier, + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), + ) { + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + BasicTextField( + value = value, + onValueChange = onValueChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +private fun InfraredManagementScreen( + state: InfraredHubUiState, + onAddRemote: () -> Unit, + onDeleteRemote: (VirtualRemoteId) -> Unit, + onSaveDeviceName: (String) -> Unit, + onMoveRemote: (VirtualRemoteId, Int) -> Unit, + onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit, +) { + var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) } + var reorderRemoteId by remember { mutableStateOf(null) } + var editingRemote by remember { mutableStateOf(null) } + Column( + modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.infrared_hardware_slots_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + InfraredHardwareSummary( + deviceName = state.savedDeviceName.value, + remoteCount = state.remotes.size, + quota = state.slotQuota, + ) + } + + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.infrared_hub_remotes_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.infrared_remote_count, state.remotes.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Button(onClick = onAddRemote) { Text(stringResource(R.string.infrared_add_remote)) } + } + + state.remotes.forEach { remote -> + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) { + Row(Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) { + Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) { + ABoxIcon( + remoteIcon(remote.type), + remote.displayName, + modifier = Modifier.padding(9.dp).size(24.dp), + ) + } + Column(Modifier.weight(1f).padding(start = 12.dp)) { + Text(remote.displayName, fontWeight = FontWeight.Bold) + Text( + "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box { + IconButton(onClick = { reorderRemoteId = remote.id }) { + Icon(Icons.Outlined.SwapVert, contentDescription = stringResource(R.string.infrared_move_remote)) + } + DropdownMenu( + expanded = reorderRemoteId == remote.id, + onDismissRequest = { reorderRemoteId = null }, + ) { + val index = state.remotes.indexOf(remote) + DropdownMenuItem( + text = { Text(stringResource(R.string.infrared_move_up)) }, + enabled = index > 0, + onClick = { reorderRemoteId = null; onMoveRemote(remote.id, -1) }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.infrared_move_down)) }, + enabled = index < state.remotes.lastIndex, + onClick = { reorderRemoteId = null; onMoveRemote(remote.id, 1) }, + ) + } + } + ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote }) + } + } + } + + Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text(stringResource(R.string.infrared_gateway_name_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.infrared_gateway_name_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + StableSingleLineField( + value = deviceName, + onValueChange = { deviceName = it }, + label = stringResource(R.string.infrared_device_name_label), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { onSaveDeviceName(deviceName.trim()) }, + enabled = deviceName.isNotBlank() && deviceName != state.savedDeviceName.value, + modifier = Modifier.align(Alignment.End), + ) { Text(stringResource(R.string.infrared_save_name)) } + } + } + Spacer(Modifier.height(24.dp)) + } + editingRemote?.let { remote -> + EditRemoteDialog( + remote = remote, + onDismiss = { editingRemote = null }, + onSave = { name, layout, mode -> + editingRemote = null + onUpdateRemote(remote.id, name, layout, mode) + }, + onDelete = { + editingRemote = null + onDeleteRemote(remote.id) + }, + ) + } +} + +@Composable +private fun EditRemoteDialog( + remote: VirtualRemote, + onDismiss: () -> Unit, + onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit, + onDelete: () -> Unit, +) { + var name by remember(remote.id) { mutableStateOf(remote.displayName) } + var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) } + var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.infrared_edit_remote)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true) + Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold) + PanelLayoutStyle.entries.forEach { option -> + Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = layout == option, onClick = { layout = option }) + Text(layoutLabel(option)) + } + } + Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold) + TransmissionMode.entries.forEach { option -> + Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = mode == option, onClick = { mode = option }) + Text(transmissionLabel(option)) + } + } + TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } }, + ) +} + +private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) { + RemoteDeviceType.TV -> ABoxIconKey.DEVICES + RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE + RemoteDeviceType.FAN -> ABoxIconKey.REFRESH + RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED +} + +@Composable +private fun layoutLabel(layout: PanelLayoutStyle): String = when (layout) { + PanelLayoutStyle.TV_NAV -> stringResource(R.string.infrared_layout_tv) + PanelLayoutStyle.AC_COMPACT -> stringResource(R.string.infrared_layout_ac) + PanelLayoutStyle.MEDIA_PLAYER -> stringResource(R.string.infrared_layout_media) + PanelLayoutStyle.GRID -> stringResource(R.string.infrared_layout_grid) +} + +@Composable +private fun transmissionLabel(mode: TransmissionMode): String = when (mode) { + TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_static_slots) + TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes) } @Composable @@ -271,15 +555,18 @@ private fun VirtualRemoteCard( verticalAlignment = Alignment.CenterVertically, ) { val icon = when (remote.type) { - RemoteDeviceType.TV -> "电视" - RemoteDeviceType.AIR_CONDITIONER -> "空调" - RemoteDeviceType.FAN -> "🌀" - RemoteDeviceType.CUSTOM -> "自定义" - } - Text(icon, fontSize = 28.sp) - IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) { - Text(stringResource(R.string.infrared_delete)) + RemoteDeviceType.TV -> ABoxIconKey.DEVICES + RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE + RemoteDeviceType.FAN -> ABoxIconKey.REFRESH + RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED } + ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp)) + ABoxIconButton( + key = ABoxIconKey.CLOSE, + contentDescription = "删除${remote.displayName}", + onClick = onDelete, + modifier = Modifier.testTag("delete_remote_${remote.id.value}"), + ) } Text( @@ -305,9 +592,13 @@ private fun VirtualRemoteCard( } @Composable -private fun QuotaSummaryCard(quota: SlotQuotaSummary) { +private fun InfraredHardwareSummary( + deviceName: String, + remoteCount: Int, + quota: SlotQuotaSummary, +) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("infrared_hardware_summary"), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant, ), @@ -323,13 +614,28 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Surface( + shape = RoundedCornerShape(14.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + ABoxIcon( + key = ABoxIconKey.INFRARED, + contentDescription = "红外硬件", + modifier = Modifier.padding(10.dp).size(26.dp), + ) + } + Column { + Text(deviceName, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Text( + text = "红外控制器 · $remoteCount 个虚拟遥控器", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Text( - text = "硬件槽位配额 (总计 100 槽位)", - fontWeight = FontWeight.Bold, - fontSize = 14.sp, - ) - Text( - text = "${quota.usedCount} / ${quota.totalCapacity} 已占用", + text = "${quota.usedCount}/${quota.totalCapacity}", fontWeight = FontWeight.Bold, color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, fontSize = 13.sp, @@ -345,7 +651,7 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) { ) Text( - text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个", + text = "静态槽位 ${quota.assignedStaticSlots.size} 个 · 动态缓冲槽位 100 · 剩余 ${quota.remainingStaticCount} 个", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt index 06d10b5..94d501d 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt @@ -4,7 +4,10 @@ import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.infrared.remote.UiText sealed interface HubUiMessage { @@ -19,6 +22,13 @@ data class InfraredHubUiState( val providerDisplayName: String? = null, val savedDeviceName: DeviceName = DeviceName("红外控制器"), val remotes: List = emptyList(), + val selectedRemoteId: VirtualRemoteId? = null, + val selectedRemote: VirtualRemote? = null, + val bindings: List = emptyList(), + val acState: AcStateSnapshot = AcStateSnapshot(), + val isLearnMode: Boolean = false, + val learningButtonId: String? = null, + val sendingButtonId: String? = null, val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), val loading: Boolean = true, val message: HubUiMessage? = null, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt index 5952514..5099ccd 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -9,9 +9,23 @@ import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileStore +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.ActivityEventWriter import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote +import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot import com.flagship.abox.manager.domain.infrared.RemoteDeviceType import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.domain.infrared.TransmissionMode @@ -30,7 +44,10 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import javax.inject.Inject @@ -43,12 +60,23 @@ class InfraredHubViewModel @Inject constructor( private val virtualRemoteRepository: VirtualRemoteRepository, private val createVirtualRemote: CreateVirtualRemote, private val deleteVirtualRemote: DeleteVirtualRemote, + private val sendVirtualRemoteAction: SendVirtualRemoteAction, + private val learnVirtualRemoteKey: LearnVirtualRemoteKey, + private val addVirtualRemoteKey: AddVirtualRemoteKey, + private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey, + private val activityWriter: ActivityEventWriter, ) : ViewModel() { private val rawProfileId: String? = savedStateHandle["profileId"] val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } private val messageFlow = MutableStateFlow(null) + private val selectedRemoteIdFlow = MutableStateFlow(null) + private val isLearnModeFlow = MutableStateFlow(false) + private val learningButtonIdFlow = MutableStateFlow(null) + private val sendingButtonIdFlow = MutableStateFlow(null) + private val localAcStateFlow = MutableStateFlow(null) + private var acDebounceJob: Job? = null private val routeDeviceName: DeviceName? = savedStateHandle.get("deviceName") ?.takeIf { it.isNotBlank() } ?.let(::DeviceName) @@ -96,10 +124,51 @@ class InfraredHubViewModel @Inject constructor( } } + private data class PanelSnapshot( + val selectedId: VirtualRemoteId?, + val remote: VirtualRemote?, + val bindings: List, + ) + + private val panelSnapshotFlow = combine(hubSnapshotFlow, selectedRemoteIdFlow) { hub, selectedId -> + val resolvedId = selectedId?.takeIf { id -> hub.remotes.any { it.id == id } } ?: hub.remotes.firstOrNull()?.id + resolvedId to hub + }.flatMapLatest { (selectedId, hub) -> + if (selectedId == null) { + flowOf(PanelSnapshot(null, null, emptyList())) + } else { + virtualRemoteRepository.observeKeyBindings(selectedId).map { result -> + PanelSnapshot( + selectedId, + hub.remotes.firstOrNull { it.id == selectedId }, + if (result is DomainResult.Success) result.value else emptyList(), + ) + } + } + } + + private data class CenterActions( + val learnMode: Boolean, + val learningButtonId: String?, + val sendingButtonId: String?, + val localAcState: AcStateSnapshot?, + ) + + private val centerActionsFlow = combine( + isLearnModeFlow, + learningButtonIdFlow, + sendingButtonIdFlow, + localAcStateFlow, + ) { learnMode, learningId, sendingId, localAc -> + CenterActions(learnMode, learningId, sendingId, localAc) + } + val state: StateFlow = combine( hubSnapshotFlow, + panelSnapshotFlow, + centerActionsFlow, messageFlow, - ) { snapshot, msg -> + ) { snapshot, panel, actions, msg -> val resolvedMessage = msg ?: snapshot.configError?.let { error -> when (error) { is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid @@ -119,6 +188,13 @@ class InfraredHubViewModel @Inject constructor( providerDisplayName = snapshot.providerProfile?.displayName, savedDeviceName = snapshot.savedDevice, remotes = snapshot.remotes, + selectedRemoteId = panel.selectedId, + selectedRemote = panel.remote, + bindings = panel.bindings, + acState = actions.localAcState ?: panel.remote?.acState ?: AcStateSnapshot(), + isLearnMode = actions.learnMode, + learningButtonId = actions.learningButtonId, + sendingButtonId = actions.sendingButtonId, slotQuota = snapshot.slotQuota, loading = false, message = resolvedMessage, @@ -168,6 +244,155 @@ class InfraredHubViewModel @Inject constructor( } } + fun selectRemote(remoteId: VirtualRemoteId) { + selectedRemoteIdFlow.value = remoteId + localAcStateFlow.value = null + isLearnModeFlow.value = false + } + + fun moveRemote(remoteId: VirtualRemoteId, offset: Int) { + val remotes = state.value.remotes + val from = remotes.indexOfFirst { it.id == remoteId } + val to = from + offset + if (from !in remotes.indices || to !in remotes.indices) return + val current = remotes[from] + val target = remotes[to] + viewModelScope.launch { + val first = virtualRemoteRepository.updateRemote( + current.copy(createdAtEpochMillis = target.createdAtEpochMillis), + ) + val second = virtualRemoteRepository.updateRemote( + target.copy(createdAtEpochMillis = current.createdAtEpochMillis), + ) + if (first is DomainResult.Failure) handleDomainError(first.error) + else if (second is DomainResult.Failure) handleDomainError(second.error) + } + } + + fun updateRemote( + remoteId: VirtualRemoteId, + displayName: String, + layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle, + transmissionMode: TransmissionMode, + ) { + val remote = state.value.remotes.firstOrNull { it.id == remoteId } ?: return + val trimmed = displayName.trim() + if (trimmed.isBlank()) return + viewModelScope.launch { + when (val result = virtualRemoteRepository.updateRemote( + remote.copy( + displayName = trimmed, + layoutStyle = layoutStyle, + transmissionMode = transmissionMode, + updatedAtEpochMillis = System.currentTimeMillis(), + ), + )) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器配置已保存")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + + fun toggleLearnMode() { + if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return + isLearnModeFlow.update { !it } + } + + fun onButtonClick(buttonId: String) { + if (isLearnModeFlow.value) learnButton(buttonId) else sendButton(buttonId) + } + + private fun sendButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + if (sendingButtonIdFlow.value != null || learningButtonIdFlow.value != null) return + sendingButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val result = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.SUCCEEDED) + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态")) + } + is DomainResult.Failure -> { + recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.FAILED, result.error.externalCode) + handleDomainError(result.error) + } + } + } finally { + sendingButtonIdFlow.value = null + } + } + } + + private fun learnButton(buttonId: String) { + if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + if (learningButtonIdFlow.value != null) return + learningButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val result = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.SUCCEEDED) + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键信号已学习并保存")) + } + is DomainResult.Failure -> { + recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.FAILED, result.error.externalCode) + handleDomainError(result.error) + } + } + } finally { + learningButtonIdFlow.value = null + } + } + } + + fun addCustomButton(displayName: String) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + viewModelScope.launch { + when (val result = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已添加按键")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + + fun deleteButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + viewModelScope.launch { + when (val result = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键已删除")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + + fun toggleAcPower() = updateAc(state.value.acState.copy(power = !state.value.acState.power)) + fun adjustAcTemperature(delta: Int) = updateAc(state.value.acState.copy(temperature = (state.value.acState.temperature + delta).coerceIn(16, 30))) + fun setAcMode(mode: AcMode) = updateAc(state.value.acState.copy(mode = mode)) + fun setAcFanSpeed(speed: AcFanSpeed) = updateAc(state.value.acState.copy(fanSpeed = speed)) + fun toggleAcVerticalSwing() = updateAc(state.value.acState.copy(verticalSwing = !state.value.acState.verticalSwing)) + fun toggleAcHorizontalSwing() = updateAc(state.value.acState.copy(horizontalSwing = !state.value.acState.horizontalSwing)) + fun toggleAcEco() = updateAc(state.value.acState.copy(eco = !state.value.acState.eco)) + + private fun updateAc(newState: AcStateSnapshot) { + val currentProfile = profileId ?: return + val currentRemoteId = state.value.selectedRemoteId ?: return + localAcStateFlow.value = newState + acDebounceJob?.cancel() + acDebounceJob = viewModelScope.launch { + delay(300L) + when (val result = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) { + is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态")) + is DomainResult.Failure -> handleDomainError(result.error) + } + } + } + fun saveDeviceName(newName: String) { val currentProfile = profileId ?: return val trimmed = newName.trim() @@ -189,6 +414,19 @@ class InfraredHubViewModel @Inject constructor( messageFlow.value = null } + private fun recordRemoteActivity(kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null) { + val currentProfile = profileId ?: return + val remote = state.value.selectedRemote ?: return + activityWriter.record( + profileId = currentProfile, + kind = kind, + source = ActivitySource.USER, + outcome = outcome, + target = ActivityTarget(ActivityTargetType.REMOTE, remote.id.value, remote.displayName), + errorCode = errorCode, + ) + } + private fun handleDomainError(error: DomainError) { when (error) { is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt index 654a178..daa8a8a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -16,8 +16,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +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.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -61,7 +63,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -131,7 +133,7 @@ internal fun RemotePanelScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current var showAddKeyDialog by remember { mutableStateOf(false) } @@ -141,7 +143,7 @@ internal fun RemotePanelScreen( is PanelUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -149,7 +151,7 @@ internal fun RemotePanelScreen( is PanelUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -191,8 +193,7 @@ internal fun RemotePanelScreen( ABoxIconButton(ABoxIconKey.BACK, "返回", onBack) }, actions = { - // 非空调面板显示学习模式开关 - if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) { + if (state.remote?.type == com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) { FilterChip( selected = state.isLearnMode, onClick = onToggleLearnMode, @@ -244,52 +245,20 @@ internal fun RemotePanelScreen( } } - // 面板内容区 - Box(modifier = Modifier.fillMaxSize().weight(1f)) { - when (remote.layoutStyle) { - PanelLayoutStyle.AC_COMPACT -> { - AcControlPanel( - acState = state.acState, - onTogglePower = onToggleAcPower, - onAdjustTemp = onAdjustAcTemp, - onSetMode = onSetAcMode, - onSetFanSpeed = onSetFanSpeed, - onToggleVSwing = onToggleAcVerticalSwing, - onToggleHSwing = onToggleAcHorizontalSwing, - onToggleEco = onToggleAcEco, - ) - } - PanelLayoutStyle.TV_NAV -> { - TvControlPanel( - bindings = state.bindings, - isLearnMode = state.isLearnMode, - learningButtonId = state.learningButtonId, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) - } - PanelLayoutStyle.MEDIA_PLAYER -> { - MediaPlayerControlPanel( - bindings = state.bindings, - isLearnMode = state.isLearnMode, - learningButtonId = state.learningButtonId, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) - } - PanelLayoutStyle.GRID -> { - GenericGridControlPanel( - bindings = state.bindings, - isLearnMode = state.isLearnMode, - learningButtonId = state.learningButtonId, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - onDeleteButton = onDeleteButton, - onAddButton = { showAddKeyDialog = true }, - ) - } - } - } + UnifiedRemoteControlSurface( + state = state, + onButtonClick = onButtonClick, + onAddCustomButton = onAddCustomButton, + onDeleteButton = onDeleteButton, + onToggleAcPower = onToggleAcPower, + onAdjustAcTemp = onAdjustAcTemp, + onSetAcMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleAcVerticalSwing = onToggleAcVerticalSwing, + onToggleAcHorizontalSwing = onToggleAcHorizontalSwing, + onToggleAcEco = onToggleAcEco, + modifier = Modifier.fillMaxSize().weight(1f), + ) } } } @@ -320,12 +289,13 @@ private fun AcControlPanel( Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { Card( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("ac_temperature_stepper"), colors = CardDefaults.cardColors( containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, ), @@ -359,21 +329,9 @@ private fun AcControlPanel( } } - Text( - text = "${acState.temperature}°C", - fontSize = 64.sp, - fontWeight = FontWeight.Black, - color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Text( - text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(32.dp), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, ) { Button( @@ -383,6 +341,15 @@ private fun AcControlPanel( ) { Icon(Icons.Outlined.Remove, contentDescription = "降低温度") } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "${acState.temperature}°", + fontSize = 56.sp, + fontWeight = FontWeight.Black, + color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text("设定温度", style = MaterialTheme.typography.labelMedium) + } Button( onClick = { onAdjustTemp(1) }, modifier = Modifier.size(56.dp).testTag("ac_temp_up"), @@ -391,21 +358,27 @@ private fun AcControlPanel( Icon(Icons.Outlined.Add, contentDescription = "升高温度") } } + Text( + text = "本地选择:${acState.mode.name} · ${acState.fanSpeed.name}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AcMode.entries.forEach { mode -> - FilterChip( - selected = acState.mode == mode, - onClick = { onSetMode(mode) }, - label = { Text(mode.name) }, - modifier = Modifier.weight(1f), - ) + Card(modifier = Modifier.fillMaxWidth().testTag("ac_mode_selector")) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("运行模式", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + AcMode.entries.forEach { mode -> + FilterChip( + selected = acState.mode == mode, + onClick = { onSetMode(mode) }, + label = { Text(modeLabel(mode)) }, + modifier = Modifier.weight(1f), + ) + } + } } } @@ -418,7 +391,7 @@ private fun AcControlPanel( FilterChip( selected = acState.fanSpeed == speed, onClick = { onSetFanSpeed(speed) }, - label = { Text(speed.name) }, + label = { Text(fanSpeedLabel(speed)) }, modifier = Modifier.weight(1f), ) } @@ -463,6 +436,7 @@ private fun TvControlPanel( Column( modifier = Modifier .fillMaxSize() + .verticalScroll(rememberScrollState()) .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), @@ -483,7 +457,7 @@ private fun TvControlPanel( // 中部五向方向导航盘 Card( shape = CircleShape, - modifier = Modifier.size(220.dp), + modifier = Modifier.size(220.dp).testTag("tv_direction_pad"), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), ) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -492,7 +466,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_UP") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), ) { - ButtonIndicatorText("TV_NAV_UP", "上", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, "向上", learningButtonId, sendingButtonId) } // 下 @@ -500,7 +474,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_DOWN") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), ) { - ButtonIndicatorText("TV_NAV_DOWN", "下", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, "向下", learningButtonId, sendingButtonId) } // 左 @@ -508,7 +482,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_LEFT") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), ) { - ButtonIndicatorText("TV_NAV_LEFT", "左", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_LEFT", Icons.Outlined.KeyboardArrowLeft, "向左", learningButtonId, sendingButtonId) } // 右 @@ -516,7 +490,7 @@ private fun TvControlPanel( onClick = { onButtonClick("TV_NAV_RIGHT") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), ) { - ButtonIndicatorText("TV_NAV_RIGHT", "右", learningButtonId, sendingButtonId) + DirectionIndicator("TV_NAV_RIGHT", Icons.Outlined.KeyboardArrowRight, "向右", learningButtonId, sendingButtonId) } // OK 居中大按钮 @@ -535,14 +509,14 @@ private fun TvControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround, ) { - InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_BACK", "返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_HOME", "主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MENU", "菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } // 底部音量与频道调节柱 Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("tv_volume_channel_rockers"), horizontalArrangement = Arrangement.SpaceEvenly, ) { Column( @@ -550,11 +524,11 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Add, contentDescription = "提高音量") } - Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Remove, contentDescription = "降低音量") } } @@ -563,17 +537,118 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_CH_UP", "频道上调", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Add, contentDescription = "频道上调") } - Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { - ButtonIndicatorText("TV_CH_DOWN", "频道下调", learningButtonId, sendingButtonId) + Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) { + Icon(Icons.Outlined.Remove, contentDescription = "频道下调") } } } } } +@Composable +fun UnifiedRemoteControlSurface( + state: RemotePanelUiState, + onButtonClick: (String) -> Unit, + onAddCustomButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onToggleAcPower: () -> Unit, + onAdjustAcTemp: (Int) -> Unit, + onSetAcMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleAcVerticalSwing: () -> Unit, + onToggleAcHorizontalSwing: () -> Unit, + onToggleAcEco: () -> Unit, + modifier: Modifier = Modifier, +) { + var showAddKeyDialog by remember { mutableStateOf(false) } + val remote = state.remote + Box(modifier = modifier) { + if (remote == null) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + when (remote.layoutStyle) { + PanelLayoutStyle.AC_COMPACT -> AcControlPanel( + acState = state.acState, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetFanSpeed, + onToggleVSwing = onToggleAcVerticalSwing, + onToggleHSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + ) + PanelLayoutStyle.TV_NAV -> TvControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + PanelLayoutStyle.MEDIA_PLAYER -> MediaPlayerControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + PanelLayoutStyle.GRID -> GenericGridControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onDeleteButton = onDeleteButton, + onAddButton = { showAddKeyDialog = true }, + ) + } + } + } + if (showAddKeyDialog) { + AddCustomKeyDialog( + onDismiss = { showAddKeyDialog = false }, + onConfirm = { + showAddKeyDialog = false + onAddCustomButton(it) + }, + ) + } +} + +@Composable +private fun DirectionIndicator( + buttonId: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + description: String, + learningButtonId: String?, + sendingButtonId: String?, +) { + when (buttonId) { + learningButtonId -> Text("录制中", fontSize = 11.sp, fontWeight = FontWeight.Bold) + sendingButtonId -> CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else -> Icon(icon, contentDescription = description, modifier = Modifier.size(30.dp)) + } +} + +private fun modeLabel(mode: AcMode): String = when (mode) { + AcMode.COOL -> "制冷" + AcMode.HEAT -> "制热" + AcMode.AUTO -> "自动" + AcMode.DRY -> "除湿" + AcMode.FAN -> "送风" +} + +private fun fanSpeedLabel(speed: AcFanSpeed): String = when (speed) { + AcFanSpeed.AUTO -> "自动" + AcFanSpeed.LOW -> "低速" + AcFanSpeed.MEDIUM -> "中速" + AcFanSpeed.HIGH -> "高速" +} + @Composable private fun MediaPlayerControlPanel( bindings: List, @@ -709,7 +784,8 @@ private fun GridButtonCard( val statusText = when { isLearning -> "正在录制信号..." binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})" - else -> "待录制(点击录码)" + binding.status == InfraredKeyStatus.FAILED -> "学习失败(请重试)" + else -> "未学习(点击录码)" } Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt index 6027600..1cbd3a0 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt @@ -129,6 +129,7 @@ class RemotePanelViewModel @Inject constructor( ) fun toggleLearnMode() { + if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return val newMode = !isLearnModeFlow.value isLearnModeFlow.value = newMode if (!newMode) cancelLearning() @@ -166,6 +167,7 @@ class RemotePanelViewModel @Inject constructor( } fun learnButton(buttonId: String) { + if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return val currentProfile = profileId ?: return val currentRemoteId = remoteId ?: return if (state.value.learningButtonId != null) return diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 50057c9..ae2f0c1 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -204,7 +204,8 @@ class InfraredRemoteViewModel @Inject constructor( activeLearningSlotFlow.value = spec.defaultSlot val deferred = when (existingKey?.status) { InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) + InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> + learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) null -> learningCoordinator.learn( profileId = currentProfile, deviceName = currentDevice, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index 57b3678..f7216a8 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -35,7 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -89,7 +89,7 @@ internal fun InfraredRemoteScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current LaunchedEffect(state.message) { state.message?.let { msg -> @@ -97,7 +97,7 @@ internal fun InfraredRemoteScreen( is RemoteUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -105,7 +105,7 @@ internal fun InfraredRemoteScreen( is RemoteUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index c9b0674..3eded3d 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -33,7 +33,7 @@ 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.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -76,7 +76,7 @@ internal fun InfraredControlScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } - val context = LocalContext.current + val resources = LocalResources.current val scrollState = rememberScrollState() LaunchedEffect(state.message) { @@ -85,7 +85,7 @@ internal fun InfraredControlScreen( is InfraredUiMessage.Info -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() @@ -93,7 +93,7 @@ internal fun InfraredControlScreen( is InfraredUiMessage.Error -> { val text = when (val t = msg.text) { is UiText.DynamicString -> t.value - is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray()) } snackbarHostState.showSnackbar(text) onMessageShown() diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index b6b5e04..5c11827 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -73,9 +73,9 @@ 电视-音量- 电视-频道+ 电视-频道- - 电视-主页 - 电视-返回 - 电视-菜单 + 主页 + 返回 + 菜单 红外设备与环境配置 ABSDK 设备名称配置 ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。 @@ -114,4 +114,79 @@ 空调设备 🌀 电风扇 / 空气净化器 自定义面板 + 设定温度 + 运行模式 + 风速档位 + 制冷 + 制热 + 自动 + 除湿 + 送风 + 低速 + 中速 + 高速 + 上下扫风 + 左右扫风 + 节能 + 状态变更会生成完整红外帧并发送;信号发射不代表设备状态已确认。 + 向上 + 向下 + 向左 + 向右 + 音量 + 频道 + 提高音量 + 降低音量 + 频道上调 + 频道下调 + 媒体控制面板 + %1$s 个按键 + 暂无按键,请在管理面板中添加。 + 添加按键 + 添加自定义按键 + 先设置按键名称,录制红外信号时再分配硬件槽位。 + 按键名称 + 可用 + 正在学习 + 正在发送 + 可发送 + 学习失败 + 待学习 + 删除按键 + 红外控制器 · 遥控中心 + 红外设备设置 + 遥控器、槽位与网关设备名称 + 打开管理 + 使用管理操作添加遥控器 + 管理红外设备与遥控器 + 自定义 + 静态槽位 + 动态写码 + 电视导航面板 + 空调状态面板 + 媒体控制面板 + 自定义按键面板 + 学习模式 + 录制中 + 红外控制器 · %1$d 个虚拟遥控器 + 静态槽位 %1$d 个 · 动态缓冲槽位 100 · 剩余 %2$d 个 + 遥控器名称 + 电视导航布局,包含方向、音量和频道控制 + 媒体播放器布局,包含播放控制 + 自由网格布局,可增删按键 + 静态独占槽位模式 + 动态写码模式 + 硬件槽位 + %1$d 个遥控器 + 添加遥控器 + 调整遥控器顺序 + 上移 + 下移 + 编辑遥控器 + 网关设备名称 + 用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。 + 保存设备名称 + 面板布局 + 传输模式 + 删除遥控器 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 9deb20c..a93e5c1 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -114,4 +114,79 @@ Air Conditioner (AC) 🌀 Fan / Air Purifier Custom Panel + Set temperature + Operating mode + Fan speed + Cool + Heat + Auto + Dry + Fan + Low + Medium + High + Vertical swing + Horizontal swing + Eco + State changes generate a complete infrared frame and send it. Transmission does not confirm the appliance state. + Up + Down + Left + Right + Volume + Channel + Raise volume + Lower volume + Raise channel + Lower channel + Media control panel + %1$s buttons + No buttons. Add one in management. + Add button + Add custom button + Name the button now. Its hardware slot will be assigned when you record the infrared signal. + Button name + Available + Learning + Sending + Ready to send + Learning failed + Not learned + Delete button + Infrared controller · Remote center + Infrared device settings + Remotes, slots, and gateway device name + Open management + Use the management action to add a remote + Manage infrared devices and remotes + Custom + Static slots + Dynamic codes + TV navigation panel + Air conditioner panel + Media control panel + Custom button panel + Learn mode + Recording + Infrared controller · %1$d virtual remotes + Static slots: %1$d · Dynamic buffer: slot 100 · Free: %2$d + Remote name + TV navigation layout with direction, volume, and channel controls + Media player layout with playback controls + Custom grid layout with editable buttons + Static slot mode + Dynamic code mode + Hardware slots + %1$d remotes + Add remote + Reorder remote + Move up + Move down + Edit remote + Gateway device name + Used to match the infrared controller in the gateway. It must exactly match the gateway name. + Save device name + Panel layout + Transmission mode + Delete remote diff --git a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt similarity index 54% rename from android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt rename to domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt index ac36bc7..6cf9c04 100644 --- a/android/feature/automation/src/main/kotlin/com/flagship/abox/manager/automation/activity/ActivityEventWriter.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/ActivityEventWriter.kt @@ -1,15 +1,5 @@ -package com.flagship.abox.manager.automation.activity +package com.flagship.abox.manager.domain -import com.flagship.abox.manager.domain.ActivityEvent -import com.flagship.abox.manager.domain.ActivityEventId -import com.flagship.abox.manager.domain.ActivityEventKind -import com.flagship.abox.manager.domain.ActivityOutcome -import com.flagship.abox.manager.domain.ActivityRepository -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.DeviceName -import com.flagship.abox.manager.domain.ProviderProfileId import java.util.UUID import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -24,6 +14,25 @@ class ActivityEventWriter( kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null, + source: ActivitySource = ActivitySource.USER, + ) { + record( + profileId = profileId, + kind = kind, + source = source, + outcome = outcome, + target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value), + errorCode = errorCode, + ) + } + + fun record( + profileId: ProviderProfileId, + kind: ActivityEventKind, + source: ActivitySource, + outcome: ActivityOutcome, + target: ActivityTarget, + errorCode: String? = null, ) { scope.launch { runCatching { @@ -33,9 +42,9 @@ class ActivityEventWriter( profileId = profileId, occurredAtEpochMillis = System.currentTimeMillis(), kind = kind, - source = ActivitySource.USER, + source = source, outcome = outcome, - target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value), + target = target, errorCode = errorCode, ), ) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt index c642da1..01db39a 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -92,8 +92,7 @@ class InMemorySceneRepository : SceneRepository { private fun flowFor(profileId: ProviderProfileId): MutableStateFlow>> = flows.getOrPut(profileId) { - val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList() - MutableStateFlow(DomainResult.Success(initial)) + MutableStateFlow(DomainResult.Success(defaultScenes(profileId))) } private fun currentScenes(profileId: ProviderProfileId): List? = @@ -107,12 +106,30 @@ class InMemorySceneRepository : SceneRepository { DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"), ) - private fun defaultLocalScenes(profileId: ProviderProfileId): List = listOf( + private fun defaultScenes(profileId: ProviderProfileId): List = listOf( + Scene( + id = SceneId("${profileId.value}:scene-arriving-home"), + profileId = profileId, + name = "回家模式", + description = "开启 Zigbee 插座控制器", + type = AutomationType.MANUAL, + enabled = true, + icon = SceneIcon.HOME, + color = SceneColor("#4CAF50"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-arriving-home:socket-1"), + profileId = profileId, + deviceName = DeviceName("Zigbee插座控制器"), + powerState = SocketPowerState.ON, + ), + ), + ), Scene( id = SceneId("${profileId.value}:scene-leaving-home"), profileId = profileId, name = "离家模式", - description = "关闭插座并延时关闭电视", + description = "关闭 Zigbee 插座控制器", type = AutomationType.MANUAL, enabled = true, icon = SceneIcon.AWAY, @@ -124,16 +141,32 @@ class InMemorySceneRepository : SceneRepository { deviceName = DeviceName("Zigbee插座控制器"), powerState = SocketPowerState.OFF, ), - SceneAction.Delay( - id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"), - profileId = profileId, - durationSeconds = 1, + ), + ), + Scene( + id = SceneId("${profileId.value}:scene-temperature-alert"), + profileId = profileId, + name = "高温提醒", + description = "室温高于 28°C 时发送提醒", + type = AutomationType.AUTOMATIC, + enabled = true, + icon = SceneIcon.THERMOSTAT, + color = SceneColor("#EF5350"), + triggers = listOf( + SceneTrigger.Temperature( + id = SceneTriggerId("${profileId.value}:trigger-temp"), + deviceName = DeviceName("温湿度传感器"), + operator = ComparisonOperator.GREATER_THAN, + thresholdCelsius = 28.0, + label = "温度高于 28°C", ), - SceneAction.InfraredKey( - id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), + ), + actions = listOf( + SceneAction.Notification( + id = SceneActionId("${profileId.value}:scene-temperature-alert:notification-1"), profileId = profileId, - deviceName = DeviceName("红外控制器"), - key = "1", + title = "温度提醒", + message = "当前室温已高于 28°C", ), ), ), @@ -148,7 +181,7 @@ class InMemorySceneRepository : SceneRepository { color = SceneColor("#26A69A"), triggers = listOf( SceneTrigger.Temperature( - id = SceneTriggerId("${profileId.value}:trigger-temp"), + id = SceneTriggerId("${profileId.value}:trigger-cooling-temp"), deviceName = DeviceName("温湿度传感器"), operator = ComparisonOperator.GREATER_THAN, thresholdCelsius = 28.0, @@ -157,7 +190,7 @@ class InMemorySceneRepository : SceneRepository { ), conditions = listOf( SceneCondition.SocketPower( - id = SceneConditionId("${profileId.value}:cond-socket"), + id = SceneConditionId("${profileId.value}:cond-cooling-socket"), deviceName = DeviceName("Zigbee插座控制器"), expectedState = SocketPowerState.OFF, ), diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt index 95a89c2..4fd4d54 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt @@ -23,18 +23,6 @@ class AddVirtualRemoteKey( } val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase() - val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { - // Find next available slot - val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) { - is DomainResult.Success -> result.value - is DomainResult.Failure -> return result - } - val usedSlots = quota.assignedStaticSlots - val nextSlot = (1..99).firstOrNull { it !in usedSlots } - ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位")) - nextSlot.toString() - } else null - val binding = RemoteKeyBinding( id = UUID.randomUUID().toString(), remoteId = remoteId, @@ -42,9 +30,9 @@ class AddVirtualRemoteKey( hardwareDeviceName = remote.deviceName, buttonId = buttonId, displayName = trimmed, - assignedSlot = assignedSlot, + assignedSlot = null, rawHexCode = null, - status = InfraredKeyStatus.FAILED, // Initially unlearned + status = InfraredKeyStatus.UNLEARNED, ) when (val res = repository.updateKeyBinding(binding)) { diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt index c4c333d..fecb403 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt @@ -69,7 +69,7 @@ class CreateVirtualRemote( displayName = template.displayName, assignedSlot = (index + 1).toString(), rawHexCode = template.defaultHexCode, - status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED, + status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.UNLEARNED, ) } } else { @@ -86,7 +86,7 @@ class CreateVirtualRemote( status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) { InfraredKeyStatus.READY } else { - InfraredKeyStatus.FAILED + InfraredKeyStatus.UNLEARNED }, ) } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt index 9c0a2ec..edf0179 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyModels.kt @@ -12,6 +12,7 @@ data class InfraredKeyRef( ) enum class InfraredKeyStatus { + UNLEARNED, LEARNING, READY, FAILED, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 43919ad..a172739 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -181,6 +181,7 @@ class RelearnInfraredKey( val result = repository.transitionStatus( ref = ref, expectedStatuses = setOf( + InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING, diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt index 5b6f3e2..2d8eeb6 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt @@ -18,6 +18,11 @@ class LearnVirtualRemoteKey( is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) is DomainResult.Failure -> return res } + if (remote.type != RemoteDeviceType.CUSTOM) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Only custom remotes support learning"), + ) + } val bindings = when (val res = repository.getKeyBindings(remoteId)) { is DomainResult.Success -> res.value is DomainResult.Failure -> return res diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt index 4d34693..20d657c 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt @@ -85,7 +85,7 @@ data class RemoteKeyBinding( val displayName: String, val assignedSlot: String? = null, val rawHexCode: String? = null, - val status: InfraredKeyStatus = InfraredKeyStatus.READY, + val status: InfraredKeyStatus = InfraredKeyStatus.UNLEARNED, val lastErrorCode: String? = null, val lastErrorDetail: String? = null, ) diff --git a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt index 3bb677e..d72455d 100644 --- a/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt +++ b/runtime/simulation/src/main/kotlin/com/flagship/abox/manager/simulation/SimulationDeviceCatalog.kt @@ -29,21 +29,21 @@ class SimulationDeviceCatalog : DeviceCatalog { private fun defaultDevices(profileId: ProviderProfileId): List = listOf( DeviceDescriptor( - id = DeviceId("${profileId.value}:socket-1"), + id = DeviceId("${profileId.value}:Zigbee插座控制器"), profileId = profileId, - name = DeviceName("socket-1"), + name = DeviceName("Zigbee插座控制器"), type = DeviceType.SOCKET, ), DeviceDescriptor( - id = DeviceId("${profileId.value}:th-1"), + id = DeviceId("${profileId.value}:温湿度传感器"), profileId = profileId, - name = DeviceName("th-1"), + name = DeviceName("温湿度传感器"), type = DeviceType.TEMPERATURE_HUMIDITY, ), DeviceDescriptor( - id = DeviceId("${profileId.value}:ir-1"), + id = DeviceId("${profileId.value}:红外控制器"), profileId = profileId, - name = DeviceName("ir-1"), + name = DeviceName("红外控制器"), type = DeviceType.INFRARED, ), ) -- 2.54.0 From bdeddf46e229459eb3c73d4b5c63bde550e4d3a6 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Thu, 27 Aug 2026 21:59:18 +0800 Subject: [PATCH 39/40] feat(ui): update app launcher icons and bilingual app name --- android/app/src/main/AndroidManifest.xml | 2 ++ .../main/res/drawable/ic_abox_logo_master.png | Bin 0 -> 134351 bytes .../res/drawable/ic_launcher_background.xml | 3 +++ .../main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 +++++ .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 +++++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 4059 bytes .../res/mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 8919 bytes .../main/res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 4059 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 2247 bytes .../res/mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 4762 bytes .../main/res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2247 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 6323 bytes .../mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 14359 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 6323 bytes .../src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 12272 bytes .../mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 31310 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 12272 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 20442 bytes .../mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 54446 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 20442 bytes .../app/src/main/res/values-zh-rCN/strings.xml | 2 +- android/app/src/main/res/values/strings.xml | 2 +- .../abox/manager/ui/AppScreenshotSmokeTest.kt | 2 +- 23 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 android/app/src/main/res/drawable/ic_abox_logo_master.png create mode 100644 android/app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9472773..c934de3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ android:name=".ABoxManagerApplication" android:enableOnBackInvokedCallback="true" android:allowBackup="false" + android:icon="@mipmap/ic_launcher" + android:roundIcon="@mipmap/ic_launcher_round" android:label="@string/app_name" android:supportsRtl="true" android:usesCleartextTraffic="true" diff --git a/android/app/src/main/res/drawable/ic_abox_logo_master.png b/android/app/src/main/res/drawable/ic_abox_logo_master.png new file mode 100644 index 0000000000000000000000000000000000000000..c84077e49ac8512105fce5a9cb5b228ad9b52f47 GIT binary patch literal 134351 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 LAsDLa+;XXnKkXR7^l078fzK#DBH? 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 z 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..d3c3b9a --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,3 @@ + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3ba4e35 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3ba4e35 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ceb1c4960d4c12f8ac47082d6a39cc2b7966ea GIT binary patch literal 4059 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
    n-8sPDqmi=<0(( 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..b0ceb1c4960d4c12f8ac47082d6a39cc2b7966ea GIT binary patch literal 4059 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
    n-8sPDqmi=<0(( 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 V4l$c_Ttxr?002ovPDHLkV1kaeJNy6u literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..43aad777e02e0e675801cacb79a9338b37c368b5 GIT binary patch literal 4762 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 V4l$c_Ttxr?002ovPDHLkV1kaeJNy6u literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..606651acd50a9a147d40d475999d1fce637f3a6f GIT binary patch literal 6323 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 literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000000000000000000000000000000000000..5933b63077f926e563408150d7595588a26ce6d0 GIT binary patch literal 12272 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 R`f&gN002ovPDHLkV1mGcFYy2X literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000000000000000000000000000000000000..99d74d0573482f7d6f288ad45c1e9de9f33c7049 GIT binary patch literal 54446 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 R`f&gN002ovPDHLkV1mGcFYy2X literal 0 HcmV?d00001 diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 72c82f4..22d18d4 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -1,5 +1,5 @@ - ABox Manager + ABox 家庭 选择提供方 选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。 ABox diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 8ccbd56..a1216ed 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,5 +1,5 @@ - ABox Manager + ABox Home Choose a provider Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers. ABox diff --git a/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt index bd5b8aa..30756cb 100644 --- a/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt +++ b/android/app/src/screenshotTest/kotlin/com/flagship/abox/manager/ui/AppScreenshotSmokeTest.kt @@ -20,6 +20,6 @@ fun AppSmokePreview() { dynamicColorEnabled = false, ), ) { - Text("ABox Manager") + Text("ABox Home") } } -- 2.54.0 From a5f31486b45c2fb094409e78b20113501626ab4b Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Thu, 27 Aug 2026 23:20:39 +0800 Subject: [PATCH 40/40] fix(ui): stabilize theme and navigation behavior --- .../ui/preferences/SettingsScreenTest.kt | 2 - android/app/src/main/AndroidManifest.xml | 2 +- .../abox/manager/ui/ABoxManagerApp.kt | 22 ++-- .../ui/navigation/AuthenticatedNavigation.kt | 124 +++++++----------- .../manager/ui/preferences/SettingsScreen.kt | 110 +++++++++------- .../manager/ui/shell/AccountProviderDialog.kt | 4 +- .../src/main/res/values-zh-rCN/strings.xml | 8 +- android/app/src/main/res/values/strings.xml | 8 +- android/core/designsystem/build.gradle.kts | 1 + .../abox/manager/designsystem/Components.kt | 18 +++ .../abox/manager/designsystem/Theme.kt | 2 +- .../manager/designsystem/tokens/ABoxTokens.kt | 100 ++++++++++++-- .../tokens/ABoxThemeResolverTest.kt | 44 ++++++- .../manager/infrared/hub/InfraredHubScreen.kt | 57 ++++---- 14 files changed, 322 insertions(+), 180 deletions(-) diff --git a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt index 114d3eb..8a5d630 100644 --- a/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt +++ b/android/app/src/androidTest/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreenTest.kt @@ -57,7 +57,6 @@ class SettingsScreenTest { "settings-theme-mode", "settings-color-style", "settings-color-standard", - "settings-predictive-back", "settings-interface-scale", ) composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0) @@ -74,7 +73,6 @@ class SettingsScreenTest { "settings-blur", "settings-floating-navigation", "settings-liquid-glass", - "settings-predictive-back", "settings-interface-scale", ) composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c934de3..508eb01 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,7 +3,7 @@ Unit, onShowSettingsChange: (Boolean) -> Unit, ) { - if (showSettings) { - BackHandler { onShowSettingsChange(false) } - SettingsRoute( - experienceState = settingsExperienceState, - onExit = { onShowSettingsChange(false) }, - ) - } else { + Box(Modifier.fillMaxSize()) { AuthenticatedNavigation( username = username, profile = profile, @@ -122,6 +116,17 @@ private fun AuthenticatedNavHost( onProviderClick = { onShowAccountDialogChange(true) }, onAvatarClick = { onShowAccountDialogChange(true) }, ) + if (showSettings) { + ABoxBackLayer( + onBack = { onShowSettingsChange(false) }, + enabled = settingsExperienceState.isHome, + ) { + SettingsRoute( + experienceState = settingsExperienceState, + onExit = { onShowSettingsChange(false) }, + ) + } + } } if (showAccountDialog) { @@ -131,6 +136,7 @@ private fun AuthenticatedNavHost( onDismissRequest = { onShowAccountDialogChange(false) }, onOpenSettings = { onShowAccountDialogChange(false) + settingsExperienceState.showTheme(returnToHome = false) onShowSettingsChange(true) }, onSwitchProvider = { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt index 2f6d5db..c32b8f3 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/AuthenticatedNavigation.kt @@ -1,15 +1,11 @@ package com.flagship.abox.manager.ui.navigation import android.net.Uri -import androidx.activity.compose.BackHandler -import androidx.activity.compose.PredictiveBackHandler -import android.os.Build 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -17,14 +13,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableFloatStateOf 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.draw.clip -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel @@ -60,9 +53,6 @@ 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.designsystem.LocalABoxPredictiveBackEnabled -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.flow.collect import com.flagship.abox.manager.ui.socket.SocketScreen import com.flagship.abox.manager.ui.socket.SocketUiEvent import com.flagship.abox.manager.ui.socket.SocketViewModel @@ -169,12 +159,11 @@ fun AuthenticatedNavigationStateHost( } val selection = selectedDevice ?: return@LaunchedEffect - if (savedSelection != selection) { - savedSelection = selection - } - when (detailPresentation(windowSizeClass, selection)) { DetailPresentation.PANE -> { + if (savedSelection != selection) { + savedSelection = selection + } if (currentRoute != MainTab.DEVICES.route) { navController.popBackStack(MainTab.DEVICES.route, inclusive = false) } @@ -184,6 +173,9 @@ fun AuthenticatedNavigationStateHost( navController.navigate(deviceRoute(selection)) { launchSingleTop = true } + savedSelection = null + } else if (savedSelection != null) { + savedSelection = null } } } @@ -194,60 +186,25 @@ fun AuthenticatedNavigationStateHost( selectedDevice = selectedDevice, detailPresentation = selectedPresentation, ) - val canNavigateBack = selectedDevice != null || currentRoute != MainTab.DEVICES.route - val predictiveBackEnabled = LocalABoxPredictiveBackEnabled.current && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - var backProgress by remember { mutableFloatStateOf(0f) } - - Box( - modifier = Modifier.fillMaxSize() - .clip(RoundedCornerShape((28f * backProgress).dp)) - .graphicsLayer { - val scale = 1f - 0.04f * backProgress - scaleX = scale - scaleY = scale - translationX = 28.dp.toPx() * backProgress - }, - ) { - 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 - } + 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 }, - ) - } - - PredictiveBackHandler(enabled = canNavigateBack && predictiveBackEnabled) { events -> - try { - events.collect { event -> backProgress = event.progress } - savedSelection = null - navController.popCurrentDestinationUnlessRoot() - } catch (cancelled: CancellationException) { - throw cancelled - } finally { - backProgress = 0f - } - } - - BackHandler(enabled = canNavigateBack && !predictiveBackEnabled) { - savedSelection = null - navController.popCurrentDestinationUnlessRoot() - } + } + }, + { deviceName, type -> savedSelection = DeviceSelection(profileId, deviceName, type) }, + { savedSelection = null }, + ) } @Composable @@ -355,6 +312,23 @@ private fun AuthenticatedNavHost( 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, @@ -427,14 +401,14 @@ private fun AuthenticatedNavHost( deviceDetailGraph( onBackClick = { onClearDeviceSelection() - navController.popBackStack() + navigateBackFromDetail() }, ) sceneEditorDestination( - onBack = navController::popBackStack, + onBack = navigateBackFromDetail, onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) }, ) - sceneExecutionDestination(onBack = navController::popBackStack) + sceneExecutionDestination(onBack = navigateBackFromDetail) composable( route = Routes.INFRARED_HUB, arguments = listOf( @@ -445,7 +419,7 @@ private fun AuthenticatedNavHost( InfraredHubRoute( onBack = { onClearDeviceSelection() - navController.popBackStack() + navigateBackFromDetail() }, onOpenRemote = { remoteId -> navController.navigate( @@ -467,7 +441,7 @@ private fun AuthenticatedNavHost( ), ) { RemotePanelRoute( - onBack = navController::popBackStack, + onBack = navigateBackFromDetail, onSessionInvalid = onLogout, ) } @@ -479,7 +453,7 @@ private fun AuthenticatedNavHost( ), ) { InfraredControlRoute( - onBack = navController::popBackStack, + onBack = navigateBackFromDetail, onSessionInvalid = onLogout, ) } @@ -561,9 +535,3 @@ private fun NavBackStackEntry.actualRoute(): String? { } return route } - -private fun NavHostController.popCurrentDestinationUnlessRoot() { - if (currentBackStackEntry?.actualRoute() != MainTab.DEVICES.route) { - popBackStack() - } -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt index be914b8..415439d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/preferences/SettingsScreen.kt @@ -1,6 +1,5 @@ package com.flagship.abox.manager.ui.preferences -import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -58,6 +57,9 @@ 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 @@ -70,11 +72,27 @@ 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 = Saver( - save = { it.destination }, - restore = { SettingsExperienceState(it) }, + save = { "${it.destination}|${it.themeBackReturnsHome}" }, + restore = { saved -> + val parts = saved.split('|', limit = 2) + SettingsExperienceState(parts.first()).apply { + themeBackReturnsHome = parts.getOrNull(1)?.toBooleanStrictOrNull() ?: true + } + }, ) } } @@ -98,22 +116,29 @@ fun SettingsExperience( ) { val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) } .getOrDefault(SettingsDestination.HOME) - BackHandler { - if (destination == SettingsDestination.THEME) experienceState.destination = SettingsDestination.HOME.name - else onExit() - } - when (destination) { - SettingsDestination.HOME -> SettingsHomeScreen( - state = state, - onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name }, - onBack = onExit, - onAction = onAction, - ) - SettingsDestination.THEME -> ThemeSettingsScreen( - state = state, - onBack = { experienceState.destination = SettingsDestination.HOME.name }, - onAction = onAction, - ) + 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, + ) + } + } + } } } @@ -218,6 +243,7 @@ private fun ThemeSettingsScreen( @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, @@ -238,6 +264,7 @@ private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: ( values = ColorStyle.entries, label = { colorStyleLabel(it) }, onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) }, + enabled = manualColorsEnabled, ) EnumSettingRow( modifier = Modifier.testTag("settings-color-standard"), @@ -247,8 +274,8 @@ private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: ( values = ColorStandard.entries, label = { colorStandardLabel(it) }, onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) }, + enabled = manualColorsEnabled, ) - PredictiveBackSetting(state, onAction) SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) } } @@ -303,26 +330,9 @@ private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (Set }, ) { onAction(SettingsUiAction.SetLiquidGlass(it)) } } - PredictiveBackSetting(state, onAction) SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) } } -@Composable -private fun PredictiveBackSetting(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) { - val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - SettingsGroup(modifier = Modifier.testTag("settings-predictive-back")) { - SettingsSwitchRow( - icon = ABoxIconKey.BACK, - title = stringResource(R.string.settings_predictive_back), - description = stringResource(R.string.settings_predictive_back_description), - checked = state.preferences.predictiveBackEnabled, - tag = "settings-predictive-back-switch", - enabled = supported, - unavailableDescription = if (supported) null else stringResource(R.string.settings_requires_android_13), - ) { onAction(SettingsUiAction.SetPredictiveBack(it)) } - } -} - @Composable private fun SettingsPage( title: String, @@ -448,15 +458,17 @@ private fun EnumSettingRow( values: List, label: @Composable (T) -> String, onSelected: (T) -> Unit, + enabled: Boolean = true, ) { var expanded by remember { mutableStateOf(false) } SettingsGroup(modifier = modifier) { - Box(Modifier.fillMaxWidth()) { + 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 }) { @@ -478,18 +490,29 @@ private fun EnumSettingRow( @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 (state.preferences.dynamicColorEnabled) 0.42f else 1f), + modifier = Modifier.fillMaxWidth().alpha(if (manualColorsEnabled) 1f else 0.42f), horizontalArrangement = Arrangement.SpaceBetween, ) { AccentPreference.entries.forEach { accent -> - val color = accentColor(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 = !state.preferences.dynamicColorEnabled) { onAction(SettingsUiAction.SetAccent(accent)) }, + .clickable(enabled = manualColorsEnabled) { onAction(SettingsUiAction.SetAccent(accent)) }, contentAlignment = Alignment.Center, ) { if (state.preferences.accentPreference == accent) Icon(Icons.Outlined.Check, null, tint = Color.White) @@ -518,8 +541,8 @@ private fun ScaleSelector(state: SettingsUiState, onAction: (SettingsUiAction) - } @Composable -private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, onClick: () -> Unit) { - Row(modifier.fillMaxWidth().clickable(onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) { +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) @@ -570,4 +593,3 @@ private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: Str @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 }) -private fun accentColor(accent: AccentPreference) = when (accent) { AccentPreference.BLUE -> Color(0xFF4F6FA9); AccentPreference.GREEN -> Color(0xFF2F7D57); AccentPreference.AMBER -> Color(0xFFA66700); AccentPreference.ROSE -> Color(0xFFA94C68); AccentPreference.VIOLET -> Color(0xFF7659A8) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt index 45de938..d792c1f 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/shell/AccountProviderDialog.kt @@ -14,7 +14,7 @@ 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.Settings +import androidx.compose.material.icons.outlined.Palette import androidx.compose.material3.AlertDialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -107,7 +107,7 @@ fun AccountProviderDialog( } AccountSheetAction( - icon = Icons.Outlined.Settings, + icon = Icons.Outlined.Palette, label = stringResource(R.string.account_interface_settings), description = stringResource(R.string.account_interface_settings_desc), tag = "account_dialog_open_settings", diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 22d18d4..adcff17 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -265,7 +265,7 @@ 样式 渲染器 选择应用使用的界面渲染方式 - Material 3 + Material Miuix 主题 主题模式 @@ -291,7 +291,7 @@ 主题 配色、明暗、视觉效果与缩放 主题 - Material 3 + Material Miuix 让底部导航独立悬浮在页面内容上方 强调色 @@ -344,8 +344,8 @@ 当前账户 %1$s,您好 连接方式 - 界面设置 - 主题、渲染风格与交互偏好 + 主题 + 配色、明暗、视觉效果与缩放 退出当前会话 结束当前连接并返回登录流程 确定退出当前会话吗? diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index a1216ed..677acab 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -265,7 +265,7 @@ Style Renderer Choose the interface renderer used by the app - Material 3 + Material Miuix Theme Theme mode @@ -291,7 +291,7 @@ Theme Colors, brightness, effects and scale Theme - Material 3 + Material Miuix Use a detached navigation surface above page content Accent color @@ -344,8 +344,8 @@ Current account Hello, %1$s Connection method - Interface settings - Theme, renderer and interaction preferences + Theme + Colors, brightness, effects and scale Sign out End the current connection and return to sign-in Sign out of the current session? diff --git a/android/core/designsystem/build.gradle.kts b/android/core/designsystem/build.gradle.kts index 7b06b9b..8f3340d 100644 --- a/android/core/designsystem/build.gradle.kts +++ b/android/core/designsystem/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.material3) implementation(libs.compose.material.icons.extended) + implementation(libs.androidx.activity.compose) implementation(libs.miuix.ui.android) debugImplementation(libs.compose.ui.tooling) screenshotTestImplementation(libs.compose.screenshot.test) diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt index 9b38645..f8dda1d 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Components.kt @@ -1,5 +1,7 @@ package com.flagship.abox.manager.designsystem +import androidx.activity.compose.BackHandler + import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -45,6 +47,9 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -677,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() + } +} diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt index 0a1b683..e8e9518 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/Theme.kt @@ -101,7 +101,7 @@ internal fun ABoxThemeContent( LocalABoxBlurEnabled provides resolution.blurEnabled, LocalABoxNavigationPresentation provides preferences.navigationPresentation, LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled, - LocalABoxPredictiveBackEnabled provides preferences.predictiveBackEnabled, + LocalABoxPredictiveBackEnabled provides false, ) { renderer.Render( resolution = resolution, diff --git a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt index a21e57f..8b7d73c 100644 --- a/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt +++ b/android/core/designsystem/src/main/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxTokens.kt @@ -2,10 +2,13 @@ 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 @@ -122,7 +125,13 @@ object ABoxThemeResolver { val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) { dynamicColorTokens } else { - brandTokens.withAccent(preferences.accentPreference, isDarkTheme) + ABoxAccentPalette.resolve( + base = brandTokens, + accent = preferences.accentPreference, + style = preferences.colorStyle, + standard = preferences.colorStandard, + dark = isDarkTheme, + ) } val shapeTokens = when (preferences.renderer) { DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes @@ -139,13 +148,86 @@ object ABoxThemeResolver { } } -private fun ABoxColorTokens.withAccent(accent: AccentPreference, dark: Boolean): ABoxColorTokens { - val selected = when (accent) { - AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF385F9D) - AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF247A4B) - AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFF8A5800) - AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA43C59) - AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF6D52A2) +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) } - return copy(primary = selected, deviceSocket = selected) } diff --git a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt index e0a23ce..53ee0d6 100644 --- a/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt +++ b/android/core/designsystem/src/test/kotlin/com/flagship/abox/manager/designsystem/tokens/ABoxThemeResolverTest.kt @@ -26,8 +26,26 @@ class ABoxThemeResolverTest { ) assertFalse(light.isDarkTheme) - assertEquals(ABoxThemeDefaults.lightColors, light.colorTokens) - assertEquals(ABoxThemeDefaults.darkColors, dark.colorTokens) + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.lightColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + false, + ), + light.colorTokens, + ) + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.darkColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + true, + ), + dark.colorTokens, + ) } @Test @@ -42,7 +60,16 @@ class ABoxThemeResolverTest { dynamicColorTokens = dynamicTokens, ) - assertEquals(ABoxThemeDefaults.lightColors, resolution.colorTokens) + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.lightColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + false, + ), + resolution.colorTokens, + ) } @Test @@ -57,7 +84,16 @@ class ABoxThemeResolverTest { dynamicColorTokens = dynamicTokens, ) - assertEquals(ABoxThemeDefaults.darkColors, resolution.colorTokens) + assertEquals( + ABoxAccentPalette.resolve( + ABoxThemeDefaults.darkColors, + UiPreferences().accentPreference, + UiPreferences().colorStyle, + UiPreferences().colorStandard, + true, + ), + resolution.colorTokens, + ) } @Test diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index ef9d92f..71a9957 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.SwapVert -import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -71,6 +70,7 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.designsystem.ABoxBackLayer import com.flagship.abox.manager.designsystem.ABoxIconButton import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold import com.flagship.abox.manager.designsystem.icon.ABoxIcon @@ -157,8 +157,6 @@ internal fun InfraredHubScreen( var showCreateDialog by remember { mutableStateOf(false) } var showManagement by remember { mutableStateOf(false) } - BackHandler(enabled = showManagement) { showManagement = false } - LaunchedEffect(state.message) { state.message?.let { msg -> when (msg) { @@ -188,18 +186,16 @@ internal fun InfraredHubScreen( ABoxDeviceDetailScaffold( title = state.savedDeviceName.value, - subtitle = if (showManagement) stringResource(R.string.infrared_settings_subtitle) else stringResource(R.string.infrared_center_subtitle), + subtitle = stringResource(R.string.infrared_center_subtitle), icon = ABoxIconKey.INFRARED, - onBack = if (showManagement) ({ showManagement = false }) else onBack, + onBack = onBack, action = { - if (!showManagement) { - ABoxIconButton( - key = ABoxIconKey.EDIT, - contentDescription = stringResource(R.string.infrared_manage_action), - onClick = { showManagement = true }, - modifier = Modifier.testTag("hub_settings_button"), - ) - } + ABoxIconButton( + key = ABoxIconKey.EDIT, + contentDescription = stringResource(R.string.infrared_manage_action), + onClick = { showManagement = true }, + modifier = Modifier.testTag("hub_settings_button"), + ) }, ) { innerPadding -> ABoxSurface( @@ -207,16 +203,7 @@ internal fun InfraredHubScreen( .fillMaxSize() .padding(innerPadding), ) { - if (showManagement) { - InfraredManagementScreen( - state = state, - onAddRemote = { showCreateDialog = true }, - onDeleteRemote = onDeleteRemote, - onSaveDeviceName = onSaveDeviceName, - onMoveRemote = onMoveRemote, - onUpdateRemote = onUpdateRemote, - ) - } else Column( + Column( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp, vertical = 10.dp), @@ -299,6 +286,30 @@ internal fun InfraredHubScreen( } } + if (showManagement) { + ABoxBackLayer(onBack = { showManagement = false }) { + ABoxDeviceDetailScaffold( + title = state.savedDeviceName.value, + subtitle = stringResource(R.string.infrared_settings_subtitle), + icon = ABoxIconKey.INFRARED, + onBack = { showManagement = false }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier.fillMaxSize().padding(innerPadding), + ) { + InfraredManagementScreen( + state = state, + onAddRemote = { showCreateDialog = true }, + onDeleteRemote = onDeleteRemote, + onSaveDeviceName = onSaveDeviceName, + onMoveRemote = onMoveRemote, + onUpdateRemote = onUpdateRemote, + ) + } + } + } + } + if (showCreateDialog) { CreateVirtualRemoteDialog( onDismiss = { showCreateDialog = false }, -- 2.54.0

    PrE9AD|p=gB1^xw8ydHXnXJdCc`i=eeF^iG6#%6 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 literal 0 HcmV?d00001 diff --git a/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png b/android/core/designsystem/src/screenshotTestDebug/reference/com/flagship/abox/manager/designsystem/ABoxThemeScreenshotTestKt/ABoxThemeScreenshotMiuixDark_Miuix dark_78894dae_0.png new file mode 100644 index 0000000000000000000000000000000000000000..0288ec604d344538d93c4aa612dd06768c62df9c GIT binary patch literal 80341 zcmeFYXIxXu7dDE=qo}Be2nYx$^+**E5Rjr$rAY^A5;_EsUPDwwdJ&`x2q?V;gwO(_ z^dbrbLJdkUA=Cs2&r%~vNElJ(H_yCf{Ug8Jd$ozSqsfNUy;>EGFEh zddK_S%OU8%q9Q);zM}o6-HXWT`Jy6ow99gjsZ-hUj@H)$KJT|wo~=V_ zp?-eZZQkNLie4+JV{<#|ZhkObQ@5(yg(F|yO$CCgk7Jl6l?L`F;u|(mhE2!a#Akc5 z{Mg7j5+YD53ExBTv435~+^equjVaV_7$U`LAARFbx>tHYJ$yvX2(*{ChM&P(WblrtdH5Mz>QTc2Z6}G8;xHrSA>LAxJvOTRv!{XQWpD9Io3P2F%UQ0gXFcd>(iJz6 zQi@)D>Z-6qHqgK+kex*Dy(2;F3=U7&`{HuPkOqNP*Hnz78*oe>F+A1=zv;ZR2{UgR zSr{yA_YSU$2rK2@>@lnQ*n7?JN;&Xaz9|k*+?0`HgA9|~GZK)G-5>Z0N8SYlr|?s2 z!UqU5XP);p1L?4-Cpv$7jV9^+R+I;)1t8!MY2d531c?y#W!p%z#(5JX#T~^XL#us zQ$vfXVr$+D-B9pxYtN2u6ppM0PD!r6A98l1|9SYQu~ft7rX!m_LFsDs2(LXuCYeU9 zLA7H3M3{7Ehvh@w?ij6(QV`j3SToEGHliZpU0`T&F$!?>rs+> zQhJhGfH5JA9oc|O{bKp147(Ww`I!5}ST;xg(7ZGxL<6?d<5`MdHeK%`_jk(uo8{Uu zxX51^6JhSUZae(DCzF8}>pxZc0(SO1w#3wQl+XLy$8@q}PF)wFEPv1gZ(cN5D~W$Y zP9p&OJ;R20|L`L4@FtIMK7Ddgo+<+%nEnr%6@lLh` z!@r6B=+p)QrMyGa<5dBExtX{fGbtZl2~LOxQQh5*4h82#v8Pbq;gYR@xinwY7>h~c z@)`;yyVbqLyx%%#g$=O59t*-<4zqu0h!TAD#SJ;-u#ALSF%g*T(_-F3Su;`XpDmjD zJjd1(muhxWma0j1Te}j?e#h_N_}U}B!JtvQJg>Q0ZVrp`3A}@c$K288{pGqXBURK{ zRjeGiZRWgM>CwNu!8yqb7WjUst1HG|8FZX?AH-F$sbe2C`@c=&^<(vGG7wD)=oc!t zYW8k=d%1G_FIGUeysjKc=rGVYk!aVAFo&b%i-R+fZ2Ez=IR4z?Rn0kwK9bk z>^~}^T|lFlvB&eSR0(?9W{Ok;Pcr2&d)LXX1&WSmPilJ)eD`9t*dyT4p!>lMy0DGq ztsR>|uPuqZ8BzmEKXkkoYtI_w@yS8bA=^QGUt@4KM0Nh9of4?uNj~IPS+4*iQ=4nJaKq|OlxIJegC!lxJjk_ zxsPYeKjcN;w;<7FrDsZO7LSxflt2T=tz?G!Q$%@5D)gx8@!MBHT^Q1s=oysB-34`l zo~e=6SV`s6JJ&w2lfW^>?^I=&U%cDQv7M<`y9jrC5Sl74@QQ)60erh^pR_X%o5 z#UJkV^X}^2N2#-A3Fei|-L{}II3TGZpwB8YQ=Ri!&ns8g&1O&~mSWSBXs2{eEnW44 zyJL+=DCGhBMI*2K?+x~h50y(XRS0mwgxG5gSZpl;YkRTGd1@TGtxo4t!#WPXON~M( zoXPEBO{;0$#%$#78$+X&)%7~sY^oox6eY1kL_1}_JeXhrDd+pJ!=yP~ zSe?RD^P%WyT2!DJ3!9-IF*;h989dJ1Wg&B}w5!x0-jScb%PMZ(_P!MNRfG3}$Io1R zP)U%uNpAMckg&Uc-+%~OPXYP{Ly0le!Q1Y%04*pwqXN!eG^DX}67#5wXffrMi#-$_ zTU#SVNf`*n2UVs=g!Nq^QA9GeaV!A$oithNeb(B%-+d!a3yg!?a}s(7a|yQP z>Y^y`41KwPzpVLUwNQDbLkN#(&v%ms3ed9{N~3o}Ru6c9fo~2jPYnO?JU)0GA#Twx z)Str5!Ckc2qa1CKvUu1?5MKAfdQZHY^3RugBlz~;@kjUt_1QpB_+mRgF>G9L_eon? zdH+ZZHQr{LTwqfX+{=ZI0u?I% z^_>vj%?mFNCJ2ag_3(ARWuy9!1!c0I$i4#FnlwDZMm`s}3u~JT{SJP3(b+eNyOtZe z8LEu+Vn4*Fs?53ZHJ67=<(0)TdN&{dxS_A91nr9#*|`ObL!nac%~!K@kA#d_Arutw zDX?I%r7_XvlLMv8DfJ5VfJQ$|x6J85fKP8$F%beEE2fr@59jjEUob{$!Jl+=@^t)^ zttpEGFpub8zR%T`t9U{06?fZft#*8S3}${Nb+K-%#Nj^l)rGo;!@jKwgH9uoUL~(@ zbi|wBpLAf(3;;On>>g!}l6JV%?rzUJW7kxDX7SV|*FM8>pr;qUVlcm=2w@9g#S6n8 zt%hVW#io3SLGU>a335Y3+vF<5)aqEp31}W5CJsKGMke!kFxONZD%Ac&~C6{-WY)?d?JIb#T6{ z(Qjf6Y37}TU1}~!O#BEuxZ%|s7#$j`+c9=V(PmBonzu_?Q~TT2Yno~@jz;Ls9|rdi z51xyG>sO!?WsK2eZZ`ONwzP+m5{;lO``-T8f(F6(n+1~t@%lkE*>?#q-ayTIITAyd z^e=D&8jX5t4bIzktf6c>GGX&ndkf87GXx{+{?q+2IvaCN;*Mz-#O?c(nhRqx@JHP^ zg0Rm^PkNg4Q&T$?6Kn>l_YV6iUeNi7x#YFerHQjhhaQqRV!cHAV*|TH7xye31M^lV zwi5kZzfs!GzelQv{(0n{ZJEnGpR8usOAzu&AKhZ`KX!?2xx^E6-}y3&q~jEJH`}jl zQ?v$onxe9NBe=8|d%V+3=QqI`xw^FZ6Up1C&_VPrH0sHlx3Wte7B%#Js$r5-V`q|I z9e@_TmLVSaDW;CXsOiP@(YGarNrZW_#&pO;)Tra;d!yLCl>>Q3XF-r zY&|x*y*z$(i*4C2lw~^)+V)>MwDcn!C|2XAv%jH!RI_O*;*Yf69Nv<##+(!5PGjhZ zx`gw=A?14ee5O9Ne}oKtX0+`eA@v>hnG`uAK0A)w+D<+dUXjI!t4;$62Jf`ty@nLO|VIWz(4w7X;R-e&K-q3l-gmti#gbcg@>3g zfpODVqn|c|x4ifjtGd5b$HHn<3gf~O z?TJ~(+jU!R%6V!uBqL(xl*Rqs@>^LxIifvH>Wy*~^7m{ZlLqcvrFjc6gS@q^93EK1 zmr2fbR}jwgrMewD+Xuguy$-7SEG9r0JEsTwJj%u=u*So70k2o>T78G(D=HP5SzAfQ z4Q>geKGSg$kq*Z%8KO-Y>Ygu&46cNqvh0_%++@xcb}w~!p5EIY7y`I$>nLMLt_=_0*^eE zbM9v;IIWB6aE+j4r6C{v1t{eG@6fK)_-i~Z!fc8$?$uxT4DAUCb7r!!HR&uIC)mYl za3y9aw%Stg(PHn|W2kt|VW≥gg?=*PMn2Oq&SB`;P?EWJuI0`B}_GnLLY$ZnO_) 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