feat(ui): complete ABox app experience redesign #10
@@ -133,6 +133,25 @@ class AbsdkBridge internal constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun commandPower(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
powerState: SocketPowerState,
|
||||||
|
): DomainResult<Unit> = serialized {
|
||||||
|
withActiveSession(profileId) {
|
||||||
|
when (val control = call {
|
||||||
|
client.setSocketPower(deviceName.value, powerState.toSdkStatus())
|
||||||
|
}) {
|
||||||
|
is ClientCall.Failed -> DomainResult.Failure(control.error)
|
||||||
|
is ClientCall.Completed -> if (control.response.isSuccess()) {
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
} else {
|
||||||
|
DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getReading(
|
override suspend fun getReading(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.kapt)
|
alias(libs.plugins.kotlin.kapt)
|
||||||
alias(libs.plugins.hilt.android)
|
alias(libs.plugins.hilt.android)
|
||||||
|
alias(libs.plugins.compose.screenshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.flagship.abox.manager"
|
namespace = "com.flagship.abox.manager"
|
||||||
compileSdk = 34
|
compileSdk = 37
|
||||||
|
|
||||||
|
experimentalProperties["android.experimental.enableScreenshotTest"] = true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.flagship.abox.manager"
|
applicationId = "com.flagship.abox.manager"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 36
|
||||||
versionCode = 1
|
versionCode = 1
|
||||||
versionName = "0.1.0"
|
versionName = "0.1.0"
|
||||||
|
|
||||||
@@ -33,19 +39,11 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
resources {
|
resources {
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
@@ -57,6 +55,10 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
|
}
|
||||||
|
|
||||||
kapt {
|
kapt {
|
||||||
correctErrorTypes = true
|
correctErrorTypes = true
|
||||||
}
|
}
|
||||||
@@ -64,8 +66,11 @@ kapt {
|
|||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":android:core:designsystem"))
|
implementation(project(":android:core:designsystem"))
|
||||||
|
implementation(project(":android:core:preferences"))
|
||||||
|
implementation(project(":android:core:devicecatalog"))
|
||||||
implementation(project(":android:core:session"))
|
implementation(project(":android:core:session"))
|
||||||
implementation(project(":android:feature:infrared"))
|
implementation(project(":android:feature:infrared"))
|
||||||
|
implementation(project(":android:feature:automation"))
|
||||||
implementation(project(":absdk-bridge"))
|
implementation(project(":absdk-bridge"))
|
||||||
implementation(project(":runtime:simulation"))
|
implementation(project(":runtime:simulation"))
|
||||||
debugImplementation(project(":testing:fakes"))
|
debugImplementation(project(":testing:fakes"))
|
||||||
@@ -95,4 +100,5 @@ dependencies {
|
|||||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
debugImplementation(libs.compose.ui.test.manifest)
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
|
screenshotTestImplementation(libs.compose.screenshot.test)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.flagship.abox.manager.ui.devicecenter
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||||
|
import com.flagship.abox.manager.domain.DeviceId
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class DeviceCenterScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun configuredDeviceCardRendersWithSaveableGridKey() {
|
||||||
|
val profileId = ProviderProfileId("profile-1")
|
||||||
|
val device = DeviceDescriptor(
|
||||||
|
id = DeviceId("profile-1:socket-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = DeviceName("socket-1"),
|
||||||
|
type = DeviceType.SOCKET,
|
||||||
|
)
|
||||||
|
val card = DeviceCardUiModel(
|
||||||
|
key = DeviceCardKey(profileId, device.name),
|
||||||
|
device = device,
|
||||||
|
)
|
||||||
|
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
DeviceCenterScreen(
|
||||||
|
state = DeviceCenterUiState(
|
||||||
|
profileId = profileId,
|
||||||
|
cards = listOf(card),
|
||||||
|
content = DeviceCenterContent.CONTENT,
|
||||||
|
),
|
||||||
|
onFilterChanged = {},
|
||||||
|
onOpenDevice = {},
|
||||||
|
onConfigureDevice = { _, _ -> },
|
||||||
|
onQuickAction = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("device_card_profile-1:socket-1").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package com.flagship.abox.manager.ui.navigation
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass
|
||||||
|
import com.flagship.abox.manager.ui.shell.DeviceSelection
|
||||||
|
import com.flagship.abox.manager.ui.shell.DetailPresentation
|
||||||
|
import com.flagship.abox.manager.ui.shell.MainTab
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AuthenticatedNavigationRoutingTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compactDetailBackPopsDetailBeforeParentDestination() {
|
||||||
|
composeRule.setContent { ABoxTheme { RoutingFixture() } }
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_select_device").performClick()
|
||||||
|
composeRule.onNodeWithTag("routing_full_screen_detail").assertExists()
|
||||||
|
|
||||||
|
composeRule.runOnUiThread {
|
||||||
|
composeRule.activity.onBackPressedDispatcher.onBackPressed()
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithTag("routing_devices_root").assertExists()
|
||||||
|
composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reselectingTabPopsActualNavControllerToItsRoot() {
|
||||||
|
composeRule.setContent { ABoxTheme { RoutingFixture() } }
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_scenes_tab").performClick()
|
||||||
|
composeRule.onNodeWithTag("routing_scene_child").performClick()
|
||||||
|
composeRule.onNodeWithTag("routing_scenes_tab").performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_scenes_root").assertExists()
|
||||||
|
composeRule.onNodeWithTag("routing_scene_child_destination").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resizeReconcilesActualRouteAndPaneFromOneSavedSelection() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_select_device").performClick()
|
||||||
|
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
|
||||||
|
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
|
||||||
|
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
|
||||||
|
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_toggle_size").performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithTag("routing_full_screen_detail").assertExists()
|
||||||
|
composeRule.onNodeWithTag("routing_route").assertTextEquals("test/device/socket")
|
||||||
|
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.FULL_SCREEN.name)
|
||||||
|
composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_toggle_size").performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
|
||||||
|
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
|
||||||
|
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
|
||||||
|
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun savedSelectionSurvivesStateRestorationAndProfileReplacementClearsIt() {
|
||||||
|
val restorationTester = StateRestorationTester(composeRule)
|
||||||
|
restorationTester.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_select_device").performClick()
|
||||||
|
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
|
||||||
|
|
||||||
|
restorationTester.emulateSavedInstanceStateRestore()
|
||||||
|
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
|
||||||
|
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("routing_replace_profile").performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE")
|
||||||
|
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
|
||||||
|
composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RoutingFixture(
|
||||||
|
initialWindowSizeClass: ABoxWindowSizeClass = ABoxWindowSizeClass.COMPACT,
|
||||||
|
) {
|
||||||
|
var windowSizeClass by remember { mutableStateOf(initialWindowSizeClass) }
|
||||||
|
var profileId by remember { mutableStateOf(ProviderProfileId("profile")) }
|
||||||
|
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
windowSizeClass = if (windowSizeClass == ABoxWindowSizeClass.COMPACT) {
|
||||||
|
ABoxWindowSizeClass.EXPANDED
|
||||||
|
} else {
|
||||||
|
ABoxWindowSizeClass.COMPACT
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag("routing_toggle_size"),
|
||||||
|
) { Text("Resize") }
|
||||||
|
Button(
|
||||||
|
onClick = { profileId = ProviderProfileId("replacement") },
|
||||||
|
modifier = Modifier.testTag("routing_replace_profile"),
|
||||||
|
) { Text("Replace profile") }
|
||||||
|
|
||||||
|
key(profileId.value) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
AuthenticatedNavigationStateHost(
|
||||||
|
profileId = profileId,
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
navController = navController,
|
||||||
|
deviceRoute = { "test/device/${it.deviceName.value}" },
|
||||||
|
selectionForRoute = { route ->
|
||||||
|
route
|
||||||
|
?.takeIf { it.startsWith("test/device/") }
|
||||||
|
?.removePrefix("test/device/")
|
||||||
|
?.let { deviceName ->
|
||||||
|
DeviceSelection(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
type = DeviceType.SOCKET,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { snapshot, onTabClick, onSelectDevice, _ ->
|
||||||
|
Text(snapshot.currentRoute.orEmpty(), modifier = Modifier.testTag("routing_route"))
|
||||||
|
Text(
|
||||||
|
snapshot.detailPresentation?.name ?: "NONE",
|
||||||
|
modifier = Modifier.testTag("routing_presentation"),
|
||||||
|
)
|
||||||
|
Button(
|
||||||
|
onClick = { onSelectDevice(DeviceName("socket"), DeviceType.SOCKET) },
|
||||||
|
modifier = Modifier.testTag("routing_select_device"),
|
||||||
|
) { Text("Select") }
|
||||||
|
Button(
|
||||||
|
onClick = { onTabClick(MainTab.SCENES) },
|
||||||
|
modifier = Modifier.testTag("routing_scenes_tab"),
|
||||||
|
) { Text("Scenes") }
|
||||||
|
Button(
|
||||||
|
onClick = { navController.navigate("scene/child") },
|
||||||
|
modifier = Modifier.testTag("routing_scene_child"),
|
||||||
|
) { Text("Child") }
|
||||||
|
|
||||||
|
if (snapshot.detailPresentation == DetailPresentation.PANE) {
|
||||||
|
Text("Pane detail", modifier = Modifier.testTag("routing_pane_detail"))
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = MainTab.DEVICES.route,
|
||||||
|
) {
|
||||||
|
composable(MainTab.DEVICES.route) {
|
||||||
|
Text("Devices", modifier = Modifier.testTag("routing_devices_root"))
|
||||||
|
}
|
||||||
|
composable(MainTab.SCENES.route) {
|
||||||
|
Text("Scenes", modifier = Modifier.testTag("routing_scenes_root"))
|
||||||
|
}
|
||||||
|
composable("scene/child") {
|
||||||
|
Text("Child", modifier = Modifier.testTag("routing_scene_child_destination"))
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "test/device/{deviceName}",
|
||||||
|
arguments = listOf(navArgument("deviceName") { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
Text("Full screen detail", modifier = Modifier.testTag("routing_full_screen_detail"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package com.flagship.abox.manager.ui.preferences
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SettingsScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun interfaceStyleOpensMenuBeforeChangingRenderer() {
|
||||||
|
val actions = mutableListOf<SettingsUiAction>()
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
SettingsScreen(
|
||||||
|
state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true),
|
||||||
|
onAction = actions::add,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("settings-style-entry").performClick()
|
||||||
|
|
||||||
|
assertTrue(actions.isEmpty())
|
||||||
|
composeRule.onNodeWithTag("settings-style-menu").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("Miuix").performClick()
|
||||||
|
assertEquals(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX), actions.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun materialThemeShowsEveryMaterialOptionInReferenceOrder() {
|
||||||
|
showTheme(DesignRenderer.MATERIAL3)
|
||||||
|
|
||||||
|
assertVerticalOrder(
|
||||||
|
"settings-material-colors",
|
||||||
|
"settings-theme-mode",
|
||||||
|
"settings-color-style",
|
||||||
|
"settings-color-standard",
|
||||||
|
"settings-interface-scale",
|
||||||
|
)
|
||||||
|
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
|
||||||
|
composeRule.onAllNodesWithTag("settings-liquid-glass").assertCountEquals(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun miuixThemeShowsEveryMiuixOptionInReferenceOrder() {
|
||||||
|
showTheme(DesignRenderer.MIUIX)
|
||||||
|
|
||||||
|
assertVerticalOrder(
|
||||||
|
"settings-theme-mode",
|
||||||
|
"settings-miuix-accent",
|
||||||
|
"settings-blur",
|
||||||
|
"settings-floating-navigation",
|
||||||
|
"settings-liquid-glass",
|
||||||
|
"settings-interface-scale",
|
||||||
|
)
|
||||||
|
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
|
||||||
|
composeRule.onAllNodesWithTag("settings-color-style").assertCountEquals(0)
|
||||||
|
composeRule.onAllNodesWithTag("settings-color-standard").assertCountEquals(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun themeDetailRemainsOpenWhenRendererChangesFromHomeMenu() {
|
||||||
|
var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3))
|
||||||
|
val experienceState = SettingsExperienceState()
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme(preferences = preferences) {
|
||||||
|
SettingsExperience(
|
||||||
|
state = SettingsUiState(preferences = preferences, loaded = true),
|
||||||
|
experienceState = experienceState,
|
||||||
|
onAction = { action ->
|
||||||
|
if (action is SettingsUiAction.SetRenderer) preferences = preferences.copy(renderer = action.value)
|
||||||
|
},
|
||||||
|
onExit = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("settings-style-entry").performClick()
|
||||||
|
composeRule.onNodeWithText("Miuix").performClick()
|
||||||
|
composeRule.onNodeWithTag("settings-theme-entry").performClick()
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("settings-theme-detail").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("settings-miuix-accent").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showTheme(renderer: DesignRenderer) {
|
||||||
|
composeRule.setContent {
|
||||||
|
val experienceState = remember { SettingsExperienceState("THEME") }
|
||||||
|
ABoxTheme {
|
||||||
|
SettingsExperience(
|
||||||
|
state = SettingsUiState(preferences = UiPreferences(renderer = renderer), loaded = true),
|
||||||
|
experienceState = experienceState,
|
||||||
|
onAction = {},
|
||||||
|
onExit = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertVerticalOrder(vararg tags: String) {
|
||||||
|
val tops = tags.map { tag -> composeRule.onNodeWithTag(tag).getUnclippedBoundsInRoot().top }
|
||||||
|
assertTrue("Expected controls in reference-image order: ${tags.toList()}", tops.zipWithNext().all { it.first < it.second })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SceneChoiceFlowTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun choicesWrapOntoAnotherLineAtNarrowEnglishWidth() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
SceneChoiceFlow(modifier = Modifier.width(240.dp)) {
|
||||||
|
listOf("Socket", "Remote", "Delay", "Notify").forEach { label ->
|
||||||
|
FilterChip(selected = false, onClick = {}, label = { Text(label) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val firstTop = composeRule.onNodeWithText("Socket").getUnclippedBoundsInRoot().top
|
||||||
|
val lastTop = composeRule.onNodeWithText("Notify").getUnclippedBoundsInRoot().top
|
||||||
|
assertTrue("The final choice should wrap below the first row", lastTop > firstTop)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package com.flagship.abox.manager.ui.shell
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class AccountProviderDialogTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dialogExposesAccountActionsAndClose() {
|
||||||
|
var closed = 0
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
AccountProviderDialog(
|
||||||
|
userName = "user",
|
||||||
|
providerName = "ABox",
|
||||||
|
onDismissRequest = { closed++ },
|
||||||
|
onOpenSettings = {},
|
||||||
|
onSwitchProvider = {},
|
||||||
|
onEndSession = {},
|
||||||
|
onForgetCredentials = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("账户与提供方").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("account_dialog_close").performClick()
|
||||||
|
|
||||||
|
assertEquals(1, closed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun destructiveActionsRequireSeparateConfirmation() {
|
||||||
|
var ended = 0
|
||||||
|
var forgotten = 0
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
AccountProviderDialog(
|
||||||
|
userName = "user",
|
||||||
|
providerName = "ABox",
|
||||||
|
onDismissRequest = {},
|
||||||
|
onOpenSettings = {},
|
||||||
|
onSwitchProvider = {},
|
||||||
|
onEndSession = { ended++ },
|
||||||
|
onForgetCredentials = { forgotten++ },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("account_dialog_end_session").performClick()
|
||||||
|
composeRule.onNodeWithText("退出当前会话").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("account_dialog_confirm").performClick()
|
||||||
|
assertEquals(1, ended)
|
||||||
|
assertEquals(0, forgotten)
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("account_dialog_forget_credentials").performClick()
|
||||||
|
composeRule.onNodeWithText("清除保存的凭据").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("account_dialog_confirm").performClick()
|
||||||
|
assertEquals(1, forgotten)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package com.flagship.abox.manager.ui.shell
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class MainAppShellTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun compactUsesBottomNavigationAndDelegatesTabClick() {
|
||||||
|
var selectedTab: MainTab? = null
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
MainAppShell(
|
||||||
|
currentTab = MainTab.DEVICES,
|
||||||
|
windowSizeClass = ABoxWindowSizeClass.COMPACT,
|
||||||
|
userName = "user",
|
||||||
|
providerName = "ABox",
|
||||||
|
onTabClick = { selectedTab = it },
|
||||||
|
onProviderClick = {},
|
||||||
|
onAvatarClick = {},
|
||||||
|
content = { _: PaddingValues -> Text("content") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("main_tab_devices").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("main_tab_scenes").performClick()
|
||||||
|
|
||||||
|
assertEquals(MainTab.SCENES, selectedTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mediumAndExpandedExposeRailNavigation() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
MainAppShell(
|
||||||
|
currentTab = MainTab.DEVICES,
|
||||||
|
windowSizeClass = ABoxWindowSizeClass.MEDIUM,
|
||||||
|
userName = "user",
|
||||||
|
providerName = "ABox",
|
||||||
|
onTabClick = {},
|
||||||
|
onProviderClick = {},
|
||||||
|
onAvatarClick = {},
|
||||||
|
content = { _: PaddingValues -> Text("content") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Scenes").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun topAreaDelegatesProviderAndAvatarClicks() {
|
||||||
|
var providerClicks = 0
|
||||||
|
var avatarClicks = 0
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
MainAppShell(
|
||||||
|
currentTab = MainTab.DEVICES,
|
||||||
|
windowSizeClass = ABoxWindowSizeClass.COMPACT,
|
||||||
|
userName = "user",
|
||||||
|
providerName = "ABox",
|
||||||
|
onTabClick = {},
|
||||||
|
onProviderClick = { providerClicks++ },
|
||||||
|
onAvatarClick = { avatarClicks++ },
|
||||||
|
content = { _: PaddingValues -> Text("content") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("main_provider_pill").performClick()
|
||||||
|
composeRule.onNodeWithTag("main_avatar").performClick()
|
||||||
|
|
||||||
|
assertEquals(1, providerClicks)
|
||||||
|
assertEquals(1, avatarClicks)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.flagship.abox.manager.ui.socket
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class SocketDetailScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun awaitingConfirmationShowsMainControlAndWarning() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
SocketScreen(
|
||||||
|
state = SocketUiState(
|
||||||
|
profileId = ProviderProfileId("p1"),
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
operation = SocketOperationState.AwaitingConfirmation(SocketPowerState.OFF),
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
onBackClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("socket_power_dial").assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithTag("socket_awaiting_confirmation").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.flagship.abox.manager.ui.temperaturehumidity
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertCountEquals
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class TemperatureHumidityDetailScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun partialReadingRendersOnlyAvailableMetric() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
TemperatureHumidityScreen(
|
||||||
|
state = TemperatureHumidityUiState(
|
||||||
|
profileId = ProviderProfileId("p1"),
|
||||||
|
deviceName = DeviceName("sensor-1"),
|
||||||
|
temperature = "24.2",
|
||||||
|
humidity = null,
|
||||||
|
),
|
||||||
|
onEvent = {},
|
||||||
|
onBackClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("sensor_metric_temperature").assertIsDisplayed()
|
||||||
|
composeRule.onAllNodesWithTag("sensor_metric_humidity").assertCountEquals(0)
|
||||||
|
composeRule.onNodeWithTag("sensor_monitoring_notice").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
package com.flagship.abox.manager.di
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceCatalog
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object FoundationModule {
|
|
||||||
// TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production
|
|
||||||
// ABox catalog is owned by the device-catalog module. Until that exists, debug builds
|
|
||||||
// keep the fake ABox catalog here. Do NOT route ABox catalog requests to
|
|
||||||
// SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding
|
|
||||||
// until a real release consumer appears.
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<application
|
<application
|
||||||
android:name=".ABoxManagerApplication"
|
android:name=".ABoxManagerApplication"
|
||||||
|
android:enableOnBackInvokedCallback="false"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true"
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package com.flagship.abox.manager.di
|
|||||||
|
|
||||||
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
|
import com.flagship.abox.manager.absdk.bridge.AbsdkBridge
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
|
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationSocketGateway
|
import com.flagship.abox.manager.simulation.SimulationSocketGateway
|
||||||
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
|
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
|
||||||
|
import com.flagship.abox.manager.simulation.SimulationDeviceCatalog
|
||||||
|
import com.flagship.abox.manager.devicecatalog.RoutingDeviceCatalog
|
||||||
|
import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -22,6 +26,18 @@ import javax.inject.Singleton
|
|||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object DeviceGatewayModule {
|
object DeviceGatewayModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDeviceCatalog(
|
||||||
|
profileStore: ProviderProfileStore,
|
||||||
|
configured: RoomConfiguredDeviceRepository,
|
||||||
|
simulation: SimulationDeviceCatalog,
|
||||||
|
): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
|
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
|
||||||
|
|||||||
@@ -62,6 +62,15 @@ internal class RoutingSocketGateway(
|
|||||||
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
|
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun commandPower(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
powerState: SocketPowerState,
|
||||||
|
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
|
||||||
|
is DomainResult.Failure -> provider
|
||||||
|
is DomainResult.Success -> delegate(provider.value).commandPower(profileId, deviceName, powerState)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun setPower(
|
override suspend fun setPower(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.SceneExecutor
|
|||||||
import com.flagship.abox.manager.domain.SceneRepository
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||||
|
import com.flagship.abox.manager.ui.scene.RecordingSceneExecutor
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -29,9 +31,13 @@ object SceneIntegrationModule {
|
|||||||
socketGateway: SocketGateway,
|
socketGateway: SocketGateway,
|
||||||
infraredGateway: InfraredGateway,
|
infraredGateway: InfraredGateway,
|
||||||
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||||
): SceneExecutor = DefaultSceneExecutor(
|
activityWriter: ActivityEventWriter,
|
||||||
|
): SceneExecutor = RecordingSceneExecutor(
|
||||||
|
delegate = DefaultSceneExecutor(
|
||||||
socketGateway = socketGateway,
|
socketGateway = socketGateway,
|
||||||
infraredGateway = infraredGateway,
|
infraredGateway = infraredGateway,
|
||||||
sendVirtualRemoteAction = sendVirtualRemoteAction,
|
sendVirtualRemoteAction = sendVirtualRemoteAction,
|
||||||
|
),
|
||||||
|
writer = activityWriter,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.flagship.abox.manager.preferences.DataStoreUiPreferencesRepository
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferencesRepository
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object UiPreferencesModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUiPreferencesRepository(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
): UiPreferencesRepository = DataStoreUiPreferencesRepository(context)
|
||||||
|
}
|
||||||
@@ -7,56 +7,60 @@ import androidx.compose.foundation.layout.safeDrawing
|
|||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.designsystem.ABoxBackLayer
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.Username
|
import com.flagship.abox.manager.domain.Username
|
||||||
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
|
|
||||||
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
|
|
||||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
|
||||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
import com.flagship.abox.manager.ui.navigation.deviceDetailGraph
|
import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
|
||||||
import com.flagship.abox.manager.ui.navigation.navigateToSocket
|
import com.flagship.abox.manager.ui.preferences.SettingsRoute
|
||||||
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
import com.flagship.abox.manager.ui.preferences.SettingsExperienceState
|
||||||
|
import com.flagship.abox.manager.ui.preferences.SettingsViewModel
|
||||||
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
||||||
import com.flagship.abox.manager.ui.scene.SceneListScreen
|
|
||||||
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
|
||||||
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
|
|
||||||
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
|
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||||
import com.flagship.abox.manager.ui.shell.MainAppShell
|
import com.flagship.abox.manager.ui.shell.AccountProviderDialog
|
||||||
|
import com.flagship.abox.manager.ui.session.SessionUiAction
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxManagerApp(
|
fun ABoxManagerApp(
|
||||||
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
||||||
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
|
||||||
loginViewModel: LoginViewModel = hiltViewModel(),
|
loginViewModel: LoginViewModel = hiltViewModel(),
|
||||||
|
settingsViewModel: SettingsViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
val actionError = actionState.errorMessage?.asString()
|
||||||
val screenKey = sessionState.screenKey()
|
val screenKey = sessionState.screenKey()
|
||||||
|
var showAccountDialog by rememberSaveable(screenKey) { mutableStateOf(false) }
|
||||||
|
var showSettings by rememberSaveable(screenKey) { mutableStateOf(false) }
|
||||||
|
val settingsExperienceState = rememberSaveable(screenKey, saver = SettingsExperienceState.Saver) {
|
||||||
|
SettingsExperienceState()
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(screenKey) {
|
LaunchedEffect(screenKey) {
|
||||||
sessionActionsViewModel.dismissError()
|
sessionActionsViewModel.dismissError()
|
||||||
if (sessionState !is AppSessionUiState.Login) {
|
if (sessionState !is AppSessionUiState.Login) {
|
||||||
loginViewModel.clearSensitiveState()
|
loginViewModel.clearSensitiveState()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ABoxTheme {
|
|
||||||
|
ABoxTheme(preferences = settingsState.preferences) {
|
||||||
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val state = sessionState) {
|
when (val state = sessionState) {
|
||||||
AppSessionUiState.Loading -> Box(
|
AppSessionUiState.Loading -> Box(
|
||||||
@@ -71,7 +75,7 @@ fun ABoxManagerApp(
|
|||||||
is AppSessionUiState.Login -> LoginRoute(
|
is AppSessionUiState.Login -> LoginRoute(
|
||||||
profile = state.profile,
|
profile = state.profile,
|
||||||
bootstrapError = state.bootstrapError,
|
bootstrapError = state.bootstrapError,
|
||||||
actionError = actionState.errorMessage,
|
actionError = actionError,
|
||||||
actionInProgress = actionState.inProgress,
|
actionInProgress = actionState.inProgress,
|
||||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
viewModel = loginViewModel,
|
viewModel = loginViewModel,
|
||||||
@@ -79,9 +83,13 @@ fun ABoxManagerApp(
|
|||||||
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
|
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
|
||||||
username = state.username,
|
username = state.username,
|
||||||
profile = state.profile,
|
profile = state.profile,
|
||||||
actionInProgress = actionState.inProgress,
|
|
||||||
errorMessage = actionState.errorMessage,
|
|
||||||
|
|
|||||||
onLogout = sessionActionsViewModel::logout,
|
onLogout = sessionActionsViewModel::logout,
|
||||||
|
onSessionAction = sessionActionsViewModel::dispatch,
|
||||||
|
showAccountDialog = showAccountDialog,
|
||||||
|
showSettings = showSettings,
|
||||||
|
settingsExperienceState = settingsExperienceState,
|
||||||
|
onShowAccountDialogChange = { showAccountDialog = it },
|
||||||
|
onShowSettingsChange = { showSettings = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,105 +100,59 @@ fun ABoxManagerApp(
|
|||||||
private fun AuthenticatedNavHost(
|
private fun AuthenticatedNavHost(
|
||||||
username: Username,
|
username: Username,
|
||||||
profile: ProviderProfile,
|
profile: ProviderProfile,
|
||||||
actionInProgress: Boolean,
|
|
||||||
errorMessage: String?,
|
|
||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
|
onSessionAction: (SessionUiAction) -> Unit,
|
||||||
|
showAccountDialog: Boolean,
|
||||||
|
showSettings: Boolean,
|
||||||
|
settingsExperienceState: SettingsExperienceState,
|
||||||
|
onShowAccountDialogChange: (Boolean) -> Unit,
|
||||||
|
onShowSettingsChange: (Boolean) -> Unit,
|
||||||
) {
|
) {
|
||||||
val navController = rememberNavController()
|
Box(Modifier.fillMaxSize()) {
|
||||||
NavHost(
|
AuthenticatedNavigation(
|
||||||
navController = navController,
|
username = username,
|
||||||
startDestination = "home",
|
profile = profile,
|
||||||
|
onLogout = onLogout,
|
||||||
|
onProviderClick = { onShowAccountDialogChange(true) },
|
||||||
|
onAvatarClick = { onShowAccountDialogChange(true) },
|
||||||
|
)
|
||||||
|
if (showSettings) {
|
||||||
|
ABoxBackLayer(
|
||||||
|
onBack = { onShowSettingsChange(false) },
|
||||||
|
enabled = settingsExperienceState.isHome,
|
||||||
) {
|
) {
|
||||||
composable("home") {
|
SettingsRoute(
|
||||||
MainAppShell(
|
experienceState = settingsExperienceState,
|
||||||
|
onExit = { onShowSettingsChange(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAccountDialog) {
|
||||||
|
AccountProviderDialog(
|
||||||
userName = username.value,
|
userName = username.value,
|
||||||
providerName = profile.displayName,
|
providerName = profile.displayName,
|
||||||
onLogout = onLogout,
|
onDismissRequest = { onShowAccountDialogChange(false) },
|
||||||
actionInProgress = actionInProgress,
|
|
||||||
errorMessage = errorMessage,
|
|
||||||
devicesSlot = {
|
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
|
||||||
DeviceListScreen(
|
|
||||||
onSocketClick = {
|
|
||||||
navController.navigateToSocket(
|
|
||||||
profileId = profile.id,
|
|
||||||
deviceName = DeviceName("Zigbee插座控制器"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onTemperatureHumidityClick = {
|
|
||||||
navController.navigateToTemperatureHumidity(
|
|
||||||
profileId = profile.id,
|
|
||||||
deviceName = DeviceName("温湿度传感器"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onInfraredClick = {
|
|
||||||
navController.navigate("infrared_hub/$encodedProfileId")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
scenesSlot = {
|
|
||||||
SceneListScreen(
|
|
||||||
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
|
|
||||||
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
|
|
||||||
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
|
|
||||||
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
|
|
||||||
)
|
|
||||||
|
pr-agent-bot
commented
设置不可达 账户弹窗是当前可见代码中唯一会打开设置界面的入口,但它始终调用 **设置不可达**
账户弹窗是当前可见代码中唯一会打开设置界面的入口,但它始终调用 `showTheme(returnToHome = false)`。用户会直接进入主题详情,返回时又关闭整个设置层,因此设置首页中的渲染器、触感等选项无法访问。常规设置入口应先调用 `showHome()`,或允许主题详情返回设置首页。
<!-- pr-agent-dedup: 42945eab325e -->
<!-- pr-agent-key-issue-location: a13e03273d5c -->
|
|||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
deviceDetailGraph(
|
|
||||||
onBackClick = navController::popBackStack,
|
|
||||||
)
|
|
||||||
sceneEditorDestination(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSaved = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
sceneExecutionDestination(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
)
|
|
||||||
composable(
|
|
||||||
route = "infrared_hub/{profileId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
|
||||||
InfraredHubRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onOpenRemote = { remoteId ->
|
|
||||||
val encodedRemoteId = android.net.Uri.encode(remoteId)
|
|
||||||
navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId")
|
|
||||||
},
|
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
navController.navigate("infrared_settings/$encodedProfileId")
|
onShowAccountDialogChange(false)
|
||||||
|
settingsExperienceState.showTheme(returnToHome = false)
|
||||||
|
onShowSettingsChange(true)
|
||||||
|
},
|
||||||
|
onSwitchProvider = {
|
||||||
|
onShowAccountDialogChange(false)
|
||||||
|
onSessionAction(SessionUiAction.SwitchProvider)
|
||||||
|
},
|
||||||
|
onEndSession = {
|
||||||
|
onShowAccountDialogChange(false)
|
||||||
|
onSessionAction(SessionUiAction.EndSession)
|
||||||
|
},
|
||||||
|
onForgetCredentials = {
|
||||||
|
onShowAccountDialogChange(false)
|
||||||
|
onSessionAction(SessionUiAction.ForgetSavedCredentials)
|
||||||
},
|
},
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
|
||||||
route = "infrared_panel/{profileId}/{remoteId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
navArgument("remoteId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
RemotePanelRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = "infrared_settings/{profileId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
InfraredControlRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun AppSessionUiState.screenKey(): String = when (this) {
|
private fun AppSessionUiState.screenKey(): String = when (this) {
|
||||||
|
|||||||
@@ -13,13 +13,6 @@ object DomainErrorMapper {
|
|||||||
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
|
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
|
||||||
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
|
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
|
||||||
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
|
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
|
||||||
is DomainError.Unknown -> {
|
is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown)
|
||||||
val detail = error.detail
|
|
||||||
if (!detail.isNullOrBlank()) {
|
|
||||||
UiText.DynamicString(detail)
|
|
||||||
} else {
|
|
||||||
UiText.StringResource(R.string.error_unknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package com.flagship.abox.manager.ui.defaults
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
|
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||||
|
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
|
||||||
|
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||||
|
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||||
|
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ABoxDefaultContentInitializer @Inject constructor(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val deviceRepository: ConfiguredDeviceRepository,
|
||||||
|
private val remoteRepository: VirtualRemoteRepository,
|
||||||
|
) {
|
||||||
|
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
|
||||||
|
suspend fun initialize(profileId: ProviderProfileId) {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
if (profile?.id != profileId || profile.type != ProviderType.ABOX) return
|
||||||
|
|
||||||
|
val marker = "$MARKER_PREFIX${profileId.value}"
|
||||||
|
if (preferences.getInt(marker, 0) >= CONTENT_VERSION) return
|
||||||
|
|
||||||
|
if (!ensureDevices(profileId)) return
|
||||||
|
if (!ensureCustomRemotes(profileId)) return
|
||||||
|
preferences.edit().putInt(marker, CONTENT_VERSION).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ensureDevices(profileId: ProviderProfileId): Boolean {
|
||||||
|
val existing = when (val result = deviceRepository.observe(profileId).first()) {
|
||||||
|
is DomainResult.Success -> result.value
|
||||||
|
is DomainResult.Failure -> return false
|
||||||
|
}
|
||||||
|
val defaults = listOf(
|
||||||
|
DeviceName("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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.flagship.abox.manager.ui.devicecenter
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
|
|
||||||
|
data class DeviceCardKey(val profileId: ProviderProfileId, val deviceName: DeviceName)
|
||||||
|
|
||||||
|
fun DeviceCardKey.saveableKey(): String = "${profileId.value}\u0000${deviceName.value}"
|
||||||
|
|
||||||
|
enum class DeviceCategoryFilter { ALL, SOCKET, SENSOR, INFRARED }
|
||||||
|
data class DeviceCardUiModel(
|
||||||
|
val key: DeviceCardKey,
|
||||||
|
val device: DeviceDescriptor,
|
||||||
|
val displayName: String = device.name.value,
|
||||||
|
val typeLabel: String = when (device.type) {
|
||||||
|
DeviceType.SOCKET -> "插座"
|
||||||
|
DeviceType.TEMPERATURE_HUMIDITY -> "温湿度"
|
||||||
|
DeviceType.INFRARED -> "红外控制器"
|
||||||
|
},
|
||||||
|
val detail: DeviceCardDetail = DeviceCardDetail.Loading,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface DeviceCardDetail {
|
||||||
|
data object Loading : DeviceCardDetail
|
||||||
|
data class Socket(
|
||||||
|
val powerState: SocketPowerState,
|
||||||
|
val operating: Boolean = false,
|
||||||
|
val pendingTarget: SocketPowerState? = null,
|
||||||
|
val lastConfirmedAtMillis: Long? = null,
|
||||||
|
) : DeviceCardDetail
|
||||||
|
data class Sensor(
|
||||||
|
val temperature: String?,
|
||||||
|
val humidity: String?,
|
||||||
|
val refreshing: Boolean = false,
|
||||||
|
val lastUpdatedAtMillis: Long? = null,
|
||||||
|
) : DeviceCardDetail
|
||||||
|
data class Infrared(
|
||||||
|
val remoteNames: List<String>,
|
||||||
|
val quota: SlotQuotaSummary,
|
||||||
|
) : DeviceCardDetail
|
||||||
|
data object Offline : DeviceCardDetail
|
||||||
|
data object Unavailable : DeviceCardDetail
|
||||||
|
}
|
||||||
|
enum class DeviceCenterContent { LOADING, CONTENT, EMPTY, ERROR }
|
||||||
|
data class DeviceCenterUiState(
|
||||||
|
val profileId: ProviderProfileId? = null,
|
||||||
|
val filter: DeviceCategoryFilter = DeviceCategoryFilter.ALL,
|
||||||
|
val cards: List<DeviceCardUiModel> = emptyList(),
|
||||||
|
val selectedCard: DeviceCardKey? = null,
|
||||||
|
val content: DeviceCenterContent = DeviceCenterContent.LOADING,
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.flagship.abox.manager.ui.devicecenter
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceCenterRoute(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
onOpenDevice: (String, DeviceType) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: DeviceCenterViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
LaunchedEffect(profileId) { viewModel.initialize(profileId) }
|
||||||
|
DeviceCenterScreen(
|
||||||
|
state = state,
|
||||||
|
onFilterChanged = viewModel::setFilter,
|
||||||
|
onOpenDevice = { card -> onOpenDevice(card.device.name.value, card.device.type) },
|
||||||
|
onConfigureDevice = viewModel::addConfiguredDevice,
|
||||||
|
onQuickAction = { card ->
|
||||||
|
if (card.device.type == DeviceType.SOCKET) viewModel.toggleSocket(card)
|
||||||
|
else viewModel.refreshDevice(card)
|
||||||
|
},
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
package com.flagship.abox.manager.ui.devicecenter
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxCard
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxEmptyState
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxFilterChip
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceCenterScreen(
|
||||||
|
state: DeviceCenterUiState,
|
||||||
|
onFilterChanged: (DeviceCategoryFilter) -> Unit,
|
||||||
|
onOpenDevice: (DeviceCardUiModel) -> Unit,
|
||||||
|
onConfigureDevice: (String, DeviceType) -> Unit,
|
||||||
|
onQuickAction: (DeviceCardUiModel) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var showConfiguration by remember { mutableStateOf(false) }
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(stringResource(R.string.device_center_heading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
Text(stringResource(R.string.device_center_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
when (state.content) {
|
||||||
|
DeviceCenterContent.LOADING -> Text(stringResource(R.string.device_center_loading), modifier = Modifier.testTag("device_center_loading"))
|
||||||
|
DeviceCenterContent.EMPTY -> ABoxEmptyState(
|
||||||
|
title = stringResource(R.string.device_center_empty_title),
|
||||||
|
description = stringResource(R.string.device_center_empty_description),
|
||||||
|
modifier = Modifier.testTag("device_center_empty"),
|
||||||
|
action = {
|
||||||
|
Button(onClick = { showConfiguration = true }) { Text(stringResource(R.string.device_center_configure)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DeviceCenterContent.ERROR -> Text(stringResource(R.string.device_center_error), modifier = Modifier.testTag("device_center_error"))
|
||||||
|
DeviceCenterContent.CONTENT -> LazyVerticalGrid(
|
||||||
|
columns = GridCells.Adaptive(minSize = 220.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 24.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
modifier = Modifier.testTag("device_center_grid"),
|
||||||
|
) {
|
||||||
|
items(state.cards, key = { it.key.saveableKey() }) { card ->
|
||||||
|
when (card.device.type) {
|
||||||
|
DeviceType.SOCKET -> SocketDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) })
|
||||||
|
DeviceType.TEMPERATURE_HUMIDITY -> SensorDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) })
|
||||||
|
DeviceType.INFRARED -> InfraredDeviceCard(card, { onOpenDevice(card) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showConfiguration) {
|
||||||
|
DeviceConfigurationDialog(
|
||||||
|
onDismiss = { showConfiguration = false },
|
||||||
|
onConfirm = { name, type ->
|
||||||
|
showConfiguration = false
|
||||||
|
onConfigureDevice(name, type)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceConfigurationDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String, DeviceType) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var type by remember { mutableStateOf(DeviceType.SOCKET) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.device_center_configure_title)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text(stringResource(R.string.device_center_device_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
FilterRow(
|
||||||
|
selected = when (type) {
|
||||||
|
DeviceType.SOCKET -> DeviceCategoryFilter.SOCKET
|
||||||
|
DeviceType.TEMPERATURE_HUMIDITY -> DeviceCategoryFilter.SENSOR
|
||||||
|
DeviceType.INFRARED -> DeviceCategoryFilter.INFRARED
|
||||||
|
},
|
||||||
|
onSelected = { selected ->
|
||||||
|
type = when (selected) {
|
||||||
|
DeviceCategoryFilter.SOCKET -> DeviceType.SOCKET
|
||||||
|
DeviceCategoryFilter.SENSOR -> DeviceType.TEMPERATURE_HUMIDITY
|
||||||
|
DeviceCategoryFilter.INFRARED -> DeviceType.INFRARED
|
||||||
|
DeviceCategoryFilter.ALL -> type
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.action_confirm)) }
|
||||||
|
},
|
||||||
|
dismissButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterRow(
|
||||||
|
selected: DeviceCategoryFilter,
|
||||||
|
onSelected: (DeviceCategoryFilter) -> Unit,
|
||||||
|
) {
|
||||||
|
androidx.compose.foundation.lazy.LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
item { FilterChipItem(DeviceCategoryFilter.ALL, "全部", selected, onSelected) }
|
||||||
|
item { FilterChipItem(DeviceCategoryFilter.SOCKET, "插座", selected, onSelected) }
|
||||||
|
item { FilterChipItem(DeviceCategoryFilter.SENSOR, "温湿度", selected, onSelected) }
|
||||||
|
item { FilterChipItem(DeviceCategoryFilter.INFRARED, "红外", selected, onSelected) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FilterChipItem(
|
||||||
|
filter: DeviceCategoryFilter,
|
||||||
|
label: String,
|
||||||
|
selected: DeviceCategoryFilter,
|
||||||
|
onSelected: (DeviceCategoryFilter) -> Unit,
|
||||||
|
) {
|
||||||
|
ABoxFilterChip(selected = selected == filter, label = label, onClick = { onSelected(filter) })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SocketDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onToggle: () -> Unit) {
|
||||||
|
val detail = card.detail as? DeviceCardDetail.Socket
|
||||||
|
val isOffline = card.detail is DeviceCardDetail.Offline
|
||||||
|
val isAwaitingConfirmation = detail?.pendingTarget != null && !detail.operating
|
||||||
|
val isOn = detail?.powerState == SocketPowerState.ON
|
||||||
|
DeviceSurface(card, onOpen, minimumHeight = 0.dp) {
|
||||||
|
DeviceHeader(
|
||||||
|
icon = ABoxIconKey.SOCKET,
|
||||||
|
title = card.displayName,
|
||||||
|
subtitle = "${stringResource(R.string.socket_title)} · ${when { isOffline -> stringResource(R.string.device_status_offline); detail == null -> stringResource(R.string.device_status_unavailable); detail.operating -> stringResource(R.string.device_status_switching); isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation); isOn -> stringResource(R.string.device_status_on); else -> stringResource(R.string.device_status_off) }}",
|
||||||
|
action = {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.border(
|
||||||
|
width = if (isOn) 2.dp else 1.dp,
|
||||||
|
color = if (isOn) MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)
|
||||||
|
else MaterialTheme.colorScheme.outline,
|
||||||
|
shape = CircleShape,
|
||||||
|
),
|
||||||
|
shape = CircleShape,
|
||||||
|
color = if (isOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = if (isOn) 6.dp else 0.dp,
|
||||||
|
) {
|
||||||
|
IconButton(
|
||||||
|
onClick = onToggle,
|
||||||
|
enabled = !isOffline && detail != null && !detail.operating && !isAwaitingConfirmation,
|
||||||
|
) {
|
||||||
|
if (detail?.operating == true) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
color = if (isOn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ABoxIcon(
|
||||||
|
ABoxIconKey.POWER,
|
||||||
|
if (isOn) "关闭插座" else "开启插座",
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
CardFooter(
|
||||||
|
left = when {
|
||||||
|
isOffline -> stringResource(R.string.device_status_offline)
|
||||||
|
isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation)
|
||||||
|
detail?.lastConfirmedAtMillis != null -> stringResource(R.string.device_status_confirmed)
|
||||||
|
else -> stringResource(R.string.device_status_waiting_first_confirmation)
|
||||||
|
},
|
||||||
|
right = stringResource(R.string.device_open_control_panel),
|
||||||
|
modifier = Modifier.padding(top = 24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SensorDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onRefresh: () -> Unit) {
|
||||||
|
val detail = card.detail as? DeviceCardDetail.Sensor
|
||||||
|
val isOffline = card.detail is DeviceCardDetail.Offline
|
||||||
|
DeviceSurface(card, onOpen, minimumHeight = 190.dp) {
|
||||||
|
DeviceHeader(
|
||||||
|
icon = ABoxIconKey.SENSOR,
|
||||||
|
title = card.displayName,
|
||||||
|
subtitle = if (isOffline) {
|
||||||
|
"${stringResource(R.string.device_sensor_subtitle)} · ${stringResource(R.string.device_status_offline)}"
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.device_sensor_subtitle)
|
||||||
|
},
|
||||||
|
action = {
|
||||||
|
IconButton(onClick = onRefresh, enabled = detail?.refreshing != true) {
|
||||||
|
if (detail?.refreshing == true) CircularProgressIndicator(Modifier.size(20.dp))
|
||||||
|
else ABoxIcon(ABoxIconKey.REFRESH, "刷新温湿度")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
Row(Modifier.fillMaxWidth().padding(vertical = 22.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
|
||||||
|
Metric("环境温度", detail?.temperature, "°C", ABoxIconKey.TEMPERATURE)
|
||||||
|
Surface(Modifier.width(1.dp).height(48.dp), color = MaterialTheme.colorScheme.outlineVariant) {}
|
||||||
|
Metric("相对湿度", detail?.humidity, "%", ABoxIconKey.HUMIDITY)
|
||||||
|
}
|
||||||
|
CardFooter(
|
||||||
|
if (isOffline) stringResource(R.string.device_status_offline) else stringResource(R.string.device_live_reading),
|
||||||
|
stringResource(R.string.device_details),
|
||||||
|
Modifier.padding(top = 4.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfraredDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit) {
|
||||||
|
val detail = card.detail as? DeviceCardDetail.Infrared
|
||||||
|
DeviceSurface(card, onOpen, minimumHeight = 172.dp) {
|
||||||
|
DeviceHeader(
|
||||||
|
icon = ABoxIconKey.INFRARED,
|
||||||
|
title = card.displayName,
|
||||||
|
subtitle = stringResource(R.string.device_infrared_subtitle, detail?.remoteNames?.size ?: 0),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.device_infrared_mounted, detail?.remoteNames?.joinToString("、")?.takeIf { it.isNotBlank() } ?: stringResource(R.string.device_infrared_no_remotes)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(top = 22.dp, bottom = 18.dp),
|
||||||
|
)
|
||||||
|
CardFooter(
|
||||||
|
left = detail?.quota?.let { stringResource(R.string.device_infrared_slots, it.usedCount, it.totalCapacity) } ?: stringResource(R.string.device_infrared_slots_unavailable),
|
||||||
|
right = stringResource(R.string.device_infrared_center),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceSurface(
|
||||||
|
card: DeviceCardUiModel,
|
||||||
|
onOpen: () -> Unit,
|
||||||
|
minimumHeight: androidx.compose.ui.unit.Dp,
|
||||||
|
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("device_card_${card.device.id.value}"),
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.defaultMinSize(minHeight = minimumHeight)
|
||||||
|
.clickable(onClick = onOpen)
|
||||||
|
.padding(horizontal = 18.dp, vertical = 18.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceHeader(icon: ABoxIconKey, title: String, subtitle: String, action: (@Composable () -> Unit)? = null) {
|
||||||
|
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) {
|
||||||
|
ABoxIcon(icon, subtitle, Modifier.padding(10.dp).size(24.dp))
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(start = 12.dp)) {
|
||||||
|
Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Metric(label: String, value: String?, unit: String, icon: ABoxIconKey) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
ABoxIcon(icon, label, Modifier.size(18.dp))
|
||||||
|
Text(value ?: stringResource(R.string.device_metric_no_data), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 6.dp))
|
||||||
|
if (value != null) Text(unit, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 2.dp))
|
||||||
|
}
|
||||||
|
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CardFooter(left: String, right: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
|
Text(left, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(right, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
package com.flagship.abox.manager.ui.devicecenter
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||||
|
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||||
|
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
|
||||||
|
import com.flagship.abox.manager.ui.defaults.ABoxDefaultContentInitializer
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||||
|
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class DeviceCenterViewModel @Inject constructor(
|
||||||
|
private val catalog: DeviceCatalog,
|
||||||
|
private val configuredRepository: ConfiguredDeviceRepository,
|
||||||
|
private val socketGateway: SocketGateway,
|
||||||
|
private val temperatureHumidityGateway: TemperatureHumidityGateway,
|
||||||
|
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||||
|
private val defaultContentInitializer: ABoxDefaultContentInitializer,
|
||||||
|
private val activityWriter: ActivityEventWriter,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(DeviceCenterUiState())
|
||||||
|
val uiState: StateFlow<DeviceCenterUiState> = _uiState.asStateFlow()
|
||||||
|
private var catalogJob: Job? = null
|
||||||
|
private var allCards: List<DeviceCardUiModel> = emptyList()
|
||||||
|
|
||||||
|
fun initialize(profileId: ProviderProfileId) {
|
||||||
|
if (_uiState.value.profileId == profileId && catalogJob?.isActive == true) return
|
||||||
|
catalogJob?.cancel()
|
||||||
|
allCards = emptyList()
|
||||||
|
_uiState.value = DeviceCenterUiState(profileId = profileId)
|
||||||
|
catalogJob = viewModelScope.launch {
|
||||||
|
defaultContentInitializer.initialize(profileId)
|
||||||
|
catalog.observeDevices(profileId).collectLatest { result ->
|
||||||
|
when (result) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
allCards = result.value.map(::toCard)
|
||||||
|
publishCards()
|
||||||
|
refreshAllCards()
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> _uiState.value = _uiState.value.copy(content = DeviceCenterContent.ERROR)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setFilter(filter: DeviceCategoryFilter) {
|
||||||
|
_uiState.value = _uiState.value.copy(filter = filter)
|
||||||
|
publishCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun select(device: DeviceDescriptor) {
|
||||||
|
_uiState.value = _uiState.value.copy(selectedCard = DeviceCardKey(device.profileId, device.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addConfiguredDevice(name: String, type: DeviceType) {
|
||||||
|
val profileId = _uiState.value.profileId ?: return
|
||||||
|
viewModelScope.launch { configuredRepository.add(profileId, com.flagship.abox.manager.domain.DeviceName(name), type) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeConfiguredDevice(device: DeviceCardUiModel) {
|
||||||
|
val profileId = _uiState.value.profileId ?: return
|
||||||
|
viewModelScope.launch { configuredRepository.remove(profileId, device.device.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshDevice(card: DeviceCardUiModel) {
|
||||||
|
when (card.device.type) {
|
||||||
|
DeviceType.SOCKET -> refreshSocket(card)
|
||||||
|
DeviceType.TEMPERATURE_HUMIDITY -> refreshSensor(card)
|
||||||
|
DeviceType.INFRARED -> refreshInfrared(card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleSocket(card: DeviceCardUiModel) {
|
||||||
|
val detail = card.detail as? DeviceCardDetail.Socket ?: return
|
||||||
|
if (detail.operating) return
|
||||||
|
val target = if (detail.powerState == SocketPowerState.ON) SocketPowerState.OFF else SocketPowerState.ON
|
||||||
|
updateCard(card.key) { it.copy(detail = detail.copy(operating = true, pendingTarget = target)) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val command = socketGateway.commandPower(card.device.profileId, card.device.name, target)
|
||||||
|
activityWriter.recordDevice(
|
||||||
|
card.device.profileId,
|
||||||
|
card.device.name,
|
||||||
|
ActivityEventKind.SOCKET_COMMAND,
|
||||||
|
command.activityOutcome(),
|
||||||
|
command.activityErrorCode(),
|
||||||
|
)
|
||||||
|
if (command is DomainResult.Success) {
|
||||||
|
repeat(CONFIRMATION_ATTEMPTS) { attempt ->
|
||||||
|
if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS)
|
||||||
|
when (val confirmed = socketGateway.getStatus(card.device.profileId, card.device.name)) {
|
||||||
|
is DomainResult.Success -> if (confirmed.value.powerState == target) {
|
||||||
|
activityWriter.recordDevice(card.device.profileId, card.device.name, ActivityEventKind.SOCKET_CONFIRMATION, ActivityOutcome.SUCCEEDED)
|
||||||
|
updateCard(card.key) {
|
||||||
|
it.copy(detail = DeviceCardDetail.Socket(target, lastConfirmedAtMillis = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activityWriter.recordDevice(
|
||||||
|
card.device.profileId,
|
||||||
|
card.device.name,
|
||||||
|
ActivityEventKind.SOCKET_CONFIRMATION,
|
||||||
|
ActivityOutcome.AWAITING_CONFIRMATION,
|
||||||
|
)
|
||||||
|
updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = target)) }
|
||||||
|
} else {
|
||||||
|
updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = null)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshAllCards() = allCards.forEach(::refreshDevice)
|
||||||
|
|
||||||
|
private fun refreshSocket(card: DeviceCardUiModel) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = socketGateway.getStatus(card.device.profileId, card.device.name)) {
|
||||||
|
is DomainResult.Success -> updateCard(card.key) {
|
||||||
|
it.copy(detail = DeviceCardDetail.Socket(result.value.powerState, lastConfirmedAtMillis = System.currentTimeMillis()))
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> updateCard(card.key) {
|
||||||
|
it.copy(detail = if (result.error is DomainError.DeviceOffline) DeviceCardDetail.Offline else DeviceCardDetail.Unavailable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshSensor(card: DeviceCardUiModel) {
|
||||||
|
val previous = card.detail as? DeviceCardDetail.Sensor
|
||||||
|
updateCard(card.key) { it.copy(detail = previous?.copy(refreshing = true) ?: DeviceCardDetail.Loading) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = temperatureHumidityGateway.getReading(card.device.profileId, card.device.name)) {
|
||||||
|
is DomainResult.Success -> updateCard(card.key) {
|
||||||
|
it.copy(detail = DeviceCardDetail.Sensor(
|
||||||
|
temperature = result.value.temperature,
|
||||||
|
humidity = result.value.humidity,
|
||||||
|
lastUpdatedAtMillis = System.currentTimeMillis(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> updateCard(card.key) {
|
||||||
|
it.copy(
|
||||||
|
detail = if (result.error is DomainError.DeviceOffline) {
|
||||||
|
DeviceCardDetail.Offline
|
||||||
|
} else {
|
||||||
|
previous?.copy(refreshing = false) ?: DeviceCardDetail.Unavailable
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshInfrared(card: DeviceCardUiModel) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val remotes = virtualRemoteRepository.observeRemotes(card.device.profileId, card.device.name).first()
|
||||||
|
val quota = virtualRemoteRepository.observeSlotQuota(card.device.profileId, card.device.name).first()
|
||||||
|
if (remotes is DomainResult.Success && quota is DomainResult.Success) {
|
||||||
|
updateCard(card.key) {
|
||||||
|
it.copy(detail = DeviceCardDetail.Infrared(remotes.value.map { remote -> remote.displayName }, quota.value))
|
||||||
|
}
|
||||||
|
} else updateCard(card.key) { it.copy(detail = DeviceCardDetail.Unavailable) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateCard(key: DeviceCardKey, transform: (DeviceCardUiModel) -> DeviceCardUiModel) {
|
||||||
|
allCards = allCards.map { if (it.key == key) transform(it) else it }
|
||||||
|
publishCards()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun publishCards() {
|
||||||
|
val filter = _uiState.value.filter
|
||||||
|
val cards = allCards.filter { filter.matches(it.device.type) }
|
||||||
|
_uiState.value = _uiState.value.copy(cards = cards, content = if (allCards.isEmpty()) DeviceCenterContent.EMPTY else DeviceCenterContent.CONTENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toCard(device: DeviceDescriptor) = DeviceCardUiModel(DeviceCardKey(device.profileId, device.name), device)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CONFIRMATION_ATTEMPTS = 4
|
||||||
|
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun DomainResult<*>.activityOutcome(): ActivityOutcome =
|
||||||
|
if (this is DomainResult.Success) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED
|
||||||
|
|
||||||
|
private fun DomainResult<*>.activityErrorCode(): String? =
|
||||||
|
(this as? DomainResult.Failure)?.error?.externalCode
|
||||||
|
|
||||||
|
private fun DeviceCategoryFilter.matches(type: DeviceType): Boolean = when (this) {
|
||||||
|
DeviceCategoryFilter.ALL -> true
|
||||||
|
DeviceCategoryFilter.SOCKET -> type == DeviceType.SOCKET
|
||||||
|
DeviceCategoryFilter.SENSOR -> type == DeviceType.TEMPERATURE_HUMIDITY
|
||||||
|
DeviceCategoryFilter.INFRARED -> type == DeviceType.INFRARED
|
||||||
|
}
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
package com.flagship.abox.manager.ui.navigation
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.NavBackStackEntry
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Username
|
||||||
|
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
|
||||||
|
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
|
||||||
|
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
||||||
|
import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute
|
||||||
|
import com.flagship.abox.manager.automation.ui.activity.ActivityRoute
|
||||||
|
import com.flagship.abox.manager.ui.scene.SceneListScreen
|
||||||
|
import com.flagship.abox.manager.ui.preferences.SettingsRoute
|
||||||
|
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
||||||
|
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
|
||||||
|
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
|
||||||
|
import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass
|
||||||
|
import com.flagship.abox.manager.ui.shell.DetailPresentation
|
||||||
|
import com.flagship.abox.manager.ui.shell.DeviceSelection
|
||||||
|
import com.flagship.abox.manager.ui.shell.MainAppShell
|
||||||
|
import com.flagship.abox.manager.ui.shell.MainTab
|
||||||
|
import com.flagship.abox.manager.ui.shell.detailPresentation
|
||||||
|
import com.flagship.abox.manager.ui.shell.widthClass
|
||||||
|
import com.flagship.abox.manager.ui.socket.SocketScreen
|
||||||
|
import com.flagship.abox.manager.ui.socket.SocketUiEvent
|
||||||
|
import com.flagship.abox.manager.ui.socket.SocketViewModel
|
||||||
|
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityScreen
|
||||||
|
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityUiEvent
|
||||||
|
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityViewModel
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
/** Pure authenticated graph state for tab, detail, and profile continuity tests. */
|
||||||
|
data class AuthenticatedGraphState(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val currentTab: MainTab = MainTab.DEVICES,
|
||||||
|
private val backStacks: Map<MainTab, List<String>> = MainTab.entries.associateWith { listOf(it.route) },
|
||||||
|
val selectedDevice: DeviceSelection? = null,
|
||||||
|
) {
|
||||||
|
fun selectTab(tab: MainTab): AuthenticatedGraphState {
|
||||||
|
val nextStack = if (tab == currentTab) {
|
||||||
|
listOf(tab.route)
|
||||||
|
} else {
|
||||||
|
backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) }
|
||||||
|
}
|
||||||
|
return copy(
|
||||||
|
currentTab = tab,
|
||||||
|
backStacks = backStacks + (tab to nextStack),
|
||||||
|
selectedDevice = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pushChild(route: String): AuthenticatedGraphState = copy(
|
||||||
|
backStacks = backStacks + (currentTab to (backStacks[currentTab].orEmpty() + route)),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun tabBackStack(tab: MainTab): List<String> =
|
||||||
|
backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) }
|
||||||
|
|
||||||
|
fun recreateForProfile(newProfileId: ProviderProfileId): AuthenticatedGraphState =
|
||||||
|
AuthenticatedGraphState(profileId = newProfileId)
|
||||||
|
|
||||||
|
fun selectDevice(deviceName: DeviceName, type: DeviceType): AuthenticatedGraphState = copy(
|
||||||
|
selectedDevice = DeviceSelection(profileId, deviceName, type),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun handleBack(): AuthenticatedGraphState = when {
|
||||||
|
selectedDevice != null -> copy(selectedDevice = null)
|
||||||
|
tabBackStack(currentTab).size > 1 -> copy(
|
||||||
|
backStacks = backStacks + (currentTab to tabBackStack(currentTab).dropLast(1)),
|
||||||
|
)
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AuthenticatedNavigationSnapshot(
|
||||||
|
val currentRoute: String?,
|
||||||
|
val selectedDevice: DeviceSelection?,
|
||||||
|
val detailPresentation: DetailPresentation?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val NullableDeviceSelectionSaver: Saver<DeviceSelection?, Any> = DeviceSelection.Saver
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation-owned state host. The authenticated graph and routing tests share this exact state
|
||||||
|
* machine so saved selection, route transitions, resize reconciliation, and BackHandler behavior
|
||||||
|
* cannot drift apart.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun AuthenticatedNavigationStateHost(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
windowSizeClass: ABoxWindowSizeClass,
|
||||||
|
navController: NavHostController,
|
||||||
|
deviceRoute: (DeviceSelection) -> String,
|
||||||
|
selectionForRoute: (String?) -> DeviceSelection? = { null },
|
||||||
|
content: @Composable (
|
||||||
|
snapshot: AuthenticatedNavigationSnapshot,
|
||||||
|
onTabClick: (MainTab) -> Unit,
|
||||||
|
onSelectDevice: (DeviceName, DeviceType) -> Unit,
|
||||||
|
onClearDeviceSelection: () -> Unit,
|
||||||
|
) -> Unit,
|
||||||
|
) {
|
||||||
|
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val currentRoute = backStackEntry?.actualRoute()
|
||||||
|
val routeSelection = selectionForRoute(currentRoute)?.takeIf { it.profileId == profileId }
|
||||||
|
var savedSelection by rememberSaveable(
|
||||||
|
profileId.value,
|
||||||
|
stateSaver = NullableDeviceSelectionSaver,
|
||||||
|
) {
|
||||||
|
mutableStateOf<DeviceSelection?>(null)
|
||||||
|
}
|
||||||
|
val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES
|
||||||
|
val selectedDevice = when {
|
||||||
|
routeSelection != null -> routeSelection
|
||||||
|
currentRoute == MainTab.DEVICES.route -> savedSelection?.takeIf { it.profileId == profileId }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
val selectedPresentation = selectedDevice?.let {
|
||||||
|
detailPresentation(windowSizeClass, it)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(profileId, currentRoute, selectedDevice, windowSizeClass) {
|
||||||
|
if (savedSelection?.profileId != null && savedSelection?.profileId != profileId) {
|
||||||
|
savedSelection = null
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
|
||||||
|
val selection = selectedDevice ?: return@LaunchedEffect
|
||||||
|
when (detailPresentation(windowSizeClass, selection)) {
|
||||||
|
DetailPresentation.PANE -> {
|
||||||
|
if (savedSelection != selection) {
|
||||||
|
savedSelection = selection
|
||||||
|
}
|
||||||
|
if (currentRoute != MainTab.DEVICES.route) {
|
||||||
|
navController.popBackStack(MainTab.DEVICES.route, inclusive = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DetailPresentation.FULL_SCREEN -> {
|
||||||
|
if (currentRoute == MainTab.DEVICES.route) {
|
||||||
|
navController.navigate(deviceRoute(selection)) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
savedSelection = null
|
||||||
|
} else if (savedSelection != null) {
|
||||||
|
savedSelection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val snapshot = AuthenticatedNavigationSnapshot(
|
||||||
|
currentRoute = currentRoute,
|
||||||
|
selectedDevice = selectedDevice,
|
||||||
|
detailPresentation = selectedPresentation,
|
||||||
|
)
|
||||||
|
content(
|
||||||
|
snapshot,
|
||||||
|
{ tab ->
|
||||||
|
savedSelection = null
|
||||||
|
if (tab == currentTab) {
|
||||||
|
navController.popBackStack(tab.route, inclusive = false)
|
||||||
|
} else {
|
||||||
|
navController.navigate(tab.route) {
|
||||||
|
launchSingleTop = true
|
||||||
|
restoreState = true
|
||||||
|
popUpTo(navController.graph.startDestinationId) {
|
||||||
|
saveState = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deviceName, type -> savedSelection = DeviceSelection(profileId, deviceName, type) },
|
||||||
|
{ savedSelection = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AuthenticatedNavigation(
|
||||||
|
username: Username,
|
||||||
|
profile: ProviderProfile,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onProviderClick: () -> Unit,
|
||||||
|
onAvatarClick: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit = {},
|
||||||
|
windowSizeClass: ABoxWindowSizeClass? = null,
|
||||||
|
) {
|
||||||
|
val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp)
|
||||||
|
key(profile.id.value) {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
AuthenticatedNavigationStateHost(
|
||||||
|
profileId = profile.id,
|
||||||
|
windowSizeClass = resolvedWindowSizeClass,
|
||||||
|
navController = navController,
|
||||||
|
deviceRoute = { selection ->
|
||||||
|
when (selection.type) {
|
||||||
|
DeviceType.SOCKET -> DeviceDetailRoutes.socketRoute(profile.id, selection.deviceName)
|
||||||
|
DeviceType.TEMPERATURE_HUMIDITY -> DeviceDetailRoutes.temperatureHumidityRoute(
|
||||||
|
profile.id,
|
||||||
|
selection.deviceName,
|
||||||
|
)
|
||||||
|
DeviceType.INFRARED -> Routes.infraredHub(profile.id.value, selection.deviceName.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
selectionForRoute = { route -> profile.selectionForRoute(route) },
|
||||||
|
) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection ->
|
||||||
|
val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES
|
||||||
|
AuthenticatedGraphContent(
|
||||||
|
navController = navController,
|
||||||
|
profile = profile,
|
||||||
|
username = username,
|
||||||
|
currentTab = currentTab,
|
||||||
|
windowSizeClass = resolvedWindowSizeClass,
|
||||||
|
isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
|
||||||
|
snapshot.currentRoute == MainTab.DEVICES.route,
|
||||||
|
selectedDevice = snapshot.selectedDevice,
|
||||||
|
onTabClick = onTabClick,
|
||||||
|
onSelectDevice = onSelectDevice,
|
||||||
|
onClearDeviceSelection = onClearDeviceSelection,
|
||||||
|
onProviderClick = onProviderClick,
|
||||||
|
onAvatarClick = onAvatarClick,
|
||||||
|
onLogout = onLogout,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthenticatedGraphContent(
|
||||||
|
navController: NavHostController,
|
||||||
|
profile: ProviderProfile,
|
||||||
|
username: Username,
|
||||||
|
currentTab: MainTab,
|
||||||
|
windowSizeClass: ABoxWindowSizeClass,
|
||||||
|
isExpandedDevicePane: Boolean,
|
||||||
|
selectedDevice: DeviceSelection?,
|
||||||
|
onTabClick: (MainTab) -> Unit,
|
||||||
|
onSelectDevice: (DeviceName, DeviceType) -> Unit,
|
||||||
|
onClearDeviceSelection: () -> Unit,
|
||||||
|
onProviderClick: () -> Unit,
|
||||||
|
onAvatarClick: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
AuthenticatedNavHost(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
navController = navController,
|
||||||
|
profile = profile,
|
||||||
|
username = username,
|
||||||
|
currentTab = currentTab,
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
isExpandedDevicePane = isExpandedDevicePane,
|
||||||
|
selectedDevice = selectedDevice,
|
||||||
|
onTabClick = onTabClick,
|
||||||
|
onSelectDevice = onSelectDevice,
|
||||||
|
onClearDeviceSelection = onClearDeviceSelection,
|
||||||
|
onProviderClick = onProviderClick,
|
||||||
|
onAvatarClick = onAvatarClick,
|
||||||
|
onLogout = onLogout,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AuthenticatedNavHost(
|
||||||
|
modifier: Modifier,
|
||||||
|
navController: NavHostController,
|
||||||
|
profile: ProviderProfile,
|
||||||
|
username: Username,
|
||||||
|
currentTab: MainTab,
|
||||||
|
windowSizeClass: ABoxWindowSizeClass,
|
||||||
|
isExpandedDevicePane: Boolean,
|
||||||
|
selectedDevice: DeviceSelection?,
|
||||||
|
onTabClick: (MainTab) -> Unit,
|
||||||
|
onSelectDevice: (DeviceName, DeviceType) -> Unit,
|
||||||
|
onClearDeviceSelection: () -> Unit,
|
||||||
|
onProviderClick: () -> Unit,
|
||||||
|
onAvatarClick: () -> Unit,
|
||||||
|
onLogout: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
) {
|
||||||
|
val activeBackStackEntry by navController.currentBackStackEntryAsState()
|
||||||
|
val activeRoute = activeBackStackEntry?.actualRoute()
|
||||||
|
var backNavigationInFlight by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(activeRoute) {
|
||||||
|
backNavigationInFlight = false
|
||||||
|
}
|
||||||
|
val navigateBackFromDetail: () -> Unit = {
|
||||||
|
val liveRoute = navController.currentBackStackEntry?.actualRoute()
|
||||||
|
val isMainDestination = MainTab.entries.any { it.route == liveRoute }
|
||||||
|
if (!backNavigationInFlight && !isMainDestination) {
|
||||||
|
backNavigationInFlight = true
|
||||||
|
if (!navController.popBackStack()) {
|
||||||
|
backNavigationInFlight = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = MainTab.DEVICES.route,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
composable(MainTab.DEVICES.route) {
|
||||||
|
MainAppShell(
|
||||||
|
currentTab = currentTab,
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
userName = username.value,
|
||||||
|
providerName = profile.displayName,
|
||||||
|
onTabClick = onTabClick,
|
||||||
|
onProviderClick = onProviderClick,
|
||||||
|
onAvatarClick = onAvatarClick,
|
||||||
|
) { paddingValues ->
|
||||||
|
Row(Modifier.fillMaxSize().padding(paddingValues)) {
|
||||||
|
DeviceCenterRoute(
|
||||||
|
profileId = profile.id,
|
||||||
|
onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
if (isExpandedDevicePane && selectedDevice != null) {
|
||||||
|
DeviceDetailPane(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
selection = selectedDevice,
|
||||||
|
onBack = onClearDeviceSelection,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(MainTab.SCENES.route) {
|
||||||
|
MainAppShell(
|
||||||
|
currentTab = currentTab,
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
userName = username.value,
|
||||||
|
providerName = profile.displayName,
|
||||||
|
onTabClick = onTabClick,
|
||||||
|
onProviderClick = onProviderClick,
|
||||||
|
onAvatarClick = onAvatarClick,
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(paddingValues)) {
|
||||||
|
SceneListScreen(
|
||||||
|
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
|
||||||
|
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
|
||||||
|
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
|
||||||
|
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(MainTab.ACTIVITY.route) {
|
||||||
|
MainAppShell(
|
||||||
|
currentTab = currentTab,
|
||||||
|
windowSizeClass = windowSizeClass,
|
||||||
|
userName = username.value,
|
||||||
|
providerName = profile.displayName,
|
||||||
|
onTabClick = onTabClick,
|
||||||
|
onProviderClick = onProviderClick,
|
||||||
|
onAvatarClick = onAvatarClick,
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(Modifier.fillMaxSize().padding(paddingValues)) {
|
||||||
|
ActivityRoute(profileId = profile.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composable(Routes.SETTINGS) {
|
||||||
|
SettingsRoute()
|
||||||
|
}
|
||||||
|
deviceDetailGraph(
|
||||||
|
onBackClick = {
|
||||||
|
onClearDeviceSelection()
|
||||||
|
navigateBackFromDetail()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sceneEditorDestination(
|
||||||
|
onBack = navigateBackFromDetail,
|
||||||
|
onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) },
|
||||||
|
)
|
||||||
|
sceneExecutionDestination(onBack = navigateBackFromDetail)
|
||||||
|
composable(
|
||||||
|
route = Routes.INFRARED_HUB,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
|
navArgument("deviceName") { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) { entry ->
|
||||||
|
InfraredHubRoute(
|
||||||
|
onBack = {
|
||||||
|
onClearDeviceSelection()
|
||||||
|
navigateBackFromDetail()
|
||||||
|
},
|
||||||
|
onOpenRemote = { remoteId ->
|
||||||
|
navController.navigate(
|
||||||
|
"${Routes.INFRARED_PANEL}/${Uri.encode(profile.id.value)}/${Uri.encode(remoteId)}",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onOpenSettings = {
|
||||||
|
val deviceName = entry.arguments?.getString("deviceName").orEmpty()
|
||||||
|
navController.navigate(Routes.infraredSettings(profile.id.value, deviceName))
|
||||||
|
},
|
||||||
|
onSessionInvalid = onLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = "${Routes.INFRARED_PANEL}/{profileId}/{remoteId}",
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
|
navArgument("remoteId") { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
RemotePanelRoute(
|
||||||
|
onBack = navigateBackFromDetail,
|
||||||
|
onSessionInvalid = onLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.INFRARED_SETTINGS,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
|
navArgument("deviceName") { type = NavType.StringType },
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
InfraredControlRoute(
|
||||||
|
onBack = navigateBackFromDetail,
|
||||||
|
onSessionInvalid = onLogout,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceDetailPane(
|
||||||
|
selection: DeviceSelection,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val viewModelKey = "${selection.profileId.value}:${selection.deviceName.value}:${selection.type.name}"
|
||||||
|
when (selection.type) {
|
||||||
|
DeviceType.SOCKET -> {
|
||||||
|
val viewModel: SocketViewModel = hiltViewModel(key = viewModelKey)
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(selection) {
|
||||||
|
viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName))
|
||||||
|
}
|
||||||
|
SocketScreen(
|
||||||
|
state = state,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
onBackClick = onBack,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DeviceType.TEMPERATURE_HUMIDITY -> {
|
||||||
|
val viewModel: TemperatureHumidityViewModel = hiltViewModel(key = viewModelKey)
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(selection) {
|
||||||
|
viewModel.onEvent(
|
||||||
|
TemperatureHumidityUiEvent.Initialize(selection.profileId, selection.deviceName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TemperatureHumidityScreen(
|
||||||
|
state = state,
|
||||||
|
onEvent = viewModel::onEvent,
|
||||||
|
onBackClick = onBack,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DeviceType.INFRARED -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? {
|
||||||
|
val parts = route?.split('/') ?: return null
|
||||||
|
return when {
|
||||||
|
parts.size >= 4 && parts[0] == "device" && parts[1] == "socket" -> DeviceSelection(
|
||||||
|
profileId = id,
|
||||||
|
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
|
||||||
|
type = DeviceType.SOCKET,
|
||||||
|
)
|
||||||
|
parts.size >= 4 && parts[0] == "device" && parts[1] == "th" -> DeviceSelection(
|
||||||
|
profileId = id,
|
||||||
|
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
|
||||||
|
type = DeviceType.TEMPERATURE_HUMIDITY,
|
||||||
|
)
|
||||||
|
parts.size >= 3 && parts[0] == "infrared_hub" -> DeviceSelection(
|
||||||
|
profileId = id,
|
||||||
|
deviceName = DeviceName(parts[2].decodeRoutePart()),
|
||||||
|
type = DeviceType.INFRARED,
|
||||||
|
)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.decodeRoutePart(): String = runCatching {
|
||||||
|
URLDecoder.decode(this, StandardCharsets.UTF_8.name())
|
||||||
|
}.getOrDefault(this)
|
||||||
|
|
||||||
|
private fun NavBackStackEntry.actualRoute(): String? {
|
||||||
|
var route = destination.route ?: return null
|
||||||
|
destination.arguments.keys.forEach { argumentName ->
|
||||||
|
arguments?.getString(argumentName)?.let { value ->
|
||||||
|
route = route.replace("{$argumentName}", Uri.encode(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return route
|
||||||
|
}
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
package com.flagship.abox.manager.ui.navigation
|
package com.flagship.abox.manager.ui.navigation
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
|
||||||
object Routes {
|
object Routes {
|
||||||
const val STARTUP = "startup"
|
const val STARTUP = "startup"
|
||||||
const val PROVIDER_SELECTION = "provider_selection"
|
const val PROVIDER_SELECTION = "provider_selection"
|
||||||
const val LOGIN = "login"
|
const val LOGIN = "login"
|
||||||
|
|
||||||
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
|
|
||||||
const val AUTHENTICATED = "authenticated"
|
const val AUTHENTICATED = "authenticated"
|
||||||
|
const val SETTINGS = "settings"
|
||||||
|
|
||||||
|
const val INFRARED_HUB = "infrared_hub/{profileId}/{deviceName}"
|
||||||
|
const val INFRARED_PANEL = "infrared_panel"
|
||||||
|
const val INFRARED_SETTINGS = "infrared_settings/{profileId}/{deviceName}"
|
||||||
|
|
||||||
|
fun infraredHub(profileId: String, deviceName: String): String =
|
||||||
|
"infrared_hub/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
|
||||||
|
|
||||||
|
fun infraredSettings(profileId: String, deviceName: String): String =
|
||||||
|
"infrared_settings/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
package com.flagship.abox.manager.ui.preferences
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.outlined.Check
|
||||||
|
import androidx.compose.material.icons.outlined.ChevronRight
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.alpha
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxErrorState
|
||||||
|
import com.flagship.abox.manager.designsystem.LocalABoxShapes
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
|
import com.flagship.abox.manager.preferences.AccentPreference
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxAccentPalette
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxBackLayer
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.InterfaceScale
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStandard
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStyle
|
||||||
|
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||||
|
|
||||||
|
private enum class SettingsDestination { HOME, THEME }
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
class SettingsExperienceState(initialDestination: String = SettingsDestination.HOME.name) {
|
||||||
|
var destination: String by mutableStateOf(initialDestination)
|
||||||
|
var themeBackReturnsHome: Boolean by mutableStateOf(true)
|
||||||
|
val isHome: Boolean get() = destination == SettingsDestination.HOME.name
|
||||||
|
|
||||||
|
fun showHome() {
|
||||||
|
destination = SettingsDestination.HOME.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showTheme(returnToHome: Boolean = true) {
|
||||||
|
themeBackReturnsHome = returnToHome
|
||||||
|
destination = SettingsDestination.THEME.name
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val Saver: Saver<SettingsExperienceState, String> = Saver(
|
||||||
|
save = { "${it.destination}|${it.themeBackReturnsHome}" },
|
||||||
|
restore = { saved ->
|
||||||
|
val parts = saved.split('|', limit = 2)
|
||||||
|
SettingsExperienceState(parts.first()).apply {
|
||||||
|
themeBackReturnsHome = parts.getOrNull(1)?.toBooleanStrictOrNull() ?: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsRoute(
|
||||||
|
experienceState: SettingsExperienceState = SettingsExperienceState(),
|
||||||
|
onExit: () -> Unit = {},
|
||||||
|
viewModel: SettingsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||||
|
SettingsExperience(state, experienceState, viewModel::onAction, onExit)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsExperience(
|
||||||
|
state: SettingsUiState,
|
||||||
|
experienceState: SettingsExperienceState,
|
||||||
|
onAction: (SettingsUiAction) -> Unit,
|
||||||
|
onExit: () -> Unit,
|
||||||
|
) {
|
||||||
|
val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) }
|
||||||
|
.getOrDefault(SettingsDestination.HOME)
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
SettingsHomeScreen(
|
||||||
|
state = state,
|
||||||
|
onOpenTheme = { experienceState.showTheme(returnToHome = true) },
|
||||||
|
onBack = onExit,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (destination == SettingsDestination.THEME) {
|
||||||
|
val leaveTheme = {
|
||||||
|
if (experienceState.themeBackReturnsHome) experienceState.showHome() else onExit()
|
||||||
|
}
|
||||||
|
ABoxBackLayer(onBack = leaveTheme) {
|
||||||
|
ABoxSurface(modifier = Modifier.fillMaxSize()) {
|
||||||
|
ThemeSettingsScreen(
|
||||||
|
state = state,
|
||||||
|
onBack = leaveTheme,
|
||||||
|
onAction = onAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SettingsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
onAction: (SettingsUiAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) = SettingsHomeScreen(state, onOpenTheme = {}, onBack = {}, onAction = onAction, modifier = modifier)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsHomeScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
onOpenTheme: () -> Unit,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onAction: (SettingsUiAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var styleMenuExpanded by remember { mutableStateOf(false) }
|
||||||
|
SettingsPage(modifier = modifier, title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||||
|
SettingsGroup {
|
||||||
|
Box(Modifier.fillMaxWidth()) {
|
||||||
|
SettingsLinkRow(
|
||||||
|
icon = ABoxIconKey.SETTINGS,
|
||||||
|
title = stringResource(R.string.settings_style_entry),
|
||||||
|
description = stringResource(R.string.settings_renderer_description),
|
||||||
|
value = rendererLabel(state.preferences.renderer),
|
||||||
|
modifier = Modifier.testTag("settings-style-entry"),
|
||||||
|
) { styleMenuExpanded = true }
|
||||||
|
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = styleMenuExpanded,
|
||||||
|
onDismissRequest = { styleMenuExpanded = false },
|
||||||
|
modifier = Modifier.testTag("settings-style-menu"),
|
||||||
|
) {
|
||||||
|
DesignRenderer.entries.forEach { renderer ->
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(rendererLabel(renderer)) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (renderer == state.preferences.renderer) Icon(Icons.Outlined.Check, null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
styleMenuExpanded = false
|
||||||
|
onAction(SettingsUiAction.SetRenderer(renderer))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
GroupDivider()
|
||||||
|
SettingsLinkRow(
|
||||||
|
icon = ABoxIconKey.THEME,
|
||||||
|
title = stringResource(R.string.settings_theme_entry),
|
||||||
|
description = stringResource(R.string.settings_theme_entry_description),
|
||||||
|
value = null,
|
||||||
|
modifier = Modifier.testTag("settings-theme-entry"),
|
||||||
|
onClick = onOpenTheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsGroup {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
icon = ABoxIconKey.FEEDBACK,
|
||||||
|
title = stringResource(R.string.settings_haptics),
|
||||||
|
description = stringResource(R.string.settings_haptics_description),
|
||||||
|
checked = state.preferences.hapticsEnabled,
|
||||||
|
tag = "settings-haptics",
|
||||||
|
) { onAction(SettingsUiAction.SetHaptics(it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsGroup {
|
||||||
|
SettingsInfoRow(
|
||||||
|
icon = ABoxIconKey.SETTINGS,
|
||||||
|
title = stringResource(R.string.settings_accessibility_card_title),
|
||||||
|
description = stringResource(R.string.settings_accessibility_description),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SettingsError(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeSettingsScreen(
|
||||||
|
state: SettingsUiState,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onAction: (SettingsUiAction) -> Unit,
|
||||||
|
) {
|
||||||
|
SettingsPage(
|
||||||
|
title = stringResource(R.string.settings_theme_detail_title),
|
||||||
|
onBack = onBack,
|
||||||
|
modifier = Modifier.testTag("settings-theme-detail"),
|
||||||
|
) {
|
||||||
|
ThemePreview(state)
|
||||||
|
when (state.preferences.renderer) {
|
||||||
|
DesignRenderer.MATERIAL3 -> MaterialThemeOptions(state, onAction)
|
||||||
|
DesignRenderer.MIUIX -> MiuixThemeOptions(state, onAction)
|
||||||
|
}
|
||||||
|
SettingsError(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||||
|
val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
|
||||||
|
SettingsGroup(modifier = Modifier.testTag("settings-material-colors")) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
icon = ABoxIconKey.THEME,
|
||||||
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
|
description = stringResource(R.string.settings_dynamic_color_description),
|
||||||
|
checked = state.preferences.dynamicColorEnabled,
|
||||||
|
tag = "settings-dynamic-color",
|
||||||
|
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
|
||||||
|
GroupDivider()
|
||||||
|
AccentSelector(state, onAction)
|
||||||
|
}
|
||||||
|
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
|
||||||
|
EnumSettingRow(
|
||||||
|
modifier = Modifier.testTag("settings-color-style"),
|
||||||
|
icon = ABoxIconKey.THEME,
|
||||||
|
title = stringResource(R.string.settings_color_style),
|
||||||
|
value = colorStyleLabel(state.preferences.colorStyle),
|
||||||
|
values = ColorStyle.entries,
|
||||||
|
label = { colorStyleLabel(it) },
|
||||||
|
onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) },
|
||||||
|
enabled = manualColorsEnabled,
|
||||||
|
)
|
||||||
|
EnumSettingRow(
|
||||||
|
modifier = Modifier.testTag("settings-color-standard"),
|
||||||
|
icon = ABoxIconKey.SETTINGS,
|
||||||
|
title = stringResource(R.string.settings_color_standard),
|
||||||
|
value = colorStandardLabel(state.preferences.colorStandard),
|
||||||
|
values = ColorStandard.entries,
|
||||||
|
label = { colorStandardLabel(it) },
|
||||||
|
onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) },
|
||||||
|
enabled = manualColorsEnabled,
|
||||||
|
)
|
||||||
|
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||||
|
val renderEffectsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||||
|
val floatingEnabled = state.preferences.navigationPresentation == NavigationPresentation.FLOATING
|
||||||
|
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
|
||||||
|
SettingsGroup(modifier = Modifier.testTag("settings-miuix-accent")) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
icon = ABoxIconKey.THEME,
|
||||||
|
title = stringResource(R.string.settings_dynamic_color),
|
||||||
|
description = stringResource(R.string.settings_dynamic_color_description),
|
||||||
|
checked = state.preferences.dynamicColorEnabled,
|
||||||
|
tag = "settings-dynamic-color",
|
||||||
|
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
|
||||||
|
GroupDivider()
|
||||||
|
AccentSelector(state, onAction)
|
||||||
|
}
|
||||||
|
SettingsGroup(modifier = Modifier.testTag("settings-blur")) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
icon = ABoxIconKey.EFFECTS,
|
||||||
|
title = stringResource(R.string.settings_blur),
|
||||||
|
description = stringResource(R.string.settings_blur_description),
|
||||||
|
checked = state.preferences.blurEnabled,
|
||||||
|
tag = "settings-blur-switch",
|
||||||
|
enabled = renderEffectsSupported,
|
||||||
|
unavailableDescription = if (renderEffectsSupported) null else stringResource(R.string.settings_requires_android_13),
|
||||||
|
) { onAction(SettingsUiAction.SetBlur(it)) }
|
||||||
|
}
|
||||||
|
SettingsGroup(modifier = Modifier.testTag("settings-floating-navigation")) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
icon = ABoxIconKey.EFFECTS,
|
||||||
|
title = stringResource(R.string.settings_navigation_floating),
|
||||||
|
description = stringResource(R.string.settings_navigation_floating_description),
|
||||||
|
checked = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
|
||||||
|
tag = "settings-floating-navigation-switch",
|
||||||
|
) { onAction(SettingsUiAction.SetNavigation(if (it) NavigationPresentation.FLOATING else NavigationPresentation.STANDARD)) }
|
||||||
|
}
|
||||||
|
SettingsGroup(modifier = Modifier.testTag("settings-liquid-glass")) {
|
||||||
|
SettingsSwitchRow(
|
||||||
|
icon = ABoxIconKey.EFFECTS,
|
||||||
|
title = stringResource(R.string.settings_liquid_glass),
|
||||||
|
description = stringResource(R.string.settings_liquid_glass_description),
|
||||||
|
checked = state.preferences.liquidGlassEnabled,
|
||||||
|
tag = "settings-liquid-glass-switch",
|
||||||
|
enabled = renderEffectsSupported && floatingEnabled,
|
||||||
|
unavailableDescription = when {
|
||||||
|
!floatingEnabled -> stringResource(R.string.settings_requires_floating_navigation)
|
||||||
|
!renderEffectsSupported -> stringResource(R.string.settings_requires_android_13)
|
||||||
|
else -> null
|
||||||
|
},
|
||||||
|
) { onAction(SettingsUiAction.SetLiquidGlass(it)) }
|
||||||
|
}
|
||||||
|
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsPage(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.action_back)) }
|
||||||
|
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsGroup(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
content = { Column(content = content) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemePreview(state: SettingsUiState) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(190.dp),
|
||||||
|
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||||
|
) {
|
||||||
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.width(184.dp).height(142.dp),
|
||||||
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(Modifier.size(22.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Box(Modifier.height(10.dp).weight(1f).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape))
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxWidth().height(58.dp).background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp)))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
repeat(3) { Box(Modifier.weight(1f).height(18.dp).background(if (it == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, CircleShape)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = "${rendererLabel(state.preferences.renderer)} · ${state.preferences.interfaceScale.percent}%",
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RendererSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
RendererOption(DesignRenderer.MATERIAL3, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-primary"), onAction)
|
||||||
|
RendererOption(DesignRenderer.MIUIX, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-secondary"), onAction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RendererOption(value: DesignRenderer, selected: DesignRenderer, modifier: Modifier, onAction: (SettingsUiAction) -> Unit) {
|
||||||
|
val active = value == selected
|
||||||
|
Surface(
|
||||||
|
modifier = modifier.clickable { onAction(SettingsUiAction.SetRenderer(value)) },
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
border = if (active) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||||
|
) {
|
||||||
|
Row(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(rendererLabel(value), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||||
|
if (active) Icon(Icons.Outlined.Check, null, tint = MaterialTheme.colorScheme.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ThemeModeSelector(
|
||||||
|
state: SettingsUiState,
|
||||||
|
onAction: (SettingsUiAction) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
AppThemeMode.entries.forEach { mode ->
|
||||||
|
val selected = state.preferences.themeMode == mode
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetThemeMode(mode)) },
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||||
|
) {
|
||||||
|
Text(themeLabel(mode), Modifier.padding(vertical = 14.dp), textAlign = TextAlign.Center, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun <T> EnumSettingRow(
|
||||||
|
modifier: Modifier,
|
||||||
|
icon: ABoxIconKey,
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
values: List<T>,
|
||||||
|
label: @Composable (T) -> String,
|
||||||
|
onSelected: (T) -> Unit,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
var expanded by remember { mutableStateOf(false) }
|
||||||
|
SettingsGroup(modifier = modifier) {
|
||||||
|
Box(Modifier.fillMaxWidth().alpha(if (enabled) 1f else 0.42f)) {
|
||||||
|
SettingsLinkRow(
|
||||||
|
icon = icon,
|
||||||
|
title = title,
|
||||||
|
description = stringResource(R.string.settings_tap_to_choose),
|
||||||
|
value = value,
|
||||||
|
enabled = enabled,
|
||||||
|
onClick = { expanded = true },
|
||||||
|
)
|
||||||
|
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
|
||||||
|
values.forEach { option ->
|
||||||
|
val optionLabel = label(option)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(optionLabel) },
|
||||||
|
trailingIcon = { if (optionLabel == value) Icon(Icons.Outlined.Check, null) },
|
||||||
|
onClick = {
|
||||||
|
expanded = false
|
||||||
|
onSelected(option)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccentSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||||
|
val dark = when (state.preferences.themeMode) {
|
||||||
|
AppThemeMode.DARK -> true
|
||||||
|
AppThemeMode.LIGHT -> false
|
||||||
|
AppThemeMode.SYSTEM -> androidx.compose.foundation.isSystemInDarkTheme()
|
||||||
|
}
|
||||||
|
val manualColorsEnabled = !state.preferences.dynamicColorEnabled || Build.VERSION.SDK_INT < Build.VERSION_CODES.S
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(stringResource(R.string.settings_accent_label), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().alpha(if (manualColorsEnabled) 1f else 0.42f),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
AccentPreference.entries.forEach { accent ->
|
||||||
|
val color = ABoxAccentPalette.preview(
|
||||||
|
accent = accent,
|
||||||
|
style = state.preferences.colorStyle,
|
||||||
|
standard = state.preferences.colorStandard,
|
||||||
|
dark = dark,
|
||||||
|
)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.size(44.dp).background(color, CircleShape)
|
||||||
|
.then(if (state.preferences.accentPreference == accent) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
|
||||||
|
.clickable(enabled = manualColorsEnabled) { onAction(SettingsUiAction.SetAccent(accent)) },
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
if (state.preferences.accentPreference == accent) Icon(Icons.Outlined.Check, null, tint = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ScaleSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
SettingsRowHeader(ABoxIconKey.SCALE, stringResource(R.string.settings_scale), stringResource(R.string.settings_scale_description))
|
||||||
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
InterfaceScale.entries.forEach { scale ->
|
||||||
|
val selected = state.preferences.interfaceScale == scale
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetInterfaceScale(scale)) },
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
|
||||||
|
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
|
||||||
|
) { Text("${scale.percent}%", Modifier.padding(vertical = 12.dp), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, enabled: Boolean = true, onClick: () -> Unit) {
|
||||||
|
Row(modifier.fillMaxWidth().clickable(enabled = enabled, onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SettingsRowHeader(icon, title, description, Modifier.weight(1f))
|
||||||
|
if (value != null) Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Icon(Icons.Outlined.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsSwitchRow(
|
||||||
|
icon: ABoxIconKey,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
checked: Boolean,
|
||||||
|
tag: String,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
unavailableDescription: String? = null,
|
||||||
|
onChange: (Boolean) -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().clickable(enabled = enabled) { onChange(!checked) }.padding(14.dp).alpha(if (enabled) 1f else 0.5f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
SettingsRowHeader(icon, title, unavailableDescription ?: description, Modifier.weight(1f))
|
||||||
|
Switch(checked, onCheckedChange = onChange, enabled = enabled, modifier = Modifier.testTag(tag))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsInfoRow(icon: ABoxIconKey, title: String, description: String) {
|
||||||
|
SettingsRowHeader(icon, title, description, Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: String, modifier: Modifier = Modifier) {
|
||||||
|
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
|
ABoxIcon(icon, title, Modifier.size(28.dp))
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||||
|
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable private fun GroupDivider() = Box(Modifier.fillMaxWidth().padding(start = 58.dp).height(1.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)))
|
||||||
|
|
||||||
|
@Composable private fun SettingsError(state: SettingsUiState) { state.error?.let { ABoxErrorState(stringResource(R.string.settings_error_save), it.asString()) } }
|
||||||
|
|
||||||
|
@Composable private fun rendererLabel(renderer: DesignRenderer) = stringResource(if (renderer == DesignRenderer.MATERIAL3) R.string.settings_renderer_primary else R.string.settings_renderer_secondary)
|
||||||
|
@Composable private fun themeLabel(mode: AppThemeMode) = stringResource(when (mode) { AppThemeMode.SYSTEM -> R.string.settings_theme_system; AppThemeMode.LIGHT -> R.string.settings_theme_light; AppThemeMode.DARK -> R.string.settings_theme_dark })
|
||||||
|
@Composable private fun colorStyleLabel(value: ColorStyle) = stringResource(when (value) { ColorStyle.TONAL_SPOT -> R.string.settings_color_style_tonal; ColorStyle.EXPRESSIVE -> R.string.settings_color_style_expressive; ColorStyle.FIDELITY -> R.string.settings_color_style_fidelity })
|
||||||
|
@Composable private fun colorStandardLabel(value: ColorStandard) = stringResource(when (value) { ColorStandard.SPEC_2021 -> R.string.settings_color_standard_2021; ColorStandard.SPEC_2025 -> R.string.settings_color_standard_2025 })
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package com.flagship.abox.manager.ui.preferences
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.AccentPreference
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.InterfaceScale
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStandard
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStyle
|
||||||
|
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||||
|
import com.flagship.abox.manager.preferences.PreferenceField
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferencesRepository
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferencesWriteResult
|
||||||
|
import com.flagship.abox.manager.ui.common.UiText
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SettingsViewModel @Inject constructor(
|
||||||
|
private val repository: UiPreferencesRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _uiState = MutableStateFlow(SettingsUiState())
|
||||||
|
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private val observeJob: Job = viewModelScope.launch {
|
||||||
|
repository.observe().collect { preferences ->
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
preferences = preferences,
|
||||||
|
loaded = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAction(action: SettingsUiAction) {
|
||||||
|
when (action) {
|
||||||
|
is SettingsUiAction.SetRenderer -> update(
|
||||||
|
field = PreferenceField.RENDERER,
|
||||||
|
transform = { it.copy(renderer = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetThemeMode -> update(
|
||||||
|
field = PreferenceField.THEME_MODE,
|
||||||
|
transform = { it.copy(themeMode = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetDynamicColor -> update(
|
||||||
|
field = PreferenceField.DYNAMIC_COLOR,
|
||||||
|
transform = { it.copy(dynamicColorEnabled = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetBlur -> update(
|
||||||
|
field = PreferenceField.BLUR,
|
||||||
|
transform = { it.copy(blurEnabled = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetNavigation -> update(
|
||||||
|
field = PreferenceField.NAVIGATION,
|
||||||
|
transform = { it.copy(navigationPresentation = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetHaptics -> update(
|
||||||
|
field = PreferenceField.HAPTICS,
|
||||||
|
transform = { it.copy(hapticsEnabled = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetInterfaceScale -> update(
|
||||||
|
field = PreferenceField.INTERFACE_SCALE,
|
||||||
|
transform = { it.copy(interfaceScale = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetAccent -> update(
|
||||||
|
field = PreferenceField.ACCENT,
|
||||||
|
transform = { it.copy(accentPreference = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetColorStyle -> update(
|
||||||
|
field = PreferenceField.COLOR_STYLE,
|
||||||
|
transform = { it.copy(colorStyle = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetColorStandard -> update(
|
||||||
|
field = PreferenceField.COLOR_STANDARD,
|
||||||
|
transform = { it.copy(colorStandard = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetLiquidGlass -> update(
|
||||||
|
field = PreferenceField.LIQUID_GLASS,
|
||||||
|
transform = { it.copy(liquidGlassEnabled = action.value) },
|
||||||
|
)
|
||||||
|
is SettingsUiAction.SetPredictiveBack -> update(
|
||||||
|
field = PreferenceField.PREDICTIVE_BACK,
|
||||||
|
transform = { it.copy(predictiveBackEnabled = action.value) },
|
||||||
|
)
|
||||||
|
SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun update(
|
||||||
|
field: PreferenceField,
|
||||||
|
transform: (UiPreferences) -> UiPreferences,
|
||||||
|
) {
|
||||||
|
if (field in _uiState.value.saving) return
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
saving = _uiState.value.saving + field,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
when (repository.update(transform)) {
|
||||||
|
UiPreferencesWriteResult.Success -> Unit
|
||||||
|
UiPreferencesWriteResult.Failure -> showWriteFailure(field)
|
||||||
|
}
|
||||||
|
} catch (cause: CancellationException) {
|
||||||
|
throw cause
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
showWriteFailure(field)
|
||||||
|
} finally {
|
||||||
|
_uiState.value = _uiState.value.copy(saving = _uiState.value.saving - field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showWriteFailure(field: PreferenceField) {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
error = UiText.StringResource(R.string.settings_error_save),
|
||||||
|
saving = _uiState.value.saving - field,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCleared() {
|
||||||
|
observeJob.cancel()
|
||||||
|
super.onCleared()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SettingsUiState(
|
||||||
|
val preferences: UiPreferences = UiPreferences(),
|
||||||
|
val loaded: Boolean = false,
|
||||||
|
val saving: Set<PreferenceField> = emptySet(),
|
||||||
|
val error: UiText? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface SettingsUiAction {
|
||||||
|
data class SetRenderer(val value: DesignRenderer) : SettingsUiAction
|
||||||
|
data class SetThemeMode(val value: AppThemeMode) : SettingsUiAction
|
||||||
|
data class SetDynamicColor(val value: Boolean) : SettingsUiAction
|
||||||
|
data class SetBlur(val value: Boolean) : SettingsUiAction
|
||||||
|
data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction
|
||||||
|
data class SetHaptics(val value: Boolean) : SettingsUiAction
|
||||||
|
data class SetInterfaceScale(val value: InterfaceScale) : SettingsUiAction
|
||||||
|
data class SetAccent(val value: AccentPreference) : SettingsUiAction
|
||||||
|
data class SetColorStyle(val value: ColorStyle) : SettingsUiAction
|
||||||
|
data class SetColorStandard(val value: ColorStandard) : SettingsUiAction
|
||||||
|
data class SetLiquidGlass(val value: Boolean) : SettingsUiAction
|
||||||
|
data class SetPredictiveBack(val value: Boolean) : SettingsUiAction
|
||||||
|
data object DismissError : SettingsUiAction
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||||
|
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||||
|
import com.flagship.abox.manager.domain.ActivitySource
|
||||||
|
import com.flagship.abox.manager.domain.ActivityTarget
|
||||||
|
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionExecutionResult
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionOutcome
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionResult
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionSource
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionUpdate
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
|
class RecordingSceneExecutor(
|
||||||
|
private val delegate: SceneExecutor,
|
||||||
|
private val writer: ActivityEventWriter,
|
||||||
|
) : SceneExecutor {
|
||||||
|
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
|
||||||
|
val result = delegate.execute(scene)
|
||||||
|
if (scene.enableLogging) {
|
||||||
|
when (result) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
result.value.results.forEach { recordStep(scene, it, ActivitySource.USER) }
|
||||||
|
recordScene(scene, if (result.value.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED, ActivitySource.USER)
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> recordScene(scene, ActivityOutcome.FAILED, ActivitySource.USER, result.error.externalCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute(
|
||||||
|
scene: Scene,
|
||||||
|
executionId: SceneExecutionId,
|
||||||
|
source: SceneExecutionSource,
|
||||||
|
): Flow<SceneExecutionUpdate> {
|
||||||
|
val activitySource = if (source == SceneExecutionSource.AUTOMATION) ActivitySource.AUTOMATION else ActivitySource.USER
|
||||||
|
return delegate.execute(scene, executionId, source).onEach { update ->
|
||||||
|
if (!scene.enableLogging) return@onEach
|
||||||
|
when (update) {
|
||||||
|
is SceneExecutionUpdate.StepFinished -> recordStep(scene, update.result, activitySource)
|
||||||
|
is SceneExecutionUpdate.Completed -> recordScene(
|
||||||
|
scene,
|
||||||
|
if (update.result.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED,
|
||||||
|
activitySource,
|
||||||
|
)
|
||||||
|
is SceneExecutionUpdate.Rejected -> recordScene(scene, ActivityOutcome.FAILED, activitySource, update.error.externalCode)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordScene(scene: Scene, outcome: ActivityOutcome, source: ActivitySource, errorCode: String? = null) {
|
||||||
|
writer.record(
|
||||||
|
scene.profileId,
|
||||||
|
ActivityEventKind.SCENE_EXECUTION,
|
||||||
|
source,
|
||||||
|
outcome,
|
||||||
|
ActivityTarget(ActivityTargetType.SCENE, scene.id.value, scene.name),
|
||||||
|
errorCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun recordStep(scene: Scene, result: SceneActionExecutionResult, source: ActivitySource) {
|
||||||
|
writer.record(
|
||||||
|
scene.profileId,
|
||||||
|
ActivityEventKind.SCENE_ACTION,
|
||||||
|
source,
|
||||||
|
when (result.outcome) {
|
||||||
|
SceneActionOutcome.SUCCESS -> ActivityOutcome.SUCCEEDED
|
||||||
|
SceneActionOutcome.FAILURE -> ActivityOutcome.FAILED
|
||||||
|
SceneActionOutcome.SKIPPED -> ActivityOutcome.CANCELLED
|
||||||
|
},
|
||||||
|
result.action.activityTarget(),
|
||||||
|
result.error?.externalCode,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun SceneAction.activityTarget(): ActivityTarget = when (this) {
|
||||||
|
is SceneAction.SocketPower -> ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value)
|
||||||
|
is SceneAction.InfraredKey -> ActivityTarget(ActivityTargetType.INFRARED_KEY, key, deviceName.value)
|
||||||
|
is SceneAction.VirtualRemoteButton -> ActivityTarget(ActivityTargetType.REMOTE, remoteId, "$remoteDisplayName · $buttonDisplayName")
|
||||||
|
is SceneAction.Delay -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, "Delay ${durationSeconds}s")
|
||||||
|
is SceneAction.Notification -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, title)
|
||||||
|
}
|
||||||
@@ -6,6 +6,9 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.FlowRowScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
@@ -81,12 +84,11 @@ import com.flagship.abox.manager.domain.SceneIcon
|
|||||||
import com.flagship.abox.manager.domain.SceneId
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
import com.flagship.abox.manager.domain.SceneTrigger
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||||
|
|
||||||
private const val DEFAULT_SOCKET_DEVICE_NAME = "Zigbee插座控制器"
|
|
||||||
private const val DEFAULT_TH_DEVICE_NAME = "温湿度传感器"
|
|
||||||
private const val DEFAULT_IR_DEVICE_NAME = "红外控制器"
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -505,7 +507,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL,
|
selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL,
|
||||||
onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) },
|
onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) },
|
||||||
@@ -521,7 +523,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR,
|
selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR,
|
||||||
onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) },
|
onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) },
|
||||||
@@ -572,6 +574,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
if (showTriggerDialog) {
|
if (showTriggerDialog) {
|
||||||
AddTriggerDialog(
|
AddTriggerDialog(
|
||||||
|
availableDevices = state.availableDevices,
|
||||||
onDismiss = { showTriggerDialog = false },
|
onDismiss = { showTriggerDialog = false },
|
||||||
onAdd = { triggerType, devName, time, temp, hum, op ->
|
onAdd = { triggerType, devName, time, temp, hum, op ->
|
||||||
when (triggerType) {
|
when (triggerType) {
|
||||||
@@ -586,6 +589,7 @@ private fun SceneEditorContent(
|
|||||||
|
|
||||||
if (showConditionDialog) {
|
if (showConditionDialog) {
|
||||||
AddConditionDialog(
|
AddConditionDialog(
|
||||||
|
availableDevices = state.availableDevices,
|
||||||
onDismiss = { showConditionDialog = false },
|
onDismiss = { showConditionDialog = false },
|
||||||
onAddTemp = { devName, op, temp ->
|
onAddTemp = { devName, op, temp ->
|
||||||
viewModel.addTemperatureCondition(devName, op, temp)
|
viewModel.addTemperatureCondition(devName, op, temp)
|
||||||
@@ -609,6 +613,7 @@ private fun SceneEditorContent(
|
|||||||
if (showActionDialog) {
|
if (showActionDialog) {
|
||||||
AddActionDialog(
|
AddActionDialog(
|
||||||
availableRemotes = state.availableRemotes,
|
availableRemotes = state.availableRemotes,
|
||||||
|
availableDevices = state.availableDevices,
|
||||||
onDismiss = { showActionDialog = false },
|
onDismiss = { showActionDialog = false },
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onAddSocket = { name, power ->
|
onAddSocket = { name, power ->
|
||||||
@@ -745,7 +750,7 @@ private fun ActionItemRow(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun SceneTrigger.describe(): String = when (this) {
|
private fun SceneTrigger.describe(): String = when (this) {
|
||||||
is SceneTrigger.Time -> "⏰ $timeOfDay"
|
is SceneTrigger.Time -> "定时 $timeOfDay"
|
||||||
is SceneTrigger.Temperature -> stringResource(R.string.scene_trigger_desc_temp, operator.symbol, thresholdCelsius.toInt().toString())
|
is SceneTrigger.Temperature -> stringResource(R.string.scene_trigger_desc_temp, operator.symbol, thresholdCelsius.toInt().toString())
|
||||||
is SceneTrigger.Humidity -> stringResource(R.string.scene_trigger_desc_humidity, operator.symbol, thresholdPercent.toInt().toString())
|
is SceneTrigger.Humidity -> stringResource(R.string.scene_trigger_desc_humidity, operator.symbol, thresholdPercent.toInt().toString())
|
||||||
is SceneTrigger.DeviceState -> stringResource(R.string.scene_trigger_desc_device, deviceName.value, expectedState)
|
is SceneTrigger.DeviceState -> stringResource(R.string.scene_trigger_desc_device, deviceName.value, expectedState)
|
||||||
@@ -768,13 +773,28 @@ private fun SceneAction.describe(): String = when (this) {
|
|||||||
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
|
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalLayoutApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SceneChoiceFlow(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable FlowRowScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddTriggerDialog(
|
private fun AddTriggerDialog(
|
||||||
|
availableDevices: List<DeviceDescriptor>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit,
|
onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit,
|
||||||
) {
|
) {
|
||||||
var selectedType by remember { mutableIntStateOf(0) }
|
var selectedType by remember { mutableIntStateOf(0) }
|
||||||
var deviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
var deviceName by remember { mutableStateOf("") }
|
||||||
var timeInput by remember { mutableStateOf("07:30") }
|
var timeInput by remember { mutableStateOf("07:30") }
|
||||||
var tempThreshold by remember { mutableDoubleStateOf(28.0) }
|
var tempThreshold by remember { mutableDoubleStateOf(28.0) }
|
||||||
var humThreshold by remember { mutableDoubleStateOf(40.0) }
|
var humThreshold by remember { mutableDoubleStateOf(40.0) }
|
||||||
@@ -782,14 +802,18 @@ private fun AddTriggerDialog(
|
|||||||
|
|
||||||
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
|
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
|
||||||
val isTimeValid = timeRegex.matches(timeInput.trim())
|
val isTimeValid = timeRegex.matches(timeInput.trim())
|
||||||
val isDeviceValid = selectedType == 0 || deviceName.isNotBlank()
|
val matchingDevices = availableDevices.filter {
|
||||||
|
it.type == if (selectedType == 1 || selectedType == 2) DeviceType.TEMPERATURE_HUMIDITY else DeviceType.SOCKET
|
||||||
|
}
|
||||||
|
val isDeviceValid = selectedType == 0 && isTimeValid || selectedType != 0 &&
|
||||||
|
matchingDevices.any { it.name.value == deviceName }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) },
|
title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) })
|
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) })
|
||||||
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
||||||
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
||||||
@@ -809,14 +833,17 @@ private fun AddTriggerDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
1, 2 -> {
|
1, 2 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = deviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { deviceName = it },
|
matchingDevices.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(
|
||||||
singleLine = true,
|
selected = deviceName == device.name.value,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = { deviceName = device.name.value },
|
||||||
|
label = { Text(device.name.value) },
|
||||||
)
|
)
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
}
|
||||||
|
}
|
||||||
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||||
}
|
}
|
||||||
@@ -848,6 +875,7 @@ private fun AddTriggerDialog(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AddConditionDialog(
|
private fun AddConditionDialog(
|
||||||
|
availableDevices: List<DeviceDescriptor>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||||
onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||||
@@ -855,9 +883,9 @@ private fun AddConditionDialog(
|
|||||||
onAddTimeRange: (String, String) -> Unit,
|
onAddTimeRange: (String, String) -> Unit,
|
||||||
) {
|
) {
|
||||||
var selectedType by remember { mutableIntStateOf(0) }
|
var selectedType by remember { mutableIntStateOf(0) }
|
||||||
var tempDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
var tempDeviceName by remember { mutableStateOf("") }
|
||||||
var humDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
var humDeviceName by remember { mutableStateOf("") }
|
||||||
var socketDeviceName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) }
|
var socketDeviceName by remember { mutableStateOf("") }
|
||||||
var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) }
|
var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) }
|
||||||
var tempVal by remember { mutableDoubleStateOf(26.0) }
|
var tempVal by remember { mutableDoubleStateOf(26.0) }
|
||||||
var humVal by remember { mutableDoubleStateOf(50.0) }
|
var humVal by remember { mutableDoubleStateOf(50.0) }
|
||||||
@@ -870,10 +898,13 @@ private fun AddConditionDialog(
|
|||||||
val isEndTimeValid = timeRegex.matches(endTime.trim())
|
val isEndTimeValid = timeRegex.matches(endTime.trim())
|
||||||
val isTimeRangeValid = isStartTimeValid && isEndTimeValid
|
val isTimeRangeValid = isStartTimeValid && isEndTimeValid
|
||||||
|
|
||||||
|
val sensors = availableDevices.filter { it.type == DeviceType.TEMPERATURE_HUMIDITY }
|
||||||
|
val sockets = availableDevices.filter { it.type == DeviceType.SOCKET }
|
||||||
|
|
||||||
val isDeviceValid = when (selectedType) {
|
val isDeviceValid = when (selectedType) {
|
||||||
0 -> tempDeviceName.isNotBlank()
|
0 -> sensors.any { it.name.value == tempDeviceName }
|
||||||
1 -> humDeviceName.isNotBlank()
|
1 -> sensors.any { it.name.value == humDeviceName }
|
||||||
2 -> socketDeviceName.isNotBlank()
|
2 -> sockets.any { it.name.value == socketDeviceName }
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,7 +913,7 @@ private fun AddConditionDialog(
|
|||||||
title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) },
|
title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
||||||
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
||||||
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
||||||
@@ -890,14 +921,13 @@ private fun AddConditionDialog(
|
|||||||
}
|
}
|
||||||
when (selectedType) {
|
when (selectedType) {
|
||||||
0 -> {
|
0 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = tempDeviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { tempDeviceName = it },
|
sensors.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||||
singleLine = true,
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
)
|
SceneChoiceFlow {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||||
}
|
}
|
||||||
@@ -905,14 +935,13 @@ private fun AddConditionDialog(
|
|||||||
Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f)
|
Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f)
|
||||||
}
|
}
|
||||||
1 -> {
|
1 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = humDeviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { humDeviceName = it },
|
sensors.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||||
singleLine = true,
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
)
|
SceneChoiceFlow {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||||
}
|
}
|
||||||
@@ -920,15 +949,14 @@ private fun AddConditionDialog(
|
|||||||
Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f)
|
Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f)
|
||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_device_name_label))
|
||||||
value = socketDeviceName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { socketDeviceName = it },
|
sockets.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||||
singleLine = true,
|
}
|
||||||
modifier = Modifier.fillMaxWidth(),
|
}
|
||||||
)
|
|
||||||
Text(stringResource(R.string.scene_target_socket_state))
|
Text(stringResource(R.string.scene_target_socket_state))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||||
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
||||||
}
|
}
|
||||||
@@ -985,6 +1013,7 @@ private fun AddConditionDialog(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun AddActionDialog(
|
private fun AddActionDialog(
|
||||||
availableRemotes: List<VirtualRemote>,
|
availableRemotes: List<VirtualRemote>,
|
||||||
|
availableDevices: List<com.flagship.abox.manager.domain.DeviceDescriptor>,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
viewModel: SceneEditorViewModel,
|
viewModel: SceneEditorViewModel,
|
||||||
onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit,
|
onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit,
|
||||||
@@ -997,7 +1026,7 @@ private fun AddActionDialog(
|
|||||||
val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title)
|
val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title)
|
||||||
val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg)
|
val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg)
|
||||||
|
|
||||||
var socketName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) }
|
var socketName by remember { mutableStateOf(availableDevices.firstOrNull { it.type == DeviceType.SOCKET }?.name?.value.orEmpty()) }
|
||||||
var socketPower by remember { mutableStateOf(SocketPowerState.ON) }
|
var socketPower by remember { mutableStateOf(SocketPowerState.ON) }
|
||||||
|
|
||||||
var selectedRemoteIndex by remember { mutableIntStateOf(0) }
|
var selectedRemoteIndex by remember { mutableIntStateOf(0) }
|
||||||
@@ -1032,7 +1061,7 @@ private fun AddActionDialog(
|
|||||||
title = { Text(stringResource(R.string.scene_dialog_add_action_title)) },
|
title = { Text(stringResource(R.string.scene_dialog_add_action_title)) },
|
||||||
text = {
|
text = {
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
||||||
FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) })
|
FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) })
|
||||||
FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) })
|
FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) })
|
||||||
@@ -1040,15 +1069,18 @@ private fun AddActionDialog(
|
|||||||
}
|
}
|
||||||
when (actionType) {
|
when (actionType) {
|
||||||
0 -> {
|
0 -> {
|
||||||
OutlinedTextField(
|
Text(stringResource(R.string.scene_select_socket))
|
||||||
value = socketName,
|
SceneChoiceFlow {
|
||||||
onValueChange = { socketName = it },
|
availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device ->
|
||||||
label = { Text(stringResource(R.string.scene_select_socket)) },
|
FilterChip(
|
||||||
singleLine = true,
|
selected = socketName == device.name.value,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
onClick = { socketName = device.name.value },
|
||||||
|
label = { Text(device.name.value) },
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Text(stringResource(R.string.scene_socket_power_state))
|
Text(stringResource(R.string.scene_socket_power_state))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
SceneChoiceFlow {
|
||||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||||
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
||||||
}
|
}
|
||||||
@@ -1056,10 +1088,9 @@ private fun AddActionDialog(
|
|||||||
1 -> {
|
1 -> {
|
||||||
if (availableRemotes.isEmpty()) {
|
if (availableRemotes.isEmpty()) {
|
||||||
Text(stringResource(R.string.scene_no_virtual_remotes))
|
Text(stringResource(R.string.scene_no_virtual_remotes))
|
||||||
OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_slot_label)) }, singleLine = true)
|
|
||||||
} else {
|
} else {
|
||||||
Text(stringResource(R.string.scene_select_virtual_remote))
|
Text(stringResource(R.string.scene_select_virtual_remote))
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
availableRemotes.forEachIndexed { idx, remote ->
|
availableRemotes.forEachIndexed { idx, remote ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = selectedRemoteIndex == idx,
|
selected = selectedRemoteIndex == idx,
|
||||||
@@ -1070,7 +1101,7 @@ private fun AddActionDialog(
|
|||||||
}
|
}
|
||||||
Text(stringResource(R.string.scene_select_remote_button))
|
Text(stringResource(R.string.scene_select_remote_button))
|
||||||
if (keyBindings.isNotEmpty()) {
|
if (keyBindings.isNotEmpty()) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
SceneChoiceFlow {
|
||||||
keyBindings.take(4).forEach { binding ->
|
keyBindings.take(4).forEach { binding ->
|
||||||
val label = binding.displayName.ifBlank { binding.buttonId }
|
val label = binding.displayName.ifBlank { binding.buttonId }
|
||||||
FilterChip(
|
FilterChip(
|
||||||
@@ -1106,8 +1137,8 @@ private fun AddActionDialog(
|
|||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
val canConfirm = when (actionType) {
|
val canConfirm = when (actionType) {
|
||||||
0 -> socketName.isNotBlank()
|
0 -> availableDevices.any { it.type == DeviceType.SOCKET && it.name.value == socketName }
|
||||||
1 -> selectedButtonId.isNotBlank()
|
1 -> availableRemotes.isNotEmpty() && selectedButtonId.isNotBlank()
|
||||||
3 -> notifyTitle.isNotBlank()
|
3 -> notifyTitle.isNotBlank()
|
||||||
else -> true
|
else -> true
|
||||||
}
|
}
|
||||||
@@ -1119,8 +1150,6 @@ private fun AddActionDialog(
|
|||||||
val remote = availableRemotes.getOrNull(selectedRemoteIndex)
|
val remote = availableRemotes.getOrNull(selectedRemoteIndex)
|
||||||
if (remote != null) {
|
if (remote != null) {
|
||||||
onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName)
|
onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName)
|
||||||
} else {
|
|
||||||
onAddRemote("default-ir", DEFAULT_IR_DEVICE_NAME, selectedButtonId, selectedButtonName)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2 -> onAddDelay(delaySeconds)
|
2 -> onAddDelay(delaySeconds)
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import com.flagship.abox.manager.domain.AutomationType
|
|||||||
import com.flagship.abox.manager.domain.ComparisonOperator
|
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||||
import com.flagship.abox.manager.domain.ConcurrencyMode
|
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||||
|
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ExecutionStrategy
|
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
@@ -50,6 +53,7 @@ data class SceneEditorState(
|
|||||||
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
||||||
val enableLogging: Boolean = true,
|
val enableLogging: Boolean = true,
|
||||||
val availableRemotes: List<VirtualRemote> = emptyList(),
|
val availableRemotes: List<VirtualRemote> = emptyList(),
|
||||||
|
val availableDevices: List<DeviceDescriptor> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
val isSaving: Boolean = false,
|
val isSaving: Boolean = false,
|
||||||
val isSaved: Boolean = false,
|
val isSaved: Boolean = false,
|
||||||
@@ -64,6 +68,7 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
private val sceneRepository: SceneRepository,
|
private val sceneRepository: SceneRepository,
|
||||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||||
private val profileStore: ProviderProfileStore,
|
private val profileStore: ProviderProfileStore,
|
||||||
|
private val deviceCatalog: DeviceCatalog,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(SceneEditorState())
|
private val _state = MutableStateFlow(SceneEditorState())
|
||||||
@@ -74,6 +79,23 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
observeAvailableRemotes()
|
observeAvailableRemotes()
|
||||||
|
observeAvailableDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeAvailableDevices() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
profileStore.observeActiveProfile().collectLatest { profile ->
|
||||||
|
if (profile == null) {
|
||||||
|
_state.update { it.copy(availableDevices = emptyList()) }
|
||||||
|
} else {
|
||||||
|
deviceCatalog.observeDevices(profile.id).collectLatest { result ->
|
||||||
|
if (result is DomainResult.Success) {
|
||||||
|
_state.update { it.copy(availableDevices = result.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun observeAvailableRemotes() {
|
private fun observeAvailableRemotes() {
|
||||||
@@ -191,11 +213,13 @@ class SceneEditorViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
|
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
|
||||||
|
val parsedState = runCatching { SocketPowerState.valueOf(expectedState.trim().uppercase()) }.getOrNull()
|
||||||
|
?: return
|
||||||
val trigger = SceneTrigger.DeviceState(
|
val trigger = SceneTrigger.DeviceState(
|
||||||
id = SceneTriggerId(UUID.randomUUID().toString()),
|
id = SceneTriggerId(UUID.randomUUID().toString()),
|
||||||
deviceName = DeviceName(deviceName),
|
deviceName = DeviceName(deviceName),
|
||||||
expectedState = expectedState,
|
expectedState = parsedState,
|
||||||
label = "$deviceName 状态变为 $expectedState",
|
label = "$deviceName 状态变为 ${parsedState.name}",
|
||||||
)
|
)
|
||||||
_state.update { it.copy(triggers = it.triggers + trigger) }
|
_state.update { it.copy(triggers = it.triggers + trigger) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import com.flagship.abox.manager.domain.Scene
|
|||||||
import com.flagship.abox.manager.domain.SceneExecutionResult
|
import com.flagship.abox.manager.domain.SceneExecutionResult
|
||||||
import com.flagship.abox.manager.domain.SceneExecutor
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
import com.flagship.abox.manager.domain.SceneId
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionSource
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionUpdate
|
||||||
import com.flagship.abox.manager.domain.SceneRepository
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -15,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
|
|
||||||
sealed interface SceneExecutionUiState {
|
sealed interface SceneExecutionUiState {
|
||||||
data object Loading : SceneExecutionUiState
|
data object Loading : SceneExecutionUiState
|
||||||
@@ -71,11 +75,19 @@ class SceneExecutionViewModel @Inject constructor(
|
|||||||
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
|
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
when (val result = sceneExecutor.execute(scene)) {
|
sceneExecutor.execute(
|
||||||
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
|
scene = scene,
|
||||||
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
|
executionId = SceneExecutionId("manual-${scene.id.value}"),
|
||||||
result.error.detail ?: "Execution failed",
|
source = SceneExecutionSource.USER,
|
||||||
|
).collect { update ->
|
||||||
|
when (update) {
|
||||||
|
is SceneExecutionUpdate.Completed -> _uiState.value = SceneExecutionUiState.Completed(update.result)
|
||||||
|
is SceneExecutionUpdate.Rejected -> _uiState.value = SceneExecutionUiState.Failed(
|
||||||
|
update.error.detail ?: "Execution failed",
|
||||||
)
|
)
|
||||||
|
is SceneExecutionUpdate.Started -> _uiState.value = SceneExecutionUiState.Running(update.scene)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
@@ -28,10 +29,11 @@ import androidx.compose.material.icons.outlined.Bolt
|
|||||||
import androidx.compose.material.icons.outlined.Schedule
|
import androidx.compose.material.icons.outlined.Schedule
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.FilledTonalButton
|
import androidx.compose.material3.FilledTonalButton
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -49,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -68,7 +71,10 @@ import com.flagship.abox.manager.domain.Scene
|
|||||||
import com.flagship.abox.manager.domain.SceneColor
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
import com.flagship.abox.manager.domain.SceneIcon
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
import com.flagship.abox.manager.domain.SceneId
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -84,6 +90,23 @@ fun SceneListScreen(
|
|||||||
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
|
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
|
||||||
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||||
var showFabMenu by remember { mutableStateOf(false) }
|
var showFabMenu by remember { mutableStateOf(false) }
|
||||||
|
var fabExpanded by remember { mutableStateOf(true) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
LaunchedEffect(listState) {
|
||||||
|
var previousIndex = 0
|
||||||
|
var previousOffset = 0
|
||||||
|
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { (index, offset) ->
|
||||||
|
val movingUp = index > previousIndex || (index == previousIndex && offset > previousOffset)
|
||||||
|
val movingDown = index < previousIndex || (index == previousIndex && offset < previousOffset)
|
||||||
|
if (movingUp) fabExpanded = false
|
||||||
|
if (movingDown || (index == 0 && offset == 0)) fabExpanded = true
|
||||||
|
previousIndex = index
|
||||||
|
previousOffset = offset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -97,37 +120,30 @@ fun SceneListScreen(
|
|||||||
Scaffold(
|
Scaffold(
|
||||||
containerColor = Color.Transparent,
|
containerColor = Color.Transparent,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
Box {
|
|
||||||
ExtendedFloatingActionButton(
|
ExtendedFloatingActionButton(
|
||||||
onClick = { showFabMenu = true },
|
onClick = { showFabMenu = true },
|
||||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
expanded = fabExpanded,
|
||||||
text = { Text(stringResource(R.string.scene_create_action)) },
|
icon = {
|
||||||
|
Icon(
|
||||||
|
Icons.Filled.Add,
|
||||||
|
contentDescription = stringResource(R.string.scene_create_action),
|
||||||
)
|
)
|
||||||
DropdownMenu(
|
|
||||||
expanded = showFabMenu,
|
|
||||||
onDismissRequest = { showFabMenu = false },
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.scene_create_manual)) },
|
|
||||||
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
showFabMenu = false
|
|
||||||
onAddManual()
|
|
||||||
},
|
},
|
||||||
|
text = { Text(stringResource(R.string.scene_create_action), fontWeight = FontWeight.Bold) },
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
containerColor = MaterialTheme.colorScheme.primary,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
elevation = FloatingActionButtonDefaults.elevation(
|
||||||
|
defaultElevation = 6.dp,
|
||||||
|
pressedElevation = 10.dp,
|
||||||
|
focusedElevation = 8.dp,
|
||||||
|
hoveredElevation = 8.dp,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(stringResource(R.string.scene_create_automatic)) },
|
|
||||||
leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) },
|
|
||||||
onClick = {
|
|
||||||
showFabMenu = false
|
|
||||||
onAddAutomatic()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(
|
contentPadding = PaddingValues(
|
||||||
start = 16.dp,
|
start = 16.dp,
|
||||||
@@ -211,6 +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
|
@Composable
|
||||||
@@ -250,9 +328,10 @@ private fun SceneCard(
|
|||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
ABoxIcon(
|
||||||
text = scene.icon.displayEmoji(),
|
key = scene.icon.toABoxIconKey(),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
contentDescription = scene.name,
|
||||||
|
modifier = Modifier.size(28.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(Modifier.width(12.dp))
|
Spacer(Modifier.width(12.dp))
|
||||||
@@ -375,10 +454,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
ABoxIcon(ABoxIconKey.SCENES, contentDescription = "暂无情景", modifier = Modifier.size(48.dp))
|
||||||
text = "⚡",
|
|
||||||
style = MaterialTheme.typography.displayMedium,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.size(8.dp))
|
Spacer(Modifier.size(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.scene_empty_title),
|
text = stringResource(R.string.scene_empty_title),
|
||||||
@@ -396,19 +472,19 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun SceneIcon.displayEmoji(): String = when (this) {
|
fun SceneIcon.toABoxIconKey(): ABoxIconKey = when (this) {
|
||||||
SceneIcon.HOME -> "🏠"
|
SceneIcon.HOME -> ABoxIconKey.DEVICES
|
||||||
SceneIcon.OFFICE -> "💼"
|
SceneIcon.OFFICE -> ABoxIconKey.ACTIVITY
|
||||||
SceneIcon.NIGHT -> "🌙"
|
SceneIcon.NIGHT -> ABoxIconKey.PENDING
|
||||||
SceneIcon.MOVIE -> "🎬"
|
SceneIcon.MOVIE -> ABoxIconKey.SCENES
|
||||||
SceneIcon.AWAY -> "🚶"
|
SceneIcon.AWAY -> ABoxIconKey.OFFLINE
|
||||||
SceneIcon.READING -> "📖"
|
SceneIcon.READING -> ABoxIconKey.DEVICES
|
||||||
SceneIcon.DINING -> "🍽️"
|
SceneIcon.DINING -> ABoxIconKey.DEVICES
|
||||||
SceneIcon.GAMING -> "🎮"
|
SceneIcon.GAMING -> ABoxIconKey.SCENES
|
||||||
SceneIcon.THERMOSTAT -> "🌡️"
|
SceneIcon.THERMOSTAT -> ABoxIconKey.TEMPERATURE
|
||||||
SceneIcon.LIGHT -> "💡"
|
SceneIcon.LIGHT -> ABoxIconKey.POWER
|
||||||
SceneIcon.CLEAN -> "🧹"
|
SceneIcon.CLEAN -> ABoxIconKey.REFRESH
|
||||||
SceneIcon.CUSTOM -> "✨"
|
SceneIcon.CUSTOM -> ABoxIconKey.UNKNOWN_DEVICE
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun SceneColor.toColorOrNull(): Color? {
|
internal fun SceneColor.toColorOrNull(): Color? {
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.SessionCoordinator
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.SessionValidity
|
||||||
|
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
||||||
|
import com.flagship.abox.manager.ui.common.UiText
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor(
|
|||||||
private val _uiState = MutableStateFlow(SessionActionsUiState())
|
private val _uiState = MutableStateFlow(SessionActionsUiState())
|
||||||
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
fun onAction(action: SessionUiAction) {
|
||||||
|
when (action) {
|
||||||
|
SessionUiAction.EndSession -> runAction(coordinator::endActiveSession)
|
||||||
|
SessionUiAction.ForgetSavedCredentials -> runAction(coordinator::forgetSavedCredentials)
|
||||||
|
SessionUiAction.SwitchProvider -> runAction(coordinator::switchProvider)
|
||||||
|
SessionUiAction.ValidateConnection -> runValidation()
|
||||||
|
SessionUiAction.DismissError -> dismissError()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dispatch(action: SessionUiAction) = onAction(action)
|
||||||
|
|
||||||
fun logout() = runAction(coordinator::logout)
|
fun logout() = runAction(coordinator::logout)
|
||||||
|
|
||||||
fun switchProvider() = runAction(coordinator::switchProvider)
|
fun switchProvider() = onAction(SessionUiAction.SwitchProvider)
|
||||||
|
|
||||||
fun dismissError() {
|
fun dismissError() {
|
||||||
_uiState.value = _uiState.value.copy(errorMessage = null)
|
_uiState.value = _uiState.value.copy(errorMessage = null)
|
||||||
@@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor(
|
|||||||
_uiState.value = SessionActionsUiState(inProgress = true)
|
_uiState.value = SessionActionsUiState(inProgress = true)
|
||||||
val result = action()
|
val result = action()
|
||||||
_uiState.value = SessionActionsUiState(
|
_uiState.value = SessionActionsUiState(
|
||||||
errorMessage = (result as? DomainResult.Failure)?.error?.detail,
|
errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runValidation() {
|
||||||
|
if (_uiState.value.inProgress) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
_uiState.value = SessionActionsUiState(inProgress = true)
|
||||||
|
val result = coordinator.validateActiveSession()
|
||||||
|
_uiState.value = SessionActionsUiState(
|
||||||
|
validity = (result as? DomainResult.Success)?.value,
|
||||||
|
errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,5 +67,14 @@ class SessionActionsViewModel @Inject constructor(
|
|||||||
|
|
||||||
data class SessionActionsUiState(
|
data class SessionActionsUiState(
|
||||||
val inProgress: Boolean = false,
|
val inProgress: Boolean = false,
|
||||||
val errorMessage: String? = null,
|
val validity: SessionValidity? = null,
|
||||||
|
val errorMessage: UiText? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
sealed interface SessionUiAction {
|
||||||
|
data object EndSession : SessionUiAction
|
||||||
|
data object ForgetSavedCredentials : SessionUiAction
|
||||||
|
data object SwitchProvider : SessionUiAction
|
||||||
|
data object ValidateConnection : SessionUiAction
|
||||||
|
data object DismissError : SessionUiAction
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
package com.flagship.abox.manager.ui.shell
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
|
||||||
import androidx.compose.material.icons.filled.CloudDone
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.SheetState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.flagship.abox.manager.R
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Account and Provider management bottom sheet triggered by clicking the top bar user avatar.
|
|
||||||
* Allows viewing current session info and signing out.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun AccountProviderBottomSheet(
|
|
||||||
userName: String,
|
|
||||||
providerName: String,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
onLogout: () -> Unit,
|
|
||||||
actionInProgress: Boolean,
|
|
||||||
errorMessage: String?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
|
||||||
) {
|
|
||||||
ModalBottomSheet(
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
sheetState = sheetState,
|
|
||||||
modifier = modifier,
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 24.dp)
|
|
||||||
.padding(bottom = 32.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// Header with large avatar & user info
|
|
||||||
ABoxUserAvatar(
|
|
||||||
name = userName,
|
|
||||||
onClick = {},
|
|
||||||
size = 56.dp,
|
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = userName.ifBlank { stringResource(R.string.account_sheet_title) },
|
|
||||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.account_sheet_signed_in_as),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(
|
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Provider Info Card
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CloudDone,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.account_sheet_provider),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = providerName,
|
|
||||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// Action button
|
|
||||||
ABoxButton(
|
|
||||||
onClick = onLogout,
|
|
||||||
enabled = !actionInProgress,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ExitToApp,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.size(8.dp))
|
|
||||||
Text(stringResource(R.string.account_sheet_logout))
|
|
||||||
}
|
|
||||||
|
|
||||||
errorMessage?.let { error ->
|
|
||||||
Text(
|
|
||||||
text = error,
|
|
||||||
color = MaterialTheme.colorScheme.error,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
TextButton(
|
|
||||||
onClick = onDismissRequest,
|
|
||||||
enabled = !actionInProgress,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.account_sheet_close),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.flagship.abox.manager.ui.shell
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.Logout
|
||||||
|
import androidx.compose.material.icons.outlined.CloudDone
|
||||||
|
import androidx.compose.material.icons.outlined.Palette
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun AccountProviderDialog(
|
||||||
|
userName: String,
|
||||||
|
providerName: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
|
onSwitchProvider: () -> Unit,
|
||||||
|
onEndSession: () -> Unit,
|
||||||
|
onForgetCredentials: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
var confirmLogout by remember { mutableStateOf(false) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
sheetState = sheetState,
|
||||||
|
modifier = modifier.testTag("account_dialog"),
|
||||||
|
dragHandle = {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.padding(top = 10.dp, bottom = 6.dp).size(width = 40.dp, height = 4.dp),
|
||||||
|
shape = RoundedCornerShape(2.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant,
|
||||||
|
) {}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
ABoxUserAvatar(
|
||||||
|
name = userName,
|
||||||
|
onClick = {},
|
||||||
|
size = 76.dp,
|
||||||
|
contentDescription = stringResource(R.string.account_current),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.account_greeting, userName.ifBlank { stringResource(R.string.account_current) }),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = userName.ifBlank { stringResource(R.string.account_current) },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Outlined.CloudDone, contentDescription = stringResource(R.string.account_connection_method), modifier = Modifier.size(26.dp))
|
||||||
|
Column {
|
||||||
|
Text(stringResource(R.string.account_connection_method), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text(providerName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountSheetAction(
|
||||||
|
icon = Icons.Outlined.Palette,
|
||||||
|
label = stringResource(R.string.account_interface_settings),
|
||||||
|
description = stringResource(R.string.account_interface_settings_desc),
|
||||||
|
tag = "account_dialog_open_settings",
|
||||||
|
onClick = onOpenSettings,
|
||||||
|
)
|
||||||
|
AccountSheetAction(
|
||||||
|
icon = Icons.AutoMirrored.Outlined.Logout,
|
||||||
|
label = stringResource(R.string.account_logout),
|
||||||
|
description = stringResource(R.string.account_logout_desc),
|
||||||
|
tag = "account_dialog_end_session",
|
||||||
|
dangerous = true,
|
||||||
|
onClick = { confirmLogout = true },
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(28.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmLogout) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { confirmLogout = false },
|
||||||
|
title = { Text(stringResource(R.string.account_logout)) },
|
||||||
|
text = { Text(stringResource(R.string.account_logout_confirm)) },
|
||||||
|
confirmButton = {
|
||||||
|
ABoxButton(
|
||||||
|
onClick = {
|
||||||
|
confirmLogout = false
|
||||||
|
onEndSession()
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag("account_dialog_confirm"),
|
||||||
|
) { Text(stringResource(R.string.account_logout)) }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = { confirmLogout = false }) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountSheetAction(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
label: String,
|
||||||
|
description: String,
|
||||||
|
tag: String,
|
||||||
|
dangerous: Boolean = false,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onClick,
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag(tag),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
icon,
|
||||||
|
contentDescription = label,
|
||||||
|
tint = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Column(Modifier.weight(1f)) {
|
||||||
|
Text(label, color = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
|
||||||
|
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.flagship.abox.manager.ui.shell
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.runtime.saveable.Saver
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
/** Width buckets used by the authenticated shell. */
|
||||||
|
enum class ABoxWindowSizeClass {
|
||||||
|
COMPACT,
|
||||||
|
MEDIUM,
|
||||||
|
EXPANDED,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun widthClass(widthDp: Int): ABoxWindowSizeClass = when {
|
||||||
|
widthDp < 600 -> ABoxWindowSizeClass.COMPACT
|
||||||
|
widthDp < 840 -> ABoxWindowSizeClass.MEDIUM
|
||||||
|
else -> ABoxWindowSizeClass.EXPANDED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class MainNavigationPresentation {
|
||||||
|
BOTTOM_NAVIGATION,
|
||||||
|
NAVIGATION_RAIL,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun navigationPresentation(windowSizeClass: ABoxWindowSizeClass): MainNavigationPresentation = when (windowSizeClass) {
|
||||||
|
ABoxWindowSizeClass.COMPACT -> MainNavigationPresentation.BOTTOM_NAVIGATION
|
||||||
|
ABoxWindowSizeClass.MEDIUM,
|
||||||
|
ABoxWindowSizeClass.EXPANDED,
|
||||||
|
-> MainNavigationPresentation.NAVIGATION_RAIL
|
||||||
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class DeviceSelection(
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val type: DeviceType,
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
/** Saver used by navigation-owned state so selection survives recreation and resize. */
|
||||||
|
val Saver: Saver<DeviceSelection?, Any> = Saver(
|
||||||
|
save = { selection ->
|
||||||
|
selection?.let {
|
||||||
|
listOf(it.profileId.value, it.deviceName.value, it.type.name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
restore = { saved ->
|
||||||
|
val values = saved as? List<*> ?: return@Saver null
|
||||||
|
if (values.size != 3) return@Saver null
|
||||||
|
val profileId = values[0] as? String ?: return@Saver null
|
||||||
|
val deviceName = values[1] as? String ?: return@Saver null
|
||||||
|
val type = (values[2] as? String)?.let { value ->
|
||||||
|
runCatching { DeviceType.valueOf(value) }.getOrNull()
|
||||||
|
} ?: return@Saver null
|
||||||
|
DeviceSelection(
|
||||||
|
profileId = ProviderProfileId(profileId),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
type = type,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DetailPresentation {
|
||||||
|
FULL_SCREEN,
|
||||||
|
PANE,
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detailPresentation(
|
||||||
|
windowSizeClass: ABoxWindowSizeClass,
|
||||||
|
selection: DeviceSelection,
|
||||||
|
): DetailPresentation = when {
|
||||||
|
windowSizeClass == ABoxWindowSizeClass.EXPANDED && selection.type != DeviceType.INFRARED -> {
|
||||||
|
DetailPresentation.PANE
|
||||||
|
}
|
||||||
|
else -> DetailPresentation.FULL_SCREEN
|
||||||
|
}
|
||||||
@@ -1,179 +1,253 @@
|
|||||||
package com.flagship.abox.manager.ui.shell
|
package com.flagship.abox.manager.ui.shell
|
||||||
|
|
||||||
import androidx.compose.animation.Crossfade
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveable
|
|
||||||
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
||||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
|
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTopPillBar
|
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
|
||||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
import com.flagship.abox.manager.designsystem.LocalABoxNavigationPresentation
|
||||||
|
import com.flagship.abox.manager.designsystem.LocalABoxRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||||
|
|
||||||
/**
|
/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
|
||||||
* Main authenticated application shell.
|
|
||||||
*
|
|
||||||
* Provides:
|
|
||||||
* 1. A floating/pinned pill top app bar with provider context, '+' action, and avatar account sheet.
|
|
||||||
* 2. Material 3 bottom navigation bar with pill indicator for Devices, Scenes, and Activity tabs.
|
|
||||||
* 3. Clear composable slot boundaries for team member integrations.
|
|
||||||
*/
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainAppShell(
|
fun MainAppShell(
|
||||||
|
currentTab: MainTab,
|
||||||
|
windowSizeClass: ABoxWindowSizeClass,
|
||||||
userName: String,
|
userName: String,
|
||||||
providerName: String,
|
providerName: String,
|
||||||
onLogout: () -> Unit,
|
onTabClick: (MainTab) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
onProviderClick: () -> Unit,
|
||||||
initialTab: MainTab = MainTab.DEVICES,
|
onAvatarClick: () -> Unit,
|
||||||
onTabSelected: (MainTab) -> Unit = {},
|
showNavigationChrome: Boolean = true,
|
||||||
onAddClick: (() -> Unit)? = null,
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
actionInProgress: Boolean = false,
|
|
||||||
errorMessage: String? = null,
|
|
||||||
devicesSlot: @Composable () -> Unit = { DeviceListScreen() },
|
|
||||||
scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
|
|
||||||
activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
|
|
||||||
) {
|
) {
|
||||||
var activeTab by rememberSaveable { mutableStateOf(initialTab) }
|
val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
|
||||||
var showAccountSheet by remember { mutableStateOf(false) }
|
val renderer = LocalABoxRenderer.current
|
||||||
val tabStateHolder = rememberSaveableStateHolder()
|
val floating = renderer == DesignRenderer.MIUIX &&
|
||||||
|
LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
if (showNavigationChrome) {
|
||||||
|
ProviderTopBar(
|
||||||
|
currentTab = currentTab,
|
||||||
|
providerName = providerName,
|
||||||
|
userName = userName,
|
||||||
|
onProviderClick = onProviderClick,
|
||||||
|
onAvatarClick = onAvatarClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottomBar = {
|
||||||
|
if (showNavigationChrome && !useRail) {
|
||||||
|
MainBottomBar(currentTab, onTabClick, renderer, floating)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
if (showNavigationChrome && useRail) {
|
||||||
|
MainNavigationRail(
|
||||||
|
currentTab = currentTab,
|
||||||
|
onTabClick = onTabClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||||
|
) {
|
||||||
|
content(innerPadding)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainBottomBar(
|
||||||
|
currentTab: MainTab,
|
||||||
|
onTabClick: (MainTab) -> Unit,
|
||||||
|
renderer: DesignRenderer,
|
||||||
|
floating: Boolean,
|
||||||
|
) {
|
||||||
|
if (renderer == DesignRenderer.MIUIX) {
|
||||||
|
Surface(
|
||||||
|
modifier = if (floating) {
|
||||||
|
Modifier.padding(horizontal = 20.dp, vertical = 8.dp).clip(RoundedCornerShape(36.dp))
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
shape = if (floating) RoundedCornerShape(36.dp) else RoundedCornerShape(0.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||||
|
tonalElevation = if (floating) 3.dp else 0.dp,
|
||||||
|
) {
|
||||||
|
ABoxNavigationBar(containerColor = Color.Transparent) {
|
||||||
|
MainTab.entries.forEach { tab ->
|
||||||
|
val selected = tab == currentTab
|
||||||
|
ABoxNavigationBarItem(
|
||||||
|
selected = tab == currentTab,
|
||||||
|
onClick = { onTabClick(tab) },
|
||||||
|
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
|
||||||
|
icon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
|
||||||
|
contentDescription = stringResource(tab.labelRes),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(stringResource(tab.labelRes)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ABoxNavigationBar {
|
||||||
|
MainTab.entries.forEach { tab ->
|
||||||
|
val selected = tab == currentTab
|
||||||
|
ABoxNavigationBarItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onTabClick(tab) },
|
||||||
|
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
|
||||||
|
icon = { Icon(if (selected) tab.selectedIcon else tab.unselectedIcon, stringResource(tab.labelRes)) },
|
||||||
|
label = { Text(stringResource(tab.labelRes)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ProviderTopBar(
|
||||||
|
currentTab: MainTab,
|
||||||
|
providerName: String,
|
||||||
|
userName: String,
|
||||||
|
onProviderClick: () -> Unit,
|
||||||
|
onAvatarClick: () -> Unit,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
ABoxTopPillBar(
|
Surface(
|
||||||
title = stringResource(R.string.top_bar_prompt_format, providerName),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
userName = userName,
|
shape = RoundedCornerShape(32.dp),
|
||||||
onAvatarClick = { showAccountSheet = true },
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
onAddClick = onAddClick,
|
tonalElevation = 2.dp,
|
||||||
addActionContentDescription = stringResource(R.string.action_add),
|
) {
|
||||||
avatarContentDescription = stringResource(R.string.action_account),
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(64.dp)
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(24.dp))
|
||||||
|
.testTag("main_provider_pill")
|
||||||
|
.clickable(
|
||||||
|
onClickLabel = stringResource(R.string.account_sheet_provider),
|
||||||
|
onClick = onProviderClick,
|
||||||
|
)
|
||||||
|
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.Bolt,
|
||||||
|
contentDescription = providerName,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(22.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = providerName,
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier.padding(start = 8.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
Text(
|
||||||
bottomBar = {
|
text = stringResource(currentTab.labelRes),
|
||||||
ABoxNavigationBar {
|
style = MaterialTheme.typography.titleMedium,
|
||||||
MainTab.values().forEach { tab ->
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
val isSelected = (tab == activeTab)
|
maxLines = 1,
|
||||||
ABoxNavigationBarItem(
|
overflow = TextOverflow.Ellipsis,
|
||||||
selected = isSelected,
|
modifier = Modifier
|
||||||
onClick = {
|
.weight(1f)
|
||||||
activeTab = tab
|
.padding(horizontal = 8.dp),
|
||||||
onTabSelected(tab)
|
)
|
||||||
},
|
Box(modifier = Modifier.testTag("main_avatar")) {
|
||||||
|
ABoxUserAvatar(
|
||||||
|
name = userName,
|
||||||
|
onClick = onAvatarClick,
|
||||||
|
size = 36.dp,
|
||||||
|
contentDescription = stringResource(R.string.action_account),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MainNavigationRail(
|
||||||
|
currentTab: MainTab,
|
||||||
|
onTabClick: (MainTab) -> Unit,
|
||||||
|
) {
|
||||||
|
NavigationRail(
|
||||||
|
modifier = Modifier.fillMaxHeight(),
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
MainTab.entries.forEach { tab ->
|
||||||
|
val selected = tab == currentTab
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = selected,
|
||||||
|
onClick = { onTabClick(tab) },
|
||||||
|
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
|
||||||
icon = {
|
icon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon,
|
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
|
||||||
contentDescription = stringResource(tab.labelRes),
|
contentDescription = stringResource(tab.labelRes),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
label = {
|
label = { Text(stringResource(tab.labelRes)) },
|
||||||
Text(text = stringResource(tab.labelRes))
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
Crossfade(
|
|
||||||
targetState = activeTab,
|
|
||||||
label = "MainTabCrossfade",
|
|
||||||
) { tab ->
|
|
||||||
tabStateHolder.SaveableStateProvider(tab) {
|
|
||||||
when (tab) {
|
|
||||||
MainTab.DEVICES -> devicesSlot()
|
|
||||||
MainTab.SCENES -> scenesSlot()
|
|
||||||
MainTab.ACTIVITY -> activitySlot()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAccountSheet) {
|
|
||||||
AccountProviderBottomSheet(
|
|
||||||
userName = userName,
|
|
||||||
providerName = providerName,
|
|
||||||
onDismissRequest = { showAccountSheet = false },
|
|
||||||
onLogout = onLogout,
|
|
||||||
actionInProgress = actionInProgress,
|
|
||||||
errorMessage = errorMessage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DefaultScenesSlot() {
|
|
||||||
SlotPlaceholder(
|
|
||||||
title = stringResource(R.string.scenes_placeholder_title),
|
|
||||||
description = stringResource(R.string.scenes_placeholder_description),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DefaultActivitySlot() {
|
|
||||||
SlotPlaceholder(
|
|
||||||
title = stringResource(R.string.activity_placeholder_title),
|
|
||||||
description = stringResource(R.string.activity_placeholder_description),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SlotPlaceholder(
|
|
||||||
title: String,
|
|
||||||
description: String,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(top = 8.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ import androidx.compose.material.icons.outlined.ViewAgenda
|
|||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
|
|
||||||
/**
|
/** Main destinations available while authenticated. */
|
||||||
* Main bottom navigation tabs for the authenticated application shell.
|
|
||||||
*/
|
|
||||||
enum class MainTab(
|
enum class MainTab(
|
||||||
@StringRes val labelRes: Int,
|
@StringRes val labelRes: Int,
|
||||||
val selectedIcon: ImageVector,
|
val selectedIcon: ImageVector,
|
||||||
@@ -24,18 +22,30 @@ enum class MainTab(
|
|||||||
labelRes = R.string.tab_devices,
|
labelRes = R.string.tab_devices,
|
||||||
selectedIcon = Icons.Filled.Home,
|
selectedIcon = Icons.Filled.Home,
|
||||||
unselectedIcon = Icons.Outlined.Home,
|
unselectedIcon = Icons.Outlined.Home,
|
||||||
route = "shell/devices",
|
route = "tab/devices",
|
||||||
),
|
),
|
||||||
SCENES(
|
SCENES(
|
||||||
labelRes = R.string.tab_scenes,
|
labelRes = R.string.tab_scenes,
|
||||||
selectedIcon = Icons.Filled.AutoAwesome,
|
selectedIcon = Icons.Filled.AutoAwesome,
|
||||||
unselectedIcon = Icons.Outlined.AutoAwesome,
|
unselectedIcon = Icons.Outlined.AutoAwesome,
|
||||||
route = "shell/scenes",
|
route = "tab/scenes",
|
||||||
),
|
),
|
||||||
ACTIVITY(
|
ACTIVITY(
|
||||||
labelRes = R.string.tab_activity,
|
labelRes = R.string.tab_activity,
|
||||||
selectedIcon = Icons.Filled.ViewAgenda,
|
selectedIcon = Icons.Filled.ViewAgenda,
|
||||||
unselectedIcon = Icons.Outlined.ViewAgenda,
|
unselectedIcon = Icons.Outlined.ViewAgenda,
|
||||||
route = "shell/activity",
|
route = "tab/activity",
|
||||||
),
|
),
|
||||||
|
;
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromRoute(route: String?): MainTab? = when {
|
||||||
|
route == null -> null
|
||||||
|
route == DEVICES.route || route.startsWith("device/") ||
|
||||||
|
route.startsWith("infrared_") -> DEVICES
|
||||||
|
route == SCENES.route || route.startsWith("scene/") -> SCENES
|
||||||
|
route == ACTIVITY.route -> ACTIVITY
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.socket
|
|||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -38,6 +39,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -47,12 +50,17 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxIconButton
|
||||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxStatus
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxStatusBanner
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
import java.text.SimpleDateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SocketRoute(
|
fun SocketRoute(
|
||||||
@@ -79,38 +87,29 @@ fun SocketScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
val configuration = LocalConfiguration.current
|
||||||
|
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||||
Scaffold(
|
ABoxDeviceDetailScaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
title = state.deviceName?.value ?: stringResource(R.string.socket_title),
|
||||||
TopAppBar(
|
onBack = onBackClick,
|
||||||
title = {
|
subtitle = stringResource(R.string.socket_detail_subtitle),
|
||||||
Text(
|
icon = ABoxIconKey.SOCKET,
|
||||||
text = state.deviceName?.value ?: stringResource(R.string.socket_title),
|
action = {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
Row {
|
||||||
|
ABoxIconButton(
|
||||||
|
key = ABoxIconKey.EDIT,
|
||||||
|
contentDescription = stringResource(R.string.socket_name_settings),
|
||||||
|
onClick = { showDeviceNameDialog = true },
|
||||||
|
enabled = !state.isOperating,
|
||||||
)
|
)
|
||||||
},
|
ABoxIconButton(
|
||||||
navigationIcon = {
|
key = ABoxIconKey.REFRESH,
|
||||||
IconButton(onClick = onBackClick) {
|
contentDescription = stringResource(R.string.action_refresh),
|
||||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = { showEditNameDialog = true },
|
|
||||||
enabled = !state.isLoading && !state.isOperating,
|
|
||||||
) {
|
|
||||||
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { onEvent(SocketUiEvent.Refresh) },
|
onClick = { onEvent(SocketUiEvent.Refresh) },
|
||||||
enabled = !state.isLoading && !state.isOperating,
|
enabled = !state.isLoading && !state.isOperating,
|
||||||
) {
|
|
||||||
Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
Column(
|
Column(
|
||||||
@@ -136,20 +135,28 @@ fun SocketScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().height(64.dp)) {
|
||||||
|
SocketOperationBanner(operation = state.operation)
|
||||||
|
}
|
||||||
|
|
||||||
// Central Status Card
|
// Central Status Card
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 16.dp),
|
.padding(top = 8.dp, bottom = 18.dp),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = if (state.isPowerOn) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
),
|
),
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(32.dp),
|
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
@@ -157,13 +164,16 @@ fun SocketScreen(
|
|||||||
powerState = state.powerState,
|
powerState = state.powerState,
|
||||||
isOffline = state.isOffline,
|
isOffline = state.isOffline,
|
||||||
isLoading = state.isLoading,
|
isLoading = state.isLoading,
|
||||||
|
isOperating = state.isOperating,
|
||||||
|
enabled = state.isActionEnabled,
|
||||||
|
onClick = { onEvent(SocketUiEvent.TogglePower) },
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(20.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.socket_status_label),
|
text = stringResource(R.string.socket_status_label),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -174,7 +184,7 @@ fun SocketScreen(
|
|||||||
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
||||||
else -> stringResource(R.string.socket_status_unknown)
|
else -> stringResource(R.string.socket_status_unknown)
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = when {
|
color = when {
|
||||||
state.isOffline -> MaterialTheme.colorScheme.error
|
state.isOffline -> MaterialTheme.colorScheme.error
|
||||||
@@ -185,7 +195,9 @@ fun SocketScreen(
|
|||||||
)
|
)
|
||||||
|
|
||||||
state.lastConfirmedTimeMillis?.let { timeMillis ->
|
state.lastConfirmedTimeMillis?.let { timeMillis ->
|
||||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
val formattedTime = remember(timeMillis, configuration) {
|
||||||
|
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.socket_confirmed_at, formattedTime),
|
text = stringResource(R.string.socket_confirmed_at, formattedTime),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -196,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(
|
||||||
text = if (state.isPowerOn) {
|
text = stringResource(R.string.socket_direct_control),
|
||||||
stringResource(R.string.socket_action_turn_off)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.socket_action_turn_on)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// Dedicated Segmented Action Buttons
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -232,57 +221,76 @@ fun SocketScreen(
|
|||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) },
|
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) },
|
||||||
enabled = state.isActionEnabled && state.powerState != 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))
|
Text(stringResource(R.string.socket_action_turn_on))
|
||||||
}
|
}
|
||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) },
|
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) },
|
||||||
enabled = state.isActionEnabled && state.powerState != 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(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 (showEditNameDialog) {
|
if (showDeviceNameDialog) {
|
||||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
DeviceNameDialog(
|
||||||
AlertDialog(
|
currentName = state.deviceName?.value.orEmpty(),
|
||||||
onDismissRequest = { showEditNameDialog = false },
|
title = stringResource(R.string.socket_name_settings),
|
||||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
description = stringResource(R.string.gateway_device_name_socket_desc),
|
||||||
text = {
|
onDismiss = { showDeviceNameDialog = false },
|
||||||
OutlinedTextField(
|
onConfirm = { name ->
|
||||||
value = inputName,
|
showDeviceNameDialog = false
|
||||||
onValueChange = { inputName = it },
|
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(name)))
|
||||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
},
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
|
||||||
onClick = {
|
|
||||||
val trimmed = inputName.trim()
|
|
||||||
if (trimmed.isNotBlank()) {
|
|
||||||
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
|
||||||
}
|
|
||||||
showEditNameDialog = false
|
|
||||||
},
|
|
||||||
enabled = inputName.isNotBlank(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showEditNameDialog = false }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -290,6 +298,9 @@ private fun PowerIndicatorBadge(
|
|||||||
powerState: SocketPowerState?,
|
powerState: SocketPowerState?,
|
||||||
isOffline: Boolean,
|
isOffline: Boolean,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
isOperating: Boolean,
|
||||||
|
enabled: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val indicatorColor = when {
|
val indicatorColor = when {
|
||||||
@@ -301,21 +312,71 @@ private fun PowerIndicatorBadge(
|
|||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(96.dp)
|
.size(160.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(indicatorColor.copy(alpha = 0.15f))
|
.background(indicatorColor.copy(alpha = 0.15f))
|
||||||
.border(3.dp, indicatorColor, CircleShape),
|
.border(4.dp, indicatorColor, CircleShape)
|
||||||
|
.clickable(enabled = enabled, onClickLabel = "切换插座电源", onClick = onClick)
|
||||||
|
.testTag("socket_power_dial"),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (isLoading) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
ABoxLoadingIndicator(modifier = Modifier.size(36.dp))
|
if (isLoading || isOperating) {
|
||||||
|
ABoxLoadingIndicator(modifier = Modifier.size(44.dp))
|
||||||
} else {
|
} else {
|
||||||
Text(
|
ABoxIcon(
|
||||||
text = if (powerState == SocketPowerState.ON) "⚡" else "○",
|
key = ABoxIconKey.POWER,
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭",
|
||||||
color = indicatorColor,
|
modifier = Modifier.size(52.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
isOperating -> stringResource(R.string.device_status_switching)
|
||||||
|
powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on)
|
||||||
|
powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
||||||
|
else -> stringResource(R.string.device_status_unavailable)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SocketOperationBanner(
|
||||||
|
operation: SocketOperationState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
when (operation) {
|
||||||
|
is SocketOperationState.PendingCommand -> ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.PENDING,
|
||||||
|
contentDescription = "正在发送电源指令",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
is SocketOperationState.RefreshingConfirmation -> ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.PENDING,
|
||||||
|
contentDescription = "正在确认插座状态",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
is SocketOperationState.AwaitingConfirmation -> ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.WARNING,
|
||||||
|
contentDescription = "指令已发送,等待状态确认",
|
||||||
|
modifier = modifier.testTag("socket_awaiting_confirmation"),
|
||||||
|
)
|
||||||
|
is SocketOperationState.Failed -> ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.ERROR,
|
||||||
|
contentDescription = "插座操作失败",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
SocketOperationState.Unavailable -> ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.OFFLINE,
|
||||||
|
contentDescription = "插座当前不可用",
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
SocketOperationState.IdleConfirmed -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,9 +438,11 @@ private fun ErrorBanner(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onDismiss) {
|
ABoxIconButton(
|
||||||
Text("✕")
|
key = ABoxIconKey.CLOSE,
|
||||||
}
|
contentDescription = "关闭错误提示",
|
||||||
|
onClick = onDismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,14 +14,27 @@ data class SocketUiState(
|
|||||||
val isOffline: Boolean = false,
|
val isOffline: Boolean = false,
|
||||||
val errorMessage: UiText? = null,
|
val errorMessage: UiText? = null,
|
||||||
val lastConfirmedTimeMillis: Long? = null,
|
val lastConfirmedTimeMillis: Long? = null,
|
||||||
|
val operation: SocketOperationState = SocketOperationState.IdleConfirmed,
|
||||||
) {
|
) {
|
||||||
val isActionEnabled: Boolean
|
val isActionEnabled: Boolean
|
||||||
get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null
|
get() = !isLoading && operation !is SocketOperationState.PendingCommand &&
|
||||||
|
operation !is SocketOperationState.RefreshingConfirmation &&
|
||||||
|
operation !is SocketOperationState.AwaitingConfirmation &&
|
||||||
|
!isOffline && profileId != null && deviceName != null
|
||||||
|
|
||||||
val isPowerOn: Boolean
|
val isPowerOn: Boolean
|
||||||
get() = powerState == SocketPowerState.ON
|
get() = powerState == SocketPowerState.ON
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sealed interface SocketOperationState {
|
||||||
|
data object IdleConfirmed : SocketOperationState
|
||||||
|
data class PendingCommand(val target: SocketPowerState) : SocketOperationState
|
||||||
|
data class RefreshingConfirmation(val target: SocketPowerState) : SocketOperationState
|
||||||
|
data class AwaitingConfirmation(val target: SocketPowerState) : SocketOperationState
|
||||||
|
data class Failed(val target: SocketPowerState) : SocketOperationState
|
||||||
|
data object Unavailable : SocketOperationState
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface SocketUiEvent {
|
sealed interface SocketUiEvent {
|
||||||
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
|
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
|
||||||
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
||||||
|
|||||||
@@ -9,13 +9,18 @@ import com.flagship.abox.manager.domain.DomainResult
|
|||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.SocketGateway
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
|
||||||
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||||
|
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -23,6 +28,8 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class SocketViewModel @Inject constructor(
|
class SocketViewModel @Inject constructor(
|
||||||
private val socketGateway: SocketGateway,
|
private val socketGateway: SocketGateway,
|
||||||
|
private val configuredDeviceRepository: ConfiguredDeviceRepository,
|
||||||
|
private val activityWriter: ActivityEventWriter,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -56,6 +63,7 @@ class SocketViewModel @Inject constructor(
|
|||||||
it.copy(
|
it.copy(
|
||||||
profileId = event.profileId,
|
profileId = event.profileId,
|
||||||
deviceName = event.deviceName,
|
deviceName = event.deviceName,
|
||||||
|
operation = SocketOperationState.Unavailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
loadStatus()
|
loadStatus()
|
||||||
@@ -63,17 +71,20 @@ class SocketViewModel @Inject constructor(
|
|||||||
|
|
||||||
is SocketUiEvent.ChangeDeviceName -> {
|
is SocketUiEvent.ChangeDeviceName -> {
|
||||||
val trimmed = event.deviceName.value.trim()
|
val trimmed = event.deviceName.value.trim()
|
||||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
val current = _state.value
|
||||||
_state.update {
|
val profileId = current.profileId
|
||||||
it.copy(
|
val oldName = current.deviceName
|
||||||
deviceName = DeviceName(trimmed),
|
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
|
||||||
powerState = null,
|
viewModelScope.launch {
|
||||||
errorMessage = null,
|
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
|
||||||
lastConfirmedTimeMillis = null,
|
is DomainResult.Success -> {
|
||||||
)
|
_state.update { it.copy(deviceName = DeviceName(trimmed), powerState = null, errorMessage = null, lastConfirmedTimeMillis = null) }
|
||||||
}
|
|
||||||
loadStatus()
|
loadStatus()
|
||||||
}
|
}
|
||||||
|
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SocketUiEvent.Refresh -> {
|
SocketUiEvent.Refresh -> {
|
||||||
@@ -115,6 +126,7 @@ class SocketViewModel @Inject constructor(
|
|||||||
isOffline = false,
|
isOffline = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||||
|
operation = SocketOperationState.IdleConfirmed,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,6 +138,7 @@ class SocketViewModel @Inject constructor(
|
|||||||
isLoading = false,
|
isLoading = false,
|
||||||
isOffline = isOffline,
|
isOffline = isOffline,
|
||||||
errorMessage = DomainErrorMapper.map(result.error),
|
errorMessage = DomainErrorMapper.map(result.error),
|
||||||
|
operation = SocketOperationState.Unavailable,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,36 +152,83 @@ class SocketViewModel @Inject constructor(
|
|||||||
val deviceName = current.deviceName ?: return
|
val deviceName = current.deviceName ?: return
|
||||||
|
|
||||||
// Prevent duplicate action if currently operating, loading or offline
|
// Prevent duplicate action if currently operating, loading or offline
|
||||||
if (current.isOperating || current.isLoading || current.isOffline) {
|
if (current.isOperating || current.isLoading || current.isOffline || !current.isActionEnabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isOperating = true, errorMessage = null) }
|
when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) {
|
||||||
when (val result = socketGateway.setPower(profileId, deviceName, targetState)) {
|
|
||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
|
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED)
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
powerState = result.value.powerState,
|
isOperating = true,
|
||||||
|
isOffline = false,
|
||||||
|
errorMessage = null,
|
||||||
|
operation = SocketOperationState.RefreshingConfirmation(targetState),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var confirmationError: DomainError? = null
|
||||||
|
repeat(CONFIRMATION_ATTEMPTS) { attempt ->
|
||||||
|
if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS)
|
||||||
|
when (val confirmation = socketGateway.getStatus(profileId, deviceName)) {
|
||||||
|
is DomainResult.Success -> if (confirmation.value.powerState == targetState) {
|
||||||
|
activityWriter.recordDevice(
|
||||||
|
profileId,
|
||||||
|
deviceName,
|
||||||
|
ActivityEventKind.SOCKET_CONFIRMATION,
|
||||||
|
ActivityOutcome.SUCCEEDED,
|
||||||
|
)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
powerState = targetState,
|
||||||
isOperating = false,
|
isOperating = false,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
errorMessage = null,
|
errorMessage = null,
|
||||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||||
|
operation = SocketOperationState.IdleConfirmed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> confirmationError = confirmation.error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
activityWriter.recordDevice(
|
||||||
|
profileId,
|
||||||
|
deviceName,
|
||||||
|
ActivityEventKind.SOCKET_CONFIRMATION,
|
||||||
|
ActivityOutcome.AWAITING_CONFIRMATION,
|
||||||
|
confirmationError?.externalCode,
|
||||||
|
)
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
isOperating = false,
|
||||||
|
isOffline = confirmationError is DomainError.DeviceOffline,
|
||||||
|
errorMessage = confirmationError?.let(DomainErrorMapper::map),
|
||||||
|
operation = SocketOperationState.AwaitingConfirmation(targetState),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is DomainResult.Failure -> {
|
is DomainResult.Failure -> {
|
||||||
|
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.FAILED, result.error.externalCode)
|
||||||
val isOffline = result.error is DomainError.DeviceOffline
|
val isOffline = result.error is DomainError.DeviceOffline
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isOperating = false,
|
isOperating = false,
|
||||||
isOffline = isOffline,
|
isOffline = isOffline,
|
||||||
errorMessage = DomainErrorMapper.map(result.error),
|
errorMessage = DomainErrorMapper.map(result.error),
|
||||||
|
operation = SocketOperationState.Failed(targetState),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CONFIRMATION_ATTEMPTS = 4
|
||||||
|
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalConfiguration
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
@@ -39,11 +41,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.flagship.abox.manager.R
|
import com.flagship.abox.manager.R
|
||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
|
||||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxIconButton
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
import java.text.SimpleDateFormat
|
import java.text.DateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TemperatureHumidityRoute(
|
fun TemperatureHumidityRoute(
|
||||||
@@ -70,37 +75,20 @@ fun TemperatureHumidityScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
val configuration = LocalConfiguration.current
|
||||||
|
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||||
Scaffold(
|
ABoxDeviceDetailScaffold(
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
topBar = {
|
title = state.deviceName?.value ?: stringResource(R.string.th_title),
|
||||||
TopAppBar(
|
onBack = onBackClick,
|
||||||
title = {
|
subtitle = stringResource(R.string.device_sensor_subtitle),
|
||||||
Text(
|
icon = ABoxIconKey.SENSOR,
|
||||||
text = state.deviceName?.value ?: stringResource(R.string.th_title),
|
action = {
|
||||||
style = MaterialTheme.typography.titleLarge,
|
ABoxIconButton(
|
||||||
)
|
key = ABoxIconKey.EDIT,
|
||||||
},
|
contentDescription = stringResource(R.string.sensor_name_settings),
|
||||||
navigationIcon = {
|
onClick = { showDeviceNameDialog = true },
|
||||||
IconButton(onClick = onBackClick) {
|
|
||||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = { showEditNameDialog = true },
|
|
||||||
enabled = !state.isLoading && !state.isRefreshing,
|
enabled = !state.isLoading && !state.isRefreshing,
|
||||||
) {
|
|
||||||
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
IconButton(
|
|
||||||
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
|
|
||||||
enabled = !state.isLoading && !state.isRefreshing,
|
|
||||||
) {
|
|
||||||
Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
@@ -151,31 +139,41 @@ fun TemperatureHumidityScreen(
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// Telemetry Cards Row / Column
|
// Telemetry Cards Row / Column
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.sensor_current_readings),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp),
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 12.dp),
|
.padding(bottom = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
TelemetryMetricCard(
|
TelemetryMetricCard(
|
||||||
title = stringResource(R.string.th_temperature_label),
|
title = stringResource(R.string.th_temperature_label),
|
||||||
value = state.temperature ?: "--",
|
value = state.temperature,
|
||||||
unit = stringResource(R.string.th_unit_celsius),
|
unit = stringResource(R.string.th_unit_celsius),
|
||||||
icon = "🌡️",
|
icon = ABoxIconKey.TEMPERATURE,
|
||||||
|
testTag = "sensor_metric_temperature",
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
TelemetryMetricCard(
|
TelemetryMetricCard(
|
||||||
title = stringResource(R.string.th_humidity_label),
|
title = stringResource(R.string.th_humidity_label),
|
||||||
value = state.humidity ?: "--",
|
value = state.humidity,
|
||||||
unit = stringResource(R.string.th_unit_percent),
|
unit = stringResource(R.string.th_unit_percent),
|
||||||
icon = "💧",
|
icon = ABoxIconKey.HUMIDITY,
|
||||||
|
testTag = "sensor_metric_humidity",
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.lastUpdatedTimeMillis?.let { timeMillis ->
|
state.lastUpdatedTimeMillis?.let { timeMillis ->
|
||||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
val formattedTime = remember(timeMillis, configuration) {
|
||||||
|
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_last_updated, formattedTime),
|
text = stringResource(R.string.th_last_updated, formattedTime),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
@@ -189,7 +187,7 @@ fun TemperatureHumidityScreen(
|
|||||||
|
|
||||||
// Read-Only Informational Card
|
// Read-Only Informational Card
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth().testTag("sensor_monitoring_notice"),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
),
|
),
|
||||||
@@ -201,13 +199,13 @@ fun TemperatureHumidityScreen(
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_read_only_notice),
|
text = stringResource(R.string.sensor_monitoring_title),
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_read_only_description),
|
text = stringResource(R.string.sensor_monitoring_description),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||||
modifier = Modifier.padding(top = 4.dp),
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
@@ -215,7 +213,7 @@ fun TemperatureHumidityScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Refresh Button
|
// Refresh Button
|
||||||
ABoxButton(
|
ABoxButton(
|
||||||
@@ -223,7 +221,7 @@ fun TemperatureHumidityScreen(
|
|||||||
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
|
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(52.dp),
|
.height(56.dp),
|
||||||
) {
|
) {
|
||||||
if (state.isRefreshing || state.isLoading) {
|
if (state.isRefreshing || state.isLoading) {
|
||||||
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
|
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
|
||||||
@@ -239,53 +237,59 @@ fun TemperatureHumidityScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEditNameDialog) {
|
if (showDeviceNameDialog) {
|
||||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
SensorDeviceNameDialog(
|
||||||
AlertDialog(
|
currentName = state.deviceName?.value.orEmpty(),
|
||||||
onDismissRequest = { showEditNameDialog = false },
|
onDismiss = { showDeviceNameDialog = false },
|
||||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
onConfirm = { name ->
|
||||||
text = {
|
showDeviceNameDialog = false
|
||||||
OutlinedTextField(
|
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(name)))
|
||||||
value = inputName,
|
},
|
||||||
onValueChange = { inputName = it },
|
|
||||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SensorDeviceNameDialog(
|
||||||
|
currentName: String,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
var value by remember(currentName) { mutableStateOf(currentName) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.sensor_name_settings)) },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Text(stringResource(R.string.gateway_device_name_sensor_desc), style = MaterialTheme.typography.bodySmall)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = { value = it },
|
||||||
|
label = { Text(stringResource(R.string.gateway_device_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(
|
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
|
||||||
onClick = {
|
|
||||||
val trimmed = inputName.trim()
|
|
||||||
if (trimmed.isNotBlank()) {
|
|
||||||
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
|
||||||
}
|
|
||||||
showEditNameDialog = false
|
|
||||||
},
|
|
||||||
enabled = inputName.isNotBlank(),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showEditNameDialog = false }) {
|
|
||||||
Text(stringResource(R.string.action_cancel))
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||||
)
|
)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TelemetryMetricCard(
|
private fun TelemetryMetricCard(
|
||||||
title: String,
|
title: String,
|
||||||
value: String,
|
value: String?,
|
||||||
unit: String,
|
unit: String,
|
||||||
icon: String,
|
icon: ABoxIconKey,
|
||||||
|
testTag: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
if (value == null) return
|
||||||
Card(
|
Card(
|
||||||
modifier = modifier,
|
modifier = modifier.testTag(testTag),
|
||||||
colors = CardDefaults.cardColors(
|
colors = CardDefaults.cardColors(
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
),
|
),
|
||||||
@@ -298,10 +302,7 @@ private fun TelemetryMetricCard(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text(
|
ABoxIcon(icon, contentDescription = title, modifier = Modifier.size(28.dp))
|
||||||
text = icon,
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
@@ -323,7 +324,6 @@ private fun TelemetryMetricCard(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
)
|
)
|
||||||
if (value != "--") {
|
|
||||||
Text(
|
Text(
|
||||||
text = " $unit",
|
text = " $unit",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
@@ -333,7 +333,6 @@ private fun TelemetryMetricCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -355,7 +354,11 @@ private fun NoDataCard(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
) {
|
) {
|
||||||
Text("📭", style = MaterialTheme.typography.displaySmall)
|
ABoxIcon(
|
||||||
|
key = ABoxIconKey.SENSOR,
|
||||||
|
contentDescription = "暂无传感器数据",
|
||||||
|
modifier = Modifier.size(40.dp),
|
||||||
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.th_no_data_title),
|
text = stringResource(R.string.th_no_data_title),
|
||||||
@@ -434,9 +437,11 @@ private fun ErrorBanner(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
TextButton(onClick = onDismiss) {
|
ABoxIconButton(
|
||||||
Text("✕")
|
key = ABoxIconKey.CLOSE,
|
||||||
}
|
contentDescription = "关闭错误提示",
|
||||||
|
onClick = onDismiss,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ data class TemperatureHumidityUiState(
|
|||||||
val errorMessage: UiText? = null,
|
val errorMessage: UiText? = null,
|
||||||
val lastUpdatedTimeMillis: Long? = null,
|
val lastUpdatedTimeMillis: Long? = null,
|
||||||
) {
|
) {
|
||||||
val formattedTemperature: String
|
val formattedTemperature: String?
|
||||||
get() = if (temperature.isNullOrBlank()) "--" else "$temperature °C"
|
get() = temperature?.takeIf { it.isNotBlank() }?.let { "$it °C" }
|
||||||
|
|
||||||
val formattedHumidity: String
|
val formattedHumidity: String?
|
||||||
get() = if (humidity.isNullOrBlank()) "--" else "$humidity %"
|
get() = humidity?.takeIf { it.isNotBlank() }?.let { "$it %" }
|
||||||
|
|
||||||
val hasValidData: Boolean
|
val hasValidData: Boolean
|
||||||
get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank()
|
get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank()
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import com.flagship.abox.manager.domain.DomainError
|
|||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
|
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
|
||||||
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||||
|
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||||
|
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -22,6 +26,8 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class TemperatureHumidityViewModel @Inject constructor(
|
class TemperatureHumidityViewModel @Inject constructor(
|
||||||
private val thGateway: TemperatureHumidityGateway,
|
private val thGateway: TemperatureHumidityGateway,
|
||||||
|
private val configuredDeviceRepository: ConfiguredDeviceRepository,
|
||||||
|
private val activityWriter: ActivityEventWriter,
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -62,19 +68,20 @@ class TemperatureHumidityViewModel @Inject constructor(
|
|||||||
|
|
||||||
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
|
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
|
||||||
val trimmed = event.deviceName.value.trim()
|
val trimmed = event.deviceName.value.trim()
|
||||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
val current = _state.value
|
||||||
_state.update {
|
val profileId = current.profileId
|
||||||
it.copy(
|
val oldName = current.deviceName
|
||||||
deviceName = DeviceName(trimmed),
|
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
|
||||||
temperature = null,
|
viewModelScope.launch {
|
||||||
humidity = null,
|
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
|
||||||
hasNoData = false,
|
is DomainResult.Success -> {
|
||||||
errorMessage = null,
|
_state.update { it.copy(deviceName = DeviceName(trimmed), temperature = null, humidity = null, hasNoData = false, errorMessage = null, lastUpdatedTimeMillis = null) }
|
||||||
lastUpdatedTimeMillis = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
loadReading(isRefresh = false)
|
loadReading(isRefresh = false)
|
||||||
}
|
}
|
||||||
|
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TemperatureHumidityUiEvent.Refresh -> {
|
TemperatureHumidityUiEvent.Refresh -> {
|
||||||
@@ -100,12 +107,13 @@ class TemperatureHumidityViewModel @Inject constructor(
|
|||||||
|
|
||||||
when (val result = thGateway.getReading(profileId, deviceName)) {
|
when (val result = thGateway.getReading(profileId, deviceName)) {
|
||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
|
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.SUCCEEDED)
|
||||||
val reading = result.value
|
val reading = result.value
|
||||||
val isBlankData = reading.temperature.isBlank() && reading.humidity.isBlank()
|
val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank()
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
temperature = reading.temperature.takeIf { t -> t.isNotBlank() },
|
temperature = reading.temperature?.takeIf { t -> t.isNotBlank() },
|
||||||
humidity = reading.humidity.takeIf { h -> h.isNotBlank() },
|
humidity = reading.humidity?.takeIf { h -> h.isNotBlank() },
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
isRefreshing = false,
|
isRefreshing = false,
|
||||||
isOffline = false,
|
isOffline = false,
|
||||||
@@ -117,6 +125,7 @@ class TemperatureHumidityViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is DomainResult.Failure -> {
|
is DomainResult.Failure -> {
|
||||||
|
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.FAILED, result.error.externalCode)
|
||||||
val isOffline = result.error is DomainError.DeviceOffline
|
val isOffline = result.error is DomainError.DeviceOffline
|
||||||
val hasNoData = result.error is DomainError.NoData
|
val hasNoData = result.error is DomainError.NoData
|
||||||
_state.update {
|
_state.update {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<color xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:color="#F8FAFC" />
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background" />
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||||
|
</adaptive-icon>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ABox Manager</string>
|
<string name="app_name">ABox 家庭</string>
|
||||||
<string name="provider_selection_title">选择提供方</string>
|
<string name="provider_selection_title">选择提供方</string>
|
||||||
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
|
<string name="provider_selection_description">选择当前配置的连接方式。不同提供方之间的会话、凭据和本地数据相互隔离。</string>
|
||||||
<string name="provider_abox_name">ABox</string>
|
<string name="provider_abox_name">ABox</string>
|
||||||
@@ -82,9 +82,9 @@
|
|||||||
<string name="scene_section_other">其他选项与外观</string>
|
<string name="scene_section_other">其他选项与外观</string>
|
||||||
|
|
||||||
<!-- Add Buttons -->
|
<!-- Add Buttons -->
|
||||||
<string name="scene_add_trigger">+ 新增触发器</string>
|
<string name="scene_add_trigger">新增触发器</string>
|
||||||
<string name="scene_add_condition">+ 新增条件</string>
|
<string name="scene_add_condition">新增条件</string>
|
||||||
<string name="scene_add_action_btn">+ 新增动作</string>
|
<string name="scene_add_action_btn">新增动作</string>
|
||||||
<string name="scene_add_action">添加</string>
|
<string name="scene_add_action">添加</string>
|
||||||
<string name="scene_save_action">保存指令</string>
|
<string name="scene_save_action">保存指令</string>
|
||||||
|
|
||||||
@@ -104,10 +104,10 @@
|
|||||||
|
|
||||||
<!-- Trigger Types & Dialog -->
|
<!-- Trigger Types & Dialog -->
|
||||||
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
|
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
|
||||||
<string name="scene_trigger_type_time">⏰ 定时时间触发</string>
|
<string name="scene_trigger_type_time">定时时间触发</string>
|
||||||
<string name="scene_trigger_type_temp">🌡️ 环境温度触发</string>
|
<string name="scene_trigger_type_temp">环境温度触发</string>
|
||||||
<string name="scene_trigger_type_humidity">💧 环境湿度触发</string>
|
<string name="scene_trigger_type_humidity">环境湿度触发</string>
|
||||||
<string name="scene_trigger_type_device">🔌 插座状态变更触发</string>
|
<string name="scene_trigger_type_device">插座状态变更触发</string>
|
||||||
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
|
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
|
||||||
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
|
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
|
||||||
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
|
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
|
||||||
@@ -118,19 +118,19 @@
|
|||||||
|
|
||||||
<!-- Condition Types & Dialog -->
|
<!-- Condition Types & Dialog -->
|
||||||
<string name="scene_dialog_add_condition_title">选择限制条件</string>
|
<string name="scene_dialog_add_condition_title">选择限制条件</string>
|
||||||
<string name="scene_condition_type_temp">🌡️ 环境温度范围</string>
|
<string name="scene_condition_type_temp">环境温度范围</string>
|
||||||
<string name="scene_condition_type_humidity">💧 环境湿度范围</string>
|
<string name="scene_condition_type_humidity">环境湿度范围</string>
|
||||||
<string name="scene_condition_type_socket">🔌 插座当前电源状态</string>
|
<string name="scene_condition_type_socket">插座当前电源状态</string>
|
||||||
<string name="scene_condition_type_time_range">🕒 生效时间区间</string>
|
<string name="scene_condition_type_time_range">生效时间区间</string>
|
||||||
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
|
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
|
||||||
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
|
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
|
||||||
|
|
||||||
<!-- Action Types & Dialog -->
|
<!-- Action Types & Dialog -->
|
||||||
<string name="scene_dialog_add_action_title">选择执行动作</string>
|
<string name="scene_dialog_add_action_title">选择执行动作</string>
|
||||||
<string name="scene_action_type_socket">🔌 智能插座控制</string>
|
<string name="scene_action_type_socket">智能插座控制</string>
|
||||||
<string name="scene_action_type_virtual_remote">📺 红外遥控按键</string>
|
<string name="scene_action_type_virtual_remote">红外遥控按键</string>
|
||||||
<string name="scene_action_type_delay">⏱️ 延时等待 (缓冲)</string>
|
<string name="scene_action_type_delay">延时等待(缓冲)</string>
|
||||||
<string name="scene_action_type_notification">🔔 发送系统提示</string>
|
<string name="scene_action_type_notification">发送系统提示</string>
|
||||||
<string name="scene_select_socket">选择目标插座</string>
|
<string name="scene_select_socket">选择目标插座</string>
|
||||||
<string name="scene_socket_power_state">设定电源状态</string>
|
<string name="scene_socket_power_state">设定电源状态</string>
|
||||||
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
|
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
|
||||||
@@ -144,8 +144,8 @@
|
|||||||
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
|
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
|
||||||
<string name="scene_action_remote">遥控【%1$s】: 发送 %2$s</string>
|
<string name="scene_action_remote">遥控【%1$s】: 发送 %2$s</string>
|
||||||
<string name="scene_action_infrared">红外【%1$s】: 槽位 %2$s</string>
|
<string name="scene_action_infrared">红外【%1$s】: 槽位 %2$s</string>
|
||||||
<string name="scene_action_delay">⏱️ 等待 %1$d 秒</string>
|
<string name="scene_action_delay">等待 %1$d 秒</string>
|
||||||
<string name="scene_action_notification">🔔 提示: %1$s</string>
|
<string name="scene_action_notification">提示:%1$s</string>
|
||||||
|
|
||||||
<!-- Scene: execution -->
|
<!-- Scene: execution -->
|
||||||
<string name="scene_execution_title">执行指令</string>
|
<string name="scene_execution_title">执行指令</string>
|
||||||
@@ -164,13 +164,13 @@
|
|||||||
<!-- 校验与弹窗选项/占位文案 -->
|
<!-- 校验与弹窗选项/占位文案 -->
|
||||||
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</string>
|
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</string>
|
||||||
<string name="scene_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
|
<string name="scene_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
|
||||||
<string name="scene_chip_time">⏰ 定时</string>
|
<string name="scene_chip_time">定时</string>
|
||||||
<string name="scene_chip_temp">🌡️ 温度</string>
|
<string name="scene_chip_temp">温度</string>
|
||||||
<string name="scene_chip_humidity">💧 湿度</string>
|
<string name="scene_chip_humidity">湿度</string>
|
||||||
<string name="scene_chip_socket">🔌 插座</string>
|
<string name="scene_chip_socket">插座</string>
|
||||||
<string name="scene_chip_remote">📺 遥控</string>
|
<string name="scene_chip_remote">遥控</string>
|
||||||
<string name="scene_chip_delay">⏱️ 延时</string>
|
<string name="scene_chip_delay">延时</string>
|
||||||
<string name="scene_chip_notify">🔔 提示</string>
|
<string name="scene_chip_notify">提示</string>
|
||||||
<string name="scene_chip_above">> 高于</string>
|
<string name="scene_chip_above">> 高于</string>
|
||||||
<string name="scene_chip_below">< 低于</string>
|
<string name="scene_chip_below">< 低于</string>
|
||||||
<string name="scene_chip_power_on">开启</string>
|
<string name="scene_chip_power_on">开启</string>
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
|
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
|
||||||
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
|
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
|
||||||
<string name="scene_empty_actions_hint">尚未添加执行动作,请点击下方按钮添加</string>
|
<string name="scene_empty_actions_hint">尚未添加执行动作,请点击下方按钮添加</string>
|
||||||
<string name="scene_summary_triggers_and_actions">⚡ %1$d 个触发器 • %2$d 个动作</string>
|
<string name="scene_summary_triggers_and_actions">%1$d 个触发器 · %2$d 个动作</string>
|
||||||
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
|
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
|
||||||
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
|
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
|
||||||
<string name="scene_exec_rerun">重新执行</string>
|
<string name="scene_exec_rerun">重新执行</string>
|
||||||
@@ -259,4 +259,127 @@
|
|||||||
<string name="error_operation_failed">操作执行失败,请重试。</string>
|
<string name="error_operation_failed">操作执行失败,请重试。</string>
|
||||||
<string name="error_no_data">暂无可用数据。</string>
|
<string name="error_no_data">暂无可用数据。</string>
|
||||||
<string name="error_unknown">发生未知错误。</string>
|
<string name="error_unknown">发生未知错误。</string>
|
||||||
|
|
||||||
|
<!-- Interface settings -->
|
||||||
|
<string name="settings_title">界面设置</string>
|
||||||
|
<string name="settings_style_section">样式</string>
|
||||||
|
<string name="settings_renderer_label">渲染器</string>
|
||||||
|
<string name="settings_renderer_description">选择应用使用的界面渲染方式</string>
|
||||||
|
<string name="settings_renderer_material3">Material</string>
|
||||||
|
<string name="settings_renderer_miuix">Miuix</string>
|
||||||
|
<string name="settings_theme_section">主题</string>
|
||||||
|
<string name="settings_theme_label">主题模式</string>
|
||||||
|
<string name="settings_theme_system">跟随系统</string>
|
||||||
|
<string name="settings_theme_light">浅色</string>
|
||||||
|
<string name="settings_theme_dark">深色</string>
|
||||||
|
<string name="settings_dynamic_color">动态取色</string>
|
||||||
|
<string name="settings_dynamic_color_description">使用系统壁纸颜色生成界面配色</string>
|
||||||
|
<string name="settings_surface_section">界面效果</string>
|
||||||
|
<string name="settings_blur">模糊效果</string>
|
||||||
|
<string name="settings_blur_description">在支持的界面中启用背景模糊</string>
|
||||||
|
<string name="settings_navigation">导航样式</string>
|
||||||
|
<string name="settings_navigation_standard">标准导航栏</string>
|
||||||
|
<string name="settings_navigation_floating">悬浮导航栏</string>
|
||||||
|
<string name="settings_feedback_section">交互反馈</string>
|
||||||
|
<string name="settings_haptics">触觉反馈</string>
|
||||||
|
<string name="settings_haptics_description">操作时提供轻微震动反馈</string>
|
||||||
|
<string name="settings_accessibility_section">系统辅助功能</string>
|
||||||
|
<string name="settings_accessibility_card_title">遵循系统辅助功能设置</string>
|
||||||
|
<string name="settings_accessibility_description">动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。</string>
|
||||||
|
<string name="settings_error_save">无法保存界面设置,请重试</string>
|
||||||
|
<string name="settings_style_entry">界面风格</string>
|
||||||
|
<string name="settings_theme_entry">主题</string>
|
||||||
|
<string name="settings_theme_entry_description">配色、明暗、视觉效果与缩放</string>
|
||||||
|
<string name="settings_theme_detail_title">主题</string>
|
||||||
|
<string name="settings_renderer_primary">Material</string>
|
||||||
|
<string name="settings_renderer_secondary">Miuix</string>
|
||||||
|
<string name="settings_navigation_floating_description">让底部导航独立悬浮在页面内容上方</string>
|
||||||
|
<string name="settings_accent_label">强调色</string>
|
||||||
|
<string name="settings_scale">界面缩放</string>
|
||||||
|
<string name="settings_scale_description">调整界面元素的显示比例</string>
|
||||||
|
<string name="settings_color_style">色彩风格</string>
|
||||||
|
<string name="settings_color_style_tonal">Tonal Spot</string>
|
||||||
|
<string name="settings_color_style_expressive">Expressive</string>
|
||||||
|
<string name="settings_color_style_fidelity">Fidelity</string>
|
||||||
|
<string name="settings_color_standard">色彩规范</string>
|
||||||
|
<string name="settings_color_standard_2021">Spec 2021</string>
|
||||||
|
<string name="settings_color_standard_2025">Spec 2025</string>
|
||||||
|
<string name="settings_liquid_glass">液态玻璃</string>
|
||||||
|
<string name="settings_liquid_glass_description">为悬浮底栏启用半透明玻璃质感</string>
|
||||||
|
<string name="settings_predictive_back">预测性返回手势</string>
|
||||||
|
<string name="settings_predictive_back_description">侧滑返回时预览即将返回的页面</string>
|
||||||
|
<string name="settings_tap_to_choose">点击选择</string>
|
||||||
|
<string name="settings_requires_android_13">需要 Android 13 或更高版本</string>
|
||||||
|
<string name="settings_requires_floating_navigation">请先启用悬浮底栏</string>
|
||||||
|
|
||||||
|
<!-- Redesigned device center and account surfaces -->
|
||||||
|
<string name="device_center_heading">已连接终端设备</string>
|
||||||
|
<string name="device_center_description">选择需要控制或查看状态的终端设备</string>
|
||||||
|
<string name="device_center_loading">正在加载设备</string>
|
||||||
|
<string name="device_center_empty_title">尚未配置可管理设备</string>
|
||||||
|
<string name="device_center_empty_description">请先配置网关设备名称和类型</string>
|
||||||
|
<string name="device_center_configure">配置设备</string>
|
||||||
|
<string name="device_center_error">设备列表加载失败</string>
|
||||||
|
<string name="device_center_configure_title">配置设备</string>
|
||||||
|
<string name="device_center_device_name">设备名称</string>
|
||||||
|
<string name="device_status_unavailable">状态不可用</string>
|
||||||
|
<string name="device_status_offline">离线</string>
|
||||||
|
<string name="device_status_switching">正在切换</string>
|
||||||
|
<string name="device_status_awaiting_confirmation">等待状态确认</string>
|
||||||
|
<string name="device_status_on">已开启</string>
|
||||||
|
<string name="device_status_off">已关闭</string>
|
||||||
|
<string name="device_status_confirmed">状态已确认</string>
|
||||||
|
<string name="device_status_waiting_first_confirmation">等待首次确认</string>
|
||||||
|
<string name="device_open_control_panel">控制面板</string>
|
||||||
|
<string name="device_sensor_subtitle">环境传感器 · 只读监控</string>
|
||||||
|
<string name="device_live_reading">实时设备读数</string>
|
||||||
|
<string name="device_details">详情</string>
|
||||||
|
<string name="device_metric_no_data">无数据</string>
|
||||||
|
<string name="device_infrared_subtitle">红外控制器 · %1$d 个虚拟遥控器</string>
|
||||||
|
<string name="device_infrared_mounted">已挂载:%1$s</string>
|
||||||
|
<string name="device_infrared_no_remotes">暂无遥控器</string>
|
||||||
|
<string name="device_infrared_slots">硬件槽位 %1$d/%2$d</string>
|
||||||
|
<string name="device_infrared_slots_unavailable">槽位状态不可用</string>
|
||||||
|
<string name="device_infrared_center">红外中枢</string>
|
||||||
|
<string name="account_current">当前账户</string>
|
||||||
|
<string name="account_greeting">%1$s,您好</string>
|
||||||
|
<string name="account_connection_method">连接方式</string>
|
||||||
|
<string name="account_interface_settings">主题</string>
|
||||||
|
<string name="account_interface_settings_desc">配色、明暗、视觉效果与缩放</string>
|
||||||
|
<string name="account_logout">退出当前会话</string>
|
||||||
|
<string name="account_logout_desc">结束当前连接并返回登录流程</string>
|
||||||
|
<string name="account_logout_confirm">确定退出当前会话吗?</string>
|
||||||
|
<string name="scene_create_sheet_title">新建指令</string>
|
||||||
|
<string name="scene_create_sheet_description">选择指令的启动方式</string>
|
||||||
|
<string name="scene_create_manual_description">手动点击运行,适合常用的一键设备组合。</string>
|
||||||
|
<string name="scene_create_automatic_description">根据时间、设备状态或传感器条件自动执行。</string>
|
||||||
|
<string name="socket_detail_subtitle">智能插座 · 状态确认控制</string>
|
||||||
|
<string name="socket_name_settings">设置插座设备名称</string>
|
||||||
|
<string name="sensor_name_settings">设置传感器设备名称</string>
|
||||||
|
<string name="gateway_device_name">网关设备名称</string>
|
||||||
|
<string name="gateway_device_name_socket_desc">用于匹配和控制网关设备,必须与网关中的名称完全一致。</string>
|
||||||
|
<string name="gateway_device_name_sensor_desc">用于匹配和读取网关设备,必须与网关中的名称完全一致。</string>
|
||||||
|
<string name="action_save_device_name">保存设备名称</string>
|
||||||
|
<string name="socket_direct_control">直接控制</string>
|
||||||
|
<string name="socket_confirmation_hint">指令只有在网关回读到结果状态后才会确认。等待确认时可使用刷新操作。</string>
|
||||||
|
<string name="sensor_current_readings">当前读数</string>
|
||||||
|
<string name="sensor_monitoring_title">实时环境监控</string>
|
||||||
|
<string name="sensor_monitoring_description">数据直接来自当前设备。缺失指标保持不显示,不使用估算值或占位数据。</string>
|
||||||
|
<string name="infrared_center_title">红外遥控中心</string>
|
||||||
|
<string name="infrared_settings_title">红外设备设置</string>
|
||||||
|
<string name="infrared_settings_subtitle">遥控器、槽位与网关设备名称</string>
|
||||||
|
<string name="infrared_manage_title">虚拟遥控器</string>
|
||||||
|
<string name="infrared_remote_count">%1$d 个遥控器</string>
|
||||||
|
<string name="infrared_add_remote">添加遥控器</string>
|
||||||
|
<string name="infrared_hardware_slots_title">硬件槽位</string>
|
||||||
|
<string name="infrared_gateway_name_title">网关设备名称</string>
|
||||||
|
<string name="infrared_gateway_name_desc">用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。</string>
|
||||||
|
<string name="infrared_save_name">保存设备名称</string>
|
||||||
|
<string name="infrared_edit_remote">编辑遥控器</string>
|
||||||
|
<string name="infrared_move_remote">调整遥控器顺序</string>
|
||||||
|
<string name="infrared_move_up">上移</string>
|
||||||
|
<string name="infrared_move_down">下移</string>
|
||||||
|
<string name="infrared_delete_remote">删除遥控器</string>
|
||||||
|
<string name="infrared_layout">面板布局</string>
|
||||||
|
<string name="infrared_transmission_mode">传输模式</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">ABox Manager</string>
|
<string name="app_name">ABox Home</string>
|
||||||
<string name="provider_selection_title">Choose a provider</string>
|
<string name="provider_selection_title">Choose a provider</string>
|
||||||
<string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
|
<string name="provider_selection_description">Choose how this profile connects. Sessions, credentials, and local data stay isolated between providers.</string>
|
||||||
<string name="provider_abox_name">ABox</string>
|
<string name="provider_abox_name">ABox</string>
|
||||||
@@ -82,9 +82,9 @@
|
|||||||
<string name="scene_section_other">Other Options & Appearance</string>
|
<string name="scene_section_other">Other Options & Appearance</string>
|
||||||
|
|
||||||
<!-- Add Buttons -->
|
<!-- Add Buttons -->
|
||||||
<string name="scene_add_trigger">+ Add Trigger</string>
|
<string name="scene_add_trigger">Add Trigger</string>
|
||||||
<string name="scene_add_condition">+ Add Condition</string>
|
<string name="scene_add_condition">Add Condition</string>
|
||||||
<string name="scene_add_action_btn">+ Add Action</string>
|
<string name="scene_add_action_btn">Add Action</string>
|
||||||
<string name="scene_add_action">Add</string>
|
<string name="scene_add_action">Add</string>
|
||||||
<string name="scene_save_action">Save Command</string>
|
<string name="scene_save_action">Save Command</string>
|
||||||
|
|
||||||
@@ -104,10 +104,10 @@
|
|||||||
|
|
||||||
<!-- Trigger Types & Dialog -->
|
<!-- Trigger Types & Dialog -->
|
||||||
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
|
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
|
||||||
<string name="scene_trigger_type_time">⏰ Time / Schedule Trigger</string>
|
<string name="scene_trigger_type_time">Time / Schedule Trigger</string>
|
||||||
<string name="scene_trigger_type_temp">🌡️ Temperature Threshold</string>
|
<string name="scene_trigger_type_temp">Temperature Threshold</string>
|
||||||
<string name="scene_trigger_type_humidity">💧 Humidity Threshold</string>
|
<string name="scene_trigger_type_humidity">Humidity Threshold</string>
|
||||||
<string name="scene_trigger_type_device">🔌 Socket State Changed</string>
|
<string name="scene_trigger_type_device">Socket State Changed</string>
|
||||||
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</string>
|
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</string>
|
||||||
<string name="scene_trigger_temp_threshold">Temperature Threshold (°C)</string>
|
<string name="scene_trigger_temp_threshold">Temperature Threshold (°C)</string>
|
||||||
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
|
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
|
||||||
@@ -118,19 +118,19 @@
|
|||||||
|
|
||||||
<!-- Condition Types & Dialog -->
|
<!-- Condition Types & Dialog -->
|
||||||
<string name="scene_dialog_add_condition_title">Select Condition</string>
|
<string name="scene_dialog_add_condition_title">Select Condition</string>
|
||||||
<string name="scene_condition_type_temp">🌡️ Temperature Range</string>
|
<string name="scene_condition_type_temp">Temperature Range</string>
|
||||||
<string name="scene_condition_type_humidity">💧 Humidity Range</string>
|
<string name="scene_condition_type_humidity">Humidity Range</string>
|
||||||
<string name="scene_condition_type_socket">🔌 Current Socket Power</string>
|
<string name="scene_condition_type_socket">Current Socket Power</string>
|
||||||
<string name="scene_condition_type_time_range">🕒 Active Time Range</string>
|
<string name="scene_condition_type_time_range">Active Time Range</string>
|
||||||
<string name="scene_condition_start_time">Start Time (e.g. 22:00)</string>
|
<string name="scene_condition_start_time">Start Time (e.g. 22:00)</string>
|
||||||
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
|
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
|
||||||
|
|
||||||
<!-- Action Types & Dialog -->
|
<!-- Action Types & Dialog -->
|
||||||
<string name="scene_dialog_add_action_title">Select Action</string>
|
<string name="scene_dialog_add_action_title">Select Action</string>
|
||||||
<string name="scene_action_type_socket">🔌 Smart Socket Control</string>
|
<string name="scene_action_type_socket">Smart Socket Control</string>
|
||||||
<string name="scene_action_type_virtual_remote">📺 Infrared Remote Button</string>
|
<string name="scene_action_type_virtual_remote">Infrared Remote Button</string>
|
||||||
<string name="scene_action_type_delay">⏱️ Delay / Buffer Timer</string>
|
<string name="scene_action_type_delay">Delay / Buffer Timer</string>
|
||||||
<string name="scene_action_type_notification">🔔 System Notification</string>
|
<string name="scene_action_type_notification">System Notification</string>
|
||||||
<string name="scene_select_socket">Select Target Socket</string>
|
<string name="scene_select_socket">Select Target Socket</string>
|
||||||
<string name="scene_socket_power_state">Target Power State</string>
|
<string name="scene_socket_power_state">Target Power State</string>
|
||||||
<string name="scene_select_virtual_remote">Select Virtual Remote</string>
|
<string name="scene_select_virtual_remote">Select Virtual Remote</string>
|
||||||
@@ -144,8 +144,8 @@
|
|||||||
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
|
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
|
||||||
<string name="scene_action_remote">Remote [%1$s]: Send %2$s</string>
|
<string name="scene_action_remote">Remote [%1$s]: Send %2$s</string>
|
||||||
<string name="scene_action_infrared">Infrared [%1$s]: Slot %2$s</string>
|
<string name="scene_action_infrared">Infrared [%1$s]: Slot %2$s</string>
|
||||||
<string name="scene_action_delay">⏱️ Delay %1$d seconds</string>
|
<string name="scene_action_delay">Delay %1$d seconds</string>
|
||||||
<string name="scene_action_notification">🔔 Notify: %1$s</string>
|
<string name="scene_action_notification">Notify: %1$s</string>
|
||||||
|
|
||||||
<!-- Scene: execution -->
|
<!-- Scene: execution -->
|
||||||
<string name="scene_execution_title">Execute Command</string>
|
<string name="scene_execution_title">Execute Command</string>
|
||||||
@@ -164,13 +164,13 @@
|
|||||||
<!-- Validation & dialog chips / placeholders -->
|
<!-- Validation & dialog chips / placeholders -->
|
||||||
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
|
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
|
||||||
<string name="scene_condition_time_error">Invalid time format (use HH:mm, e.g. 22:00)</string>
|
<string name="scene_condition_time_error">Invalid time format (use HH:mm, e.g. 22:00)</string>
|
||||||
<string name="scene_chip_time">⏰ Schedule</string>
|
<string name="scene_chip_time">Schedule</string>
|
||||||
<string name="scene_chip_temp">🌡️ Temperature</string>
|
<string name="scene_chip_temp">Temperature</string>
|
||||||
<string name="scene_chip_humidity">💧 Humidity</string>
|
<string name="scene_chip_humidity">Humidity</string>
|
||||||
<string name="scene_chip_socket">🔌 Socket</string>
|
<string name="scene_chip_socket">Socket</string>
|
||||||
<string name="scene_chip_remote">📺 Remote</string>
|
<string name="scene_chip_remote">Remote</string>
|
||||||
<string name="scene_chip_delay">⏱️ Delay</string>
|
<string name="scene_chip_delay">Delay</string>
|
||||||
<string name="scene_chip_notify">🔔 Notify</string>
|
<string name="scene_chip_notify">Notify</string>
|
||||||
<string name="scene_chip_above">> Above</string>
|
<string name="scene_chip_above">> Above</string>
|
||||||
<string name="scene_chip_below">< Below</string>
|
<string name="scene_chip_below">< Below</string>
|
||||||
<string name="scene_chip_power_on">Turn ON</string>
|
<string name="scene_chip_power_on">Turn ON</string>
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
<string name="scene_empty_triggers_hint">No triggers configured. Tap + below to add.</string>
|
<string name="scene_empty_triggers_hint">No triggers configured. Tap + below to add.</string>
|
||||||
<string name="scene_empty_conditions_hint">No conditions configured. Tap + below to add.</string>
|
<string name="scene_empty_conditions_hint">No conditions configured. Tap + below to add.</string>
|
||||||
<string name="scene_empty_actions_hint">No actions configured. Tap + below to add.</string>
|
<string name="scene_empty_actions_hint">No actions configured. Tap + below to add.</string>
|
||||||
<string name="scene_summary_triggers_and_actions">⚡ %1$d triggers • %2$d actions</string>
|
<string name="scene_summary_triggers_and_actions">%1$d triggers · %2$d actions</string>
|
||||||
<string name="scene_summary_actions_only">%1$d actions</string>
|
<string name="scene_summary_actions_only">%1$d actions</string>
|
||||||
<string name="scene_exec_ready_hint">Tap the button below to run immediately</string>
|
<string name="scene_exec_ready_hint">Tap the button below to run immediately</string>
|
||||||
<string name="scene_exec_rerun">Re-run</string>
|
<string name="scene_exec_rerun">Re-run</string>
|
||||||
@@ -259,4 +259,127 @@
|
|||||||
<string name="error_operation_failed">Operation failed. Please try again.</string>
|
<string name="error_operation_failed">Operation failed. Please try again.</string>
|
||||||
<string name="error_no_data">No sensor data available.</string>
|
<string name="error_no_data">No sensor data available.</string>
|
||||||
<string name="error_unknown">An unexpected error occurred.</string>
|
<string name="error_unknown">An unexpected error occurred.</string>
|
||||||
|
|
||||||
|
<!-- Interface settings -->
|
||||||
|
<string name="settings_title">Interface settings</string>
|
||||||
|
<string name="settings_style_section">Style</string>
|
||||||
|
<string name="settings_renderer_label">Renderer</string>
|
||||||
|
<string name="settings_renderer_description">Choose the interface renderer used by the app</string>
|
||||||
|
<string name="settings_renderer_material3">Material</string>
|
||||||
|
<string name="settings_renderer_miuix">Miuix</string>
|
||||||
|
<string name="settings_theme_section">Theme</string>
|
||||||
|
<string name="settings_theme_label">Theme mode</string>
|
||||||
|
<string name="settings_theme_system">Follow system</string>
|
||||||
|
<string name="settings_theme_light">Light</string>
|
||||||
|
<string name="settings_theme_dark">Dark</string>
|
||||||
|
<string name="settings_dynamic_color">Dynamic color</string>
|
||||||
|
<string name="settings_dynamic_color_description">Generate interface colors from the system wallpaper</string>
|
||||||
|
<string name="settings_surface_section">Surface effects</string>
|
||||||
|
<string name="settings_blur">Blur effects</string>
|
||||||
|
<string name="settings_blur_description">Enable background blur on supported screens</string>
|
||||||
|
<string name="settings_navigation">Navigation style</string>
|
||||||
|
<string name="settings_navigation_standard">Standard navigation</string>
|
||||||
|
<string name="settings_navigation_floating">Floating navigation</string>
|
||||||
|
<string name="settings_feedback_section">Interaction feedback</string>
|
||||||
|
<string name="settings_haptics">Haptic feedback</string>
|
||||||
|
<string name="settings_haptics_description">Provide subtle vibration feedback for actions</string>
|
||||||
|
<string name="settings_accessibility_section">System accessibility</string>
|
||||||
|
<string name="settings_accessibility_card_title">Follow system accessibility settings</string>
|
||||||
|
<string name="settings_accessibility_description">Animation, font size, contrast and other accessibility options are controlled by the system.</string>
|
||||||
|
<string name="settings_error_save">Unable to save interface settings. Try again.</string>
|
||||||
|
<string name="settings_style_entry">Interface style</string>
|
||||||
|
<string name="settings_theme_entry">Theme</string>
|
||||||
|
<string name="settings_theme_entry_description">Colors, brightness, effects and scale</string>
|
||||||
|
<string name="settings_theme_detail_title">Theme</string>
|
||||||
|
<string name="settings_renderer_primary">Material</string>
|
||||||
|
<string name="settings_renderer_secondary">Miuix</string>
|
||||||
|
<string name="settings_navigation_floating_description">Use a detached navigation surface above page content</string>
|
||||||
|
<string name="settings_accent_label">Accent color</string>
|
||||||
|
<string name="settings_scale">Interface scale</string>
|
||||||
|
<string name="settings_scale_description">Adjust the size of interface elements</string>
|
||||||
|
<string name="settings_color_style">Color style</string>
|
||||||
|
<string name="settings_color_style_tonal">Tonal Spot</string>
|
||||||
|
<string name="settings_color_style_expressive">Expressive</string>
|
||||||
|
<string name="settings_color_style_fidelity">Fidelity</string>
|
||||||
|
<string name="settings_color_standard">Color standard</string>
|
||||||
|
<string name="settings_color_standard_2021">Spec 2021</string>
|
||||||
|
<string name="settings_color_standard_2025">Spec 2025</string>
|
||||||
|
<string name="settings_liquid_glass">Liquid glass</string>
|
||||||
|
<string name="settings_liquid_glass_description">Apply a translucent glass surface to floating navigation</string>
|
||||||
|
<string name="settings_predictive_back">Predictive back gesture</string>
|
||||||
|
<string name="settings_predictive_back_description">Preview the destination while swiping back</string>
|
||||||
|
<string name="settings_tap_to_choose">Tap to choose</string>
|
||||||
|
<string name="settings_requires_android_13">Requires Android 13 or later</string>
|
||||||
|
<string name="settings_requires_floating_navigation">Enable floating navigation first</string>
|
||||||
|
|
||||||
|
<!-- Redesigned device center and account surfaces -->
|
||||||
|
<string name="device_center_heading">Connected devices</string>
|
||||||
|
<string name="device_center_description">Select a device to control it or view its current state</string>
|
||||||
|
<string name="device_center_loading">Loading devices</string>
|
||||||
|
<string name="device_center_empty_title">No manageable devices configured</string>
|
||||||
|
<string name="device_center_empty_description">Configure a gateway device name and type first</string>
|
||||||
|
<string name="device_center_configure">Configure device</string>
|
||||||
|
<string name="device_center_error">Unable to load devices</string>
|
||||||
|
<string name="device_center_configure_title">Configure device</string>
|
||||||
|
<string name="device_center_device_name">Device name</string>
|
||||||
|
<string name="device_status_unavailable">Status unavailable</string>
|
||||||
|
<string name="device_status_offline">Offline</string>
|
||||||
|
<string name="device_status_switching">Switching</string>
|
||||||
|
<string name="device_status_awaiting_confirmation">Awaiting confirmation</string>
|
||||||
|
<string name="device_status_on">On</string>
|
||||||
|
<string name="device_status_off">Off</string>
|
||||||
|
<string name="device_status_confirmed">State confirmed</string>
|
||||||
|
<string name="device_status_waiting_first_confirmation">Waiting for first confirmation</string>
|
||||||
|
<string name="device_open_control_panel">Control panel</string>
|
||||||
|
<string name="device_sensor_subtitle">Environment sensor · Read-only monitoring</string>
|
||||||
|
<string name="device_live_reading">Live device reading</string>
|
||||||
|
<string name="device_details">Details</string>
|
||||||
|
<string name="device_metric_no_data">No data</string>
|
||||||
|
<string name="device_infrared_subtitle">Infrared controller · %1$d virtual remotes</string>
|
||||||
|
<string name="device_infrared_mounted">Mounted: %1$s</string>
|
||||||
|
<string name="device_infrared_no_remotes">No remotes</string>
|
||||||
|
<string name="device_infrared_slots">Hardware slots %1$d/%2$d</string>
|
||||||
|
<string name="device_infrared_slots_unavailable">Slot state unavailable</string>
|
||||||
|
<string name="device_infrared_center">Infrared center</string>
|
||||||
|
<string name="account_current">Current account</string>
|
||||||
|
<string name="account_greeting">Hello, %1$s</string>
|
||||||
|
<string name="account_connection_method">Connection method</string>
|
||||||
|
<string name="account_interface_settings">Theme</string>
|
||||||
|
<string name="account_interface_settings_desc">Colors, brightness, effects and scale</string>
|
||||||
|
<string name="account_logout">Sign out</string>
|
||||||
|
<string name="account_logout_desc">End the current connection and return to sign-in</string>
|
||||||
|
<string name="account_logout_confirm">Sign out of the current session?</string>
|
||||||
|
<string name="scene_create_sheet_title">New command</string>
|
||||||
|
<string name="scene_create_sheet_description">Choose how the command starts</string>
|
||||||
|
<string name="scene_create_manual_description">Run manually for frequently used device combinations.</string>
|
||||||
|
<string name="scene_create_automatic_description">Run automatically from time, device, or sensor conditions.</string>
|
||||||
|
<string name="socket_detail_subtitle">Smart socket · confirmed control</string>
|
||||||
|
<string name="socket_name_settings">Set socket device name</string>
|
||||||
|
<string name="sensor_name_settings">Set sensor device name</string>
|
||||||
|
<string name="gateway_device_name">Gateway device name</string>
|
||||||
|
<string name="gateway_device_name_socket_desc">Used to match and control the gateway device. It must exactly match the gateway name.</string>
|
||||||
|
<string name="gateway_device_name_sensor_desc">Used to match and read the gateway device. It must exactly match the gateway name.</string>
|
||||||
|
<string name="action_save_device_name">Save device name</string>
|
||||||
|
<string name="socket_direct_control">Direct control</string>
|
||||||
|
<string name="socket_confirmation_hint">A command is confirmed only after the gateway reports the resulting state. Refresh while confirmation is pending.</string>
|
||||||
|
<string name="sensor_current_readings">Current readings</string>
|
||||||
|
<string name="sensor_monitoring_title">Live environment monitoring</string>
|
||||||
|
<string name="sensor_monitoring_description">Values come directly from the current device. Missing metrics remain absent; no estimates or placeholders are used.</string>
|
||||||
|
<string name="infrared_center_title">Infrared remote center</string>
|
||||||
|
<string name="infrared_settings_title">Infrared device settings</string>
|
||||||
|
<string name="infrared_settings_subtitle">Remotes, slots, and gateway device name</string>
|
||||||
|
<string name="infrared_manage_title">Virtual remotes</string>
|
||||||
|
<string name="infrared_remote_count">%1$d remotes</string>
|
||||||
|
<string name="infrared_add_remote">Add remote</string>
|
||||||
|
<string name="infrared_hardware_slots_title">Hardware slots</string>
|
||||||
|
<string name="infrared_gateway_name_title">Gateway device name</string>
|
||||||
|
<string name="infrared_gateway_name_desc">Used to match the infrared controller in the gateway. It must exactly match the gateway name.</string>
|
||||||
|
<string name="infrared_save_name">Save device name</string>
|
||||||
|
<string name="infrared_edit_remote">Edit remote</string>
|
||||||
|
<string name="infrared_move_remote">Reorder remote</string>
|
||||||
|
<string name="infrared_move_up">Move up</string>
|
||||||
|
<string name="infrared_move_down">Move down</string>
|
||||||
|
<string name="infrared_delete_remote">Delete remote</string>
|
||||||
|
<string name="infrared_layout">Panel layout</string>
|
||||||
|
<string name="infrared_transmission_mode">Transmission mode</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.flagship.abox.manager.ui
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.android.tools.screenshot.PreviewTest
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AppSmokePreview() {
|
||||||
|
ABoxTheme(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
renderer = DesignRenderer.MATERIAL3,
|
||||||
|
themeMode = AppThemeMode.LIGHT,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("ABox Home")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,86 @@
|
|||||||
|
package com.flagship.abox.manager.ui.devicecenter
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||||
|
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||||
|
import com.flagship.abox.manager.domain.DeviceId
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
|
||||||
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DeviceCenterViewModelTest {
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun catalogEmissionMapsCardsAndFilterLocally() = runTest {
|
||||||
|
val profile = ProviderProfileId("p1")
|
||||||
|
val catalog = TestCatalog(listOf(
|
||||||
|
descriptor(profile, "plug", DeviceType.SOCKET),
|
||||||
|
descriptor(profile, "sensor", DeviceType.TEMPERATURE_HUMIDITY),
|
||||||
|
))
|
||||||
|
val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository())
|
||||||
|
viewModel.initialize(profile)
|
||||||
|
viewModel.setFilter(DeviceCategoryFilter.SOCKET)
|
||||||
|
|
||||||
|
assertEquals(listOf("plug"), viewModel.uiState.value.cards.map { it.displayName })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun profileChangeClearsSelectionAndLoadsNewCards() = runTest {
|
||||||
|
val first = ProviderProfileId("one")
|
||||||
|
val second = ProviderProfileId("two")
|
||||||
|
val catalog = TestCatalog(listOf(descriptor(first, "old", DeviceType.SOCKET)))
|
||||||
|
val viewModel = DeviceCenterViewModel(catalog, TestConfiguredRepository())
|
||||||
|
viewModel.initialize(first)
|
||||||
|
viewModel.select(catalog.devices.single())
|
||||||
|
catalog.emit(listOf(descriptor(second, "new", DeviceType.INFRARED)))
|
||||||
|
viewModel.initialize(second)
|
||||||
|
|
||||||
|
assertEquals(second, viewModel.uiState.value.profileId)
|
||||||
|
assertEquals(null, viewModel.uiState.value.selectedCard)
|
||||||
|
assertEquals(listOf("new"), viewModel.uiState.value.cards.map { it.displayName })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deviceCardKeyHasBundleCompatibleStableRepresentation() {
|
||||||
|
val first = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1"))
|
||||||
|
val same = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1"))
|
||||||
|
val differentProfile = DeviceCardKey(ProviderProfileId("profile-b"), DeviceName("socket-1"))
|
||||||
|
|
||||||
|
assertEquals(first.saveableKey(), same.saveableKey())
|
||||||
|
assertNotEquals(first.saveableKey(), differentProfile.saveableKey())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun descriptor(profile: ProviderProfileId, name: String, type: DeviceType) =
|
||||||
|
DeviceDescriptor(DeviceId("${profile.value}:$name"), profile, DeviceName(name), type)
|
||||||
|
|
||||||
|
private class TestCatalog(initial: List<DeviceDescriptor>) : DeviceCatalog {
|
||||||
|
val devices = initial.toMutableList()
|
||||||
|
private val state = MutableStateFlow<DomainResult<List<DeviceDescriptor>>>(DomainResult.Success(initial))
|
||||||
|
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = state
|
||||||
|
fun emit(value: List<DeviceDescriptor>) { devices.clear(); devices.addAll(value); state.value = DomainResult.Success(value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestConfiguredRepository : ConfiguredDeviceRepository {
|
||||||
|
override fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> =
|
||||||
|
MutableStateFlow(DomainResult.Success(emptyList()))
|
||||||
|
override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor> =
|
||||||
|
DomainResult.Failure(DomainError.InvalidRequest())
|
||||||
|
override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit> =
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package com.flagship.abox.manager.ui.navigation
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DeviceType
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass
|
||||||
|
import com.flagship.abox.manager.ui.shell.DetailPresentation
|
||||||
|
import com.flagship.abox.manager.ui.shell.MainNavigationPresentation
|
||||||
|
import com.flagship.abox.manager.ui.shell.MainTab
|
||||||
|
import com.flagship.abox.manager.ui.shell.DeviceSelection
|
||||||
|
import com.flagship.abox.manager.ui.shell.detailPresentation
|
||||||
|
import com.flagship.abox.manager.ui.shell.navigationPresentation
|
||||||
|
import com.flagship.abox.manager.ui.shell.widthClass
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AuthenticatedNavigationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `profile key change recreates authenticated graph`() {
|
||||||
|
val firstProfile = ProviderProfileId("first")
|
||||||
|
val secondProfile = ProviderProfileId("second")
|
||||||
|
val firstGraph = AuthenticatedGraphState(firstProfile)
|
||||||
|
.selectTab(MainTab.SCENES)
|
||||||
|
.selectDevice(DeviceName("socket"), DeviceType.SOCKET)
|
||||||
|
|
||||||
|
val recreated = firstGraph.recreateForProfile(secondProfile)
|
||||||
|
|
||||||
|
assertEquals(secondProfile, recreated.profileId)
|
||||||
|
assertEquals(MainTab.DEVICES, recreated.currentTab)
|
||||||
|
assertNull(recreated.selectedDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reselecting main tab pops to its root`() {
|
||||||
|
val state = AuthenticatedGraphState(ProviderProfileId("profile"))
|
||||||
|
.selectTab(MainTab.SCENES)
|
||||||
|
.pushChild("scene/editor/manual")
|
||||||
|
|
||||||
|
val reselected = state.selectTab(MainTab.SCENES)
|
||||||
|
|
||||||
|
assertEquals(MainTab.SCENES, reselected.currentTab)
|
||||||
|
assertEquals(listOf(MainTab.SCENES.route), reselected.tabBackStack(MainTab.SCENES))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `compact uses bottom navigation`() {
|
||||||
|
assertEquals(MainNavigationPresentation.BOTTOM_NAVIGATION, navigationPresentation(widthClass(599)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `medium and expanded use rail`() {
|
||||||
|
assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(600)))
|
||||||
|
assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(840)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `profile replacement clears previously selected device`() {
|
||||||
|
val state = AuthenticatedGraphState(ProviderProfileId("first"))
|
||||||
|
.selectDevice(DeviceName("sensor"), DeviceType.TEMPERATURE_HUMIDITY)
|
||||||
|
|
||||||
|
val replaced = state.recreateForProfile(ProviderProfileId("second"))
|
||||||
|
|
||||||
|
assertNull(replaced.selectedDevice)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selected device uses pane on expanded layouts and full screen otherwise`() {
|
||||||
|
val selection = DeviceSelection(
|
||||||
|
profileId = ProviderProfileId("profile"),
|
||||||
|
deviceName = DeviceName("sensor"),
|
||||||
|
type = DeviceType.TEMPERATURE_HUMIDITY,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(DetailPresentation.FULL_SCREEN, detailPresentation(widthClass(599), selection))
|
||||||
|
assertEquals(DetailPresentation.PANE, detailPresentation(widthClass(840), selection))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `back closes child route before leaving its main tab`() {
|
||||||
|
val state = AuthenticatedGraphState(ProviderProfileId("profile"))
|
||||||
|
.selectTab(MainTab.SCENES)
|
||||||
|
.pushChild("scene/editor/manual")
|
||||||
|
|
||||||
|
val afterBack = state.handleBack()
|
||||||
|
|
||||||
|
assertEquals(MainTab.SCENES, afterBack.currentTab)
|
||||||
|
assertEquals(listOf(MainTab.SCENES.route), afterBack.tabBackStack(MainTab.SCENES))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package com.flagship.abox.manager.ui.preferences
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.AccentPreference
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.InterfaceScale
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStandard
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStyle
|
||||||
|
import com.flagship.abox.manager.ui.common.UiText.StringResource
|
||||||
|
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferencesRepository
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferencesWriteResult
|
||||||
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SettingsViewModelTest {
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun rendererUpdatePersistsWithoutCallingSessionCoordinator() = runTest {
|
||||||
|
val repository = RecordingUiPreferencesRepository()
|
||||||
|
val viewModel = SettingsViewModel(repository)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, repository.updateCalls.size)
|
||||||
|
assertEquals(DesignRenderer.MIUIX, repository.value.renderer)
|
||||||
|
assertEquals(DesignRenderer.MIUIX, viewModel.uiState.value.preferences.renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun writeFailureLeavesLastValueAndShowsLocalizedError() = runTest {
|
||||||
|
val repository = RecordingUiPreferencesRepository(
|
||||||
|
initial = UiPreferences(renderer = DesignRenderer.MATERIAL3),
|
||||||
|
writeResult = UiPreferencesWriteResult.Failure,
|
||||||
|
)
|
||||||
|
val viewModel = SettingsViewModel(repository)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, repository.updateCalls.size)
|
||||||
|
assertEquals(DesignRenderer.MATERIAL3, viewModel.uiState.value.preferences.renderer)
|
||||||
|
assertTrue(viewModel.uiState.value.error is StringResource)
|
||||||
|
assertEquals(
|
||||||
|
R.string.settings_error_save,
|
||||||
|
(viewModel.uiState.value.error as StringResource).resId,
|
||||||
|
)
|
||||||
|
assertTrue(viewModel.uiState.value.saving.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun collectsRepositoryChanges() = runTest {
|
||||||
|
val repository = RecordingUiPreferencesRepository()
|
||||||
|
val viewModel = SettingsViewModel(repository)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
repository.emit(
|
||||||
|
UiPreferences(
|
||||||
|
renderer = DesignRenderer.MIUIX,
|
||||||
|
themeMode = AppThemeMode.DARK,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
blurEnabled = true,
|
||||||
|
navigationPresentation = NavigationPresentation.FLOATING,
|
||||||
|
hapticsEnabled = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(viewModel.uiState.value.loaded)
|
||||||
|
assertEquals(repository.value, viewModel.uiState.value.preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scaleAndAccentUpdatesPersistThroughRepository() = runTest {
|
||||||
|
val repository = RecordingUiPreferencesRepository()
|
||||||
|
val viewModel = SettingsViewModel(repository)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.onAction(SettingsUiAction.SetInterfaceScale(InterfaceScale.COMPACT))
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.onAction(SettingsUiAction.SetAccent(AccentPreference.ROSE))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(InterfaceScale.COMPACT, repository.value.interfaceScale)
|
||||||
|
assertEquals(AccentPreference.ROSE, repository.value.accentPreference)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun styleSpecificThemeOptionsPersistThroughRepository() = runTest {
|
||||||
|
val repository = RecordingUiPreferencesRepository()
|
||||||
|
val viewModel = SettingsViewModel(repository)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.onAction(SettingsUiAction.SetColorStyle(ColorStyle.FIDELITY))
|
||||||
|
viewModel.onAction(SettingsUiAction.SetColorStandard(ColorStandard.SPEC_2025))
|
||||||
|
viewModel.onAction(SettingsUiAction.SetLiquidGlass(true))
|
||||||
|
viewModel.onAction(SettingsUiAction.SetPredictiveBack(false))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(ColorStyle.FIDELITY, repository.value.colorStyle)
|
||||||
|
assertEquals(ColorStandard.SPEC_2025, repository.value.colorStandard)
|
||||||
|
assertTrue(repository.value.liquidGlassEnabled)
|
||||||
|
assertEquals(false, repository.value.predictiveBackEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private class RecordingUiPreferencesRepository(
|
||||||
|
initial: UiPreferences = UiPreferences(),
|
||||||
|
private val writeResult: UiPreferencesWriteResult = UiPreferencesWriteResult.Success,
|
||||||
|
) : UiPreferencesRepository {
|
||||||
|
private val state = MutableStateFlow(initial)
|
||||||
|
val updateCalls = mutableListOf<(UiPreferences) -> UiPreferences>()
|
||||||
|
val value: UiPreferences get() = state.value
|
||||||
|
|
||||||
|
override fun observe(): Flow<UiPreferences> = state.asStateFlow()
|
||||||
|
|
||||||
|
override suspend fun update(
|
||||||
|
transform: (UiPreferences) -> UiPreferences,
|
||||||
|
): UiPreferencesWriteResult {
|
||||||
|
updateCalls += transform
|
||||||
|
if (writeResult is UiPreferencesWriteResult.Success) {
|
||||||
|
state.value = transform(state.value)
|
||||||
|
}
|
||||||
|
return writeResult
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(value: UiPreferences) {
|
||||||
|
state.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import com.flagship.abox.manager.domain.SocketPowerState
|
|||||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
@@ -39,6 +40,7 @@ class SceneEditorViewModelTest {
|
|||||||
private val profileStore = FakeProviderProfileStore()
|
private val profileStore = FakeProviderProfileStore()
|
||||||
private val sceneRepository = FakeSceneRepository()
|
private val sceneRepository = FakeSceneRepository()
|
||||||
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
||||||
|
private val deviceCatalog = FakeDeviceCatalog()
|
||||||
private val profileId = ProviderProfileId("local-experience")
|
private val profileId = ProviderProfileId("local-experience")
|
||||||
|
|
||||||
private val existingScene = Scene(
|
private val existingScene = Scene(
|
||||||
@@ -66,7 +68,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun initializeWithNullPreparesNewScene() = runTest {
|
fun initializeWithNullPreparesNewScene() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.MANUAL)
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
|
|
||||||
val state = viewModel.state.value
|
val state = viewModel.state.value
|
||||||
@@ -79,7 +81,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun initializeWithExistingSceneLoadsState() = runTest {
|
fun initializeWithExistingSceneLoadsState() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(existingScene.id)
|
viewModel.initialize(existingScene.id)
|
||||||
|
|
||||||
// Immediately binds sceneId and enters loading state
|
// Immediately binds sceneId and enters loading state
|
||||||
@@ -101,7 +103,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun addingAndReorderingActionsUpdatesState() = runTest {
|
fun addingAndReorderingActionsUpdatesState() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.MANUAL)
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
viewModel.updateName("My New Scene")
|
viewModel.updateName("My New Scene")
|
||||||
|
|
||||||
@@ -129,7 +131,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
|
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||||
viewModel.updateName("Auto Rule")
|
viewModel.updateName("Auto Rule")
|
||||||
|
|
||||||
@@ -153,7 +155,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun saveNewScenePersistsToRepository() = runTest {
|
fun saveNewScenePersistsToRepository() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.MANUAL)
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
viewModel.updateName("Brand New Scene")
|
viewModel.updateName("Brand New Scene")
|
||||||
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
||||||
@@ -170,7 +172,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||||
viewModel.updateName("Auto Without Trigger")
|
viewModel.updateName("Auto Without Trigger")
|
||||||
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||||
@@ -188,7 +190,7 @@ class SceneEditorViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
||||||
setupProfile()
|
setupProfile()
|
||||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||||
viewModel.initialize(existingScene.id)
|
viewModel.initialize(existingScene.id)
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.flagship.abox.manager.ui.session
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.LoginCredentials
|
||||||
|
import com.flagship.abox.manager.domain.Password
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
|
import com.flagship.abox.manager.domain.SessionCoordinator
|
||||||
|
import com.flagship.abox.manager.domain.SessionValidity
|
||||||
|
import com.flagship.abox.manager.domain.Username
|
||||||
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
|
||||||
|
import com.flagship.abox.manager.ui.common.UiText
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SessionActionsViewModelTest {
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val profile = ProviderProfile(
|
||||||
|
id = ProviderProfileId("abox"),
|
||||||
|
type = ProviderType.ABOX,
|
||||||
|
displayName = "ABox",
|
||||||
|
)
|
||||||
|
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun endSessionActionEndsSessionWithoutForgettingCredentials() = runTest {
|
||||||
|
val fixture = Fixture()
|
||||||
|
fixture.activateAuthenticatedProfile()
|
||||||
|
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
|
||||||
|
val viewModel = SessionActionsViewModel(fixture.coordinator)
|
||||||
|
|
||||||
|
viewModel.onAction(SessionUiAction.EndSession)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(fixture.remote.session(profile.id) == null)
|
||||||
|
assertEquals(
|
||||||
|
credentials,
|
||||||
|
fixture.credentials.loadCredentials(profile.id).successValue(),
|
||||||
|
)
|
||||||
|
assertNull(viewModel.uiState.value.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun forgetCredentialsActionKeepsSessionAndDisablesAutoLogin() = runTest {
|
||||||
|
val fixture = Fixture()
|
||||||
|
fixture.activateAuthenticatedProfile()
|
||||||
|
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
|
||||||
|
val viewModel = SessionActionsViewModel(fixture.coordinator)
|
||||||
|
|
||||||
|
viewModel.onAction(SessionUiAction.ForgetSavedCredentials)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(profile.id, fixture.remote.session(profile.id)?.profileId)
|
||||||
|
assertNull(fixture.credentials.loadCredentials(profile.id).successValue())
|
||||||
|
assertTrue(!fixture.profiles.loginPreferences(profile.id).autoLoginEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchProviderActionClearsProfileOnlyAfterLogout() = runTest {
|
||||||
|
val fixture = Fixture()
|
||||||
|
fixture.activateAuthenticatedProfile()
|
||||||
|
fixture.remote.login(profile.id, credentials)
|
||||||
|
val viewModel = SessionActionsViewModel(fixture.coordinator)
|
||||||
|
|
||||||
|
viewModel.onAction(SessionUiAction.SwitchProvider)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(profile.id, fixture.remote.logoutProfileId)
|
||||||
|
assertNull(fixture.profiles.activeProfile())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validateConnectionActionDelegatesWithoutRelogin() = runTest {
|
||||||
|
val fixture = Fixture()
|
||||||
|
fixture.activateAuthenticatedProfile()
|
||||||
|
fixture.remote.setValidationResult(
|
||||||
|
profile.id,
|
||||||
|
com.flagship.abox.manager.domain.DomainResult.Success(SessionValidity.INVALID),
|
||||||
|
)
|
||||||
|
val viewModel = SessionActionsViewModel(fixture.coordinator)
|
||||||
|
|
||||||
|
viewModel.onAction(SessionUiAction.ValidateConnection)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(SessionValidity.INVALID, fixture.remote.lastValidation)
|
||||||
|
assertEquals(0, fixture.remote.loginCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mappedActionErrorNeverExposesDomainDetail() = runTest {
|
||||||
|
val fixture = Fixture()
|
||||||
|
fixture.activateAuthenticatedProfile()
|
||||||
|
fixture.remote.logoutError = DomainError.Unknown(detail = "keystore secret detail")
|
||||||
|
val viewModel = SessionActionsViewModel(fixture.coordinator)
|
||||||
|
|
||||||
|
viewModel.dispatch(SessionUiAction.EndSession)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
UiText.StringResource(R.string.error_unknown),
|
||||||
|
viewModel.uiState.value.errorMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dismissErrorActionClearsMappedError() = runTest {
|
||||||
|
val fixture = Fixture()
|
||||||
|
fixture.activateAuthenticatedProfile()
|
||||||
|
fixture.remote.logoutError = DomainError.Network(detail = "private network detail")
|
||||||
|
val viewModel = SessionActionsViewModel(fixture.coordinator)
|
||||||
|
|
||||||
|
viewModel.onAction(SessionUiAction.EndSession)
|
||||||
|
advanceUntilIdle()
|
||||||
|
viewModel.onAction(SessionUiAction.DismissError)
|
||||||
|
|
||||||
|
assertNull(viewModel.uiState.value.errorMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class Fixture {
|
||||||
|
val profiles = FakeProviderProfileStore()
|
||||||
|
val credentials = FakeCredentialStore()
|
||||||
|
val remote = RecordingSessionGateway(profiles)
|
||||||
|
val local = FakeLocalSessionGateway()
|
||||||
|
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
|
||||||
|
|
||||||
|
suspend fun activateAuthenticatedProfile() {
|
||||||
|
profiles.saveProfile(
|
||||||
|
ProviderProfile(
|
||||||
|
id = ProviderProfileId("abox"),
|
||||||
|
type = ProviderType.ABOX,
|
||||||
|
displayName = "ABox",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
profiles.setActiveProfile(ProviderProfileId("abox"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingSessionGateway(
|
||||||
|
private val profiles: FakeProviderProfileStore,
|
||||||
|
private val delegate: FakeSessionGateway = FakeSessionGateway(),
|
||||||
|
) : com.flagship.abox.manager.domain.SessionGateway by delegate {
|
||||||
|
var loginCalls = 0
|
||||||
|
private set
|
||||||
|
var logoutProfileId: ProviderProfileId? = null
|
||||||
|
private set
|
||||||
|
var lastValidation: SessionValidity? = null
|
||||||
|
private set
|
||||||
|
var logoutError: DomainError? = null
|
||||||
|
|
||||||
|
override suspend fun login(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
credentials: LoginCredentials,
|
||||||
|
) = delegate.login(profileId, credentials).also { loginCalls++ }
|
||||||
|
|
||||||
|
override suspend fun validateSession(profileId: ProviderProfileId) = delegate
|
||||||
|
.validateSession(profileId)
|
||||||
|
.also { result ->
|
||||||
|
lastValidation = (result as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun logout(profileId: ProviderProfileId) = logoutError?.let {
|
||||||
|
logoutProfileId = profiles.activeProfile()?.id
|
||||||
|
com.flagship.abox.manager.domain.DomainResult.Failure(it)
|
||||||
|
} ?: delegate.logout(profileId).also {
|
||||||
|
logoutProfileId = profiles.activeProfile()?.id
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun session(profileId: ProviderProfileId) = delegate.observeSession(profileId)
|
||||||
|
.let { flow -> flow.first() }
|
||||||
|
|
||||||
|
fun setValidationResult(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
result: com.flagship.abox.manager.domain.DomainResult<SessionValidity>,
|
||||||
|
) {
|
||||||
|
delegate.setValidationResult(profileId, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun <T> com.flagship.abox.manager.domain.DomainResult<T>.successValue(): T? =
|
||||||
|
(this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
|
||||||
@@ -9,7 +9,7 @@ class MainTabTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `all main tabs have distinct non-empty routes and valid label resources`() {
|
fun `all main tabs have distinct non-empty routes and valid label resources`() {
|
||||||
val tabs = MainTab.values()
|
val tabs = MainTab.entries
|
||||||
assertEquals(3, tabs.size)
|
assertEquals(3, tabs.size)
|
||||||
|
|
||||||
val routes = tabs.map { it.route }
|
val routes = tabs.map { it.route }
|
||||||
|
|||||||
@@ -157,6 +157,32 @@ class SocketViewModelTest {
|
|||||||
assertNotNull(state.errorMessage)
|
assertNotNull(state.errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun commandSuccessReadsBackConfirmedState() = runTest {
|
||||||
|
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||||
|
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||||
|
|
||||||
|
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||||
|
|
||||||
|
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||||
|
assertEquals(SocketOperationState.IdleConfirmed, viewModel.state.value.operation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun readbackFailureKeepsLastConfirmedStateAndMarksAwaitingConfirmation() = runTest {
|
||||||
|
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||||
|
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||||
|
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Readback failed"))
|
||||||
|
|
||||||
|
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||||
|
|
||||||
|
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||||
|
assertEquals(
|
||||||
|
SocketOperationState.AwaitingConfirmation(SocketPowerState.ON),
|
||||||
|
viewModel.state.value.operation,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
||||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||||
|
|||||||
@@ -129,8 +129,8 @@ class TemperatureHumidityViewModelTest {
|
|||||||
assertTrue(state.hasNoData)
|
assertTrue(state.hasNoData)
|
||||||
assertNull(state.temperature)
|
assertNull(state.temperature)
|
||||||
assertNull(state.humidity)
|
assertNull(state.humidity)
|
||||||
assertEquals("--", state.formattedTemperature)
|
assertNull(state.formattedTemperature)
|
||||||
assertEquals("--", state.formattedHumidity)
|
assertNull(state.formattedHumidity)
|
||||||
assertFalse(state.hasValidData)
|
assertFalse(state.hasValidData)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,4 +190,16 @@ class TemperatureHumidityViewModelTest {
|
|||||||
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
|
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
|
||||||
assertNull(viewModel.state.value.errorMessage)
|
assertNull(viewModel.state.value.errorMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun partialReadingKeepsAvailableMetric() = runTest {
|
||||||
|
fakeGateway.setReading(
|
||||||
|
TemperatureHumidityReading(profileId, deviceName, temperature = "26.5", humidity = null),
|
||||||
|
)
|
||||||
|
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||||
|
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||||
|
|
||||||
|
assertEquals("26.5", viewModel.state.value.temperature)
|
||||||
|
assertNull(viewModel.state.value.humidity)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
alias(libs.plugins.compose.screenshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.flagship.abox.manager.designsystem"
|
namespace = "com.flagship.abox.manager.designsystem"
|
||||||
compileSdk = 34
|
compileSdk = 37
|
||||||
|
|
||||||
|
experimentalProperties["android.experimental.enableScreenshotTest"] = true
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
@@ -15,25 +21,32 @@ android {
|
|||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlin {
|
||||||
jvmTarget = "17"
|
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(project(":android:core:preferences"))
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
implementation(libs.compose.material3)
|
implementation(libs.compose.material3)
|
||||||
implementation(libs.compose.material.icons.extended)
|
implementation(libs.compose.material.icons.extended)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(libs.miuix.ui.android)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
screenshotTestImplementation(libs.compose.screenshot.test)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(platform(libs.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.activity.compose)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.test.runner)
|
||||||
|
androidTestImplementation(libs.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.compose.ui.test.manifest)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.SemanticsProperties
|
||||||
|
import androidx.compose.ui.test.assertContentDescriptionEquals
|
||||||
|
import androidx.compose.ui.test.assertHasClickAction
|
||||||
|
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsOff
|
||||||
|
import androidx.compose.ui.test.assertWidthIsAtLeast
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ABoxComponentSemanticsTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun allInteractiveControlsHaveAtLeast48DpBounds() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) {
|
||||||
|
Text("确定")
|
||||||
|
}
|
||||||
|
ABoxIconButton(
|
||||||
|
key = ABoxIconKey.ADD,
|
||||||
|
contentDescription = "添加设备",
|
||||||
|
onClick = {},
|
||||||
|
modifier = Modifier.testTag("icon"),
|
||||||
|
)
|
||||||
|
ABoxSwitch(
|
||||||
|
checked = false,
|
||||||
|
onCheckedChange = {},
|
||||||
|
contentDescription = "启用设备",
|
||||||
|
modifier = Modifier.testTag("switch"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf("button", "icon", "switch").forEach { tag ->
|
||||||
|
composeRule.onNodeWithTag(tag)
|
||||||
|
.assertWidthIsAtLeast(48.dp)
|
||||||
|
.assertHeightIsAtLeast(48.dp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun operableSwitchExposesChineseLabelRoleStateAndToggleAction() {
|
||||||
|
var checked = false
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
ABoxSwitch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = { checked = it },
|
||||||
|
contentDescription = "启用设备",
|
||||||
|
modifier = Modifier.testTag("operable-switch"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val switchNode = composeRule.onNodeWithTag("operable-switch")
|
||||||
|
.assertContentDescriptionEquals("启用设备")
|
||||||
|
.assertIsOff()
|
||||||
|
.assertHasClickAction()
|
||||||
|
.assertWidthIsAtLeast(48.dp)
|
||||||
|
.assertHeightIsAtLeast(48.dp)
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
assertEquals(Role.Switch, switchNode.config[SemanticsProperties.Role])
|
||||||
|
assertTrue(switchNode.config.contains(SemanticsProperties.ToggleableState))
|
||||||
|
|
||||||
|
composeRule.onNodeWithTag("operable-switch").performClick()
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
assertEquals(true, checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cardBodyAndQuickActionExposeIndependentSemanticNodes() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
ABoxCard(
|
||||||
|
title = "客厅插座",
|
||||||
|
subtitle = "在线",
|
||||||
|
onClick = {},
|
||||||
|
quickAction = ABoxCardQuickAction(
|
||||||
|
key = ABoxIconKey.REFRESH,
|
||||||
|
contentDescription = "刷新设备",
|
||||||
|
onClick = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("客厅插座").assertIsDisplayed().assertHasClickAction()
|
||||||
|
composeRule.onNodeWithContentDescription("刷新设备").assertIsDisplayed().assertHasClickAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun renderersExposeSameChineseStateDescription() {
|
||||||
|
var renderer by mutableStateOf(DesignRenderer.MATERIAL3)
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
renderer = renderer,
|
||||||
|
themeMode = AppThemeMode.LIGHT,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.PENDING,
|
||||||
|
contentDescription = "正在处理",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("正在处理").assertExists()
|
||||||
|
composeRule.runOnUiThread { renderer = DesignRenderer.MIUIX }
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
composeRule.onNodeWithContentDescription("正在处理").assertExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxThemeContent(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
renderer = DesignRenderer.MATERIAL3,
|
||||||
|
themeMode = AppThemeMode.LIGHT,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
),
|
||||||
|
systemInDarkTheme = false,
|
||||||
|
sdkInt = Build.VERSION.SDK_INT,
|
||||||
|
dynamicColorTokens = null,
|
||||||
|
reducedMotion = true,
|
||||||
|
) {
|
||||||
|
ABoxLoadingState(contentDescription = "正在加载")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val loadingNode = composeRule.onNodeWithContentDescription("正在加载")
|
||||||
|
.assertExists()
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
assertEquals("静态加载", loadingNode.config[SemanticsProperties.StateDescription])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun interactiveIconRequiresNonBlankChineseDescription() {
|
||||||
|
composeRule.setContent {
|
||||||
|
ABoxTheme {
|
||||||
|
ABoxIconButton(
|
||||||
|
key = ABoxIconKey.BACK,
|
||||||
|
contentDescription = "返回",
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeRule.onNodeWithContentDescription("返回")
|
||||||
|
.assertContentDescriptionEquals("返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,51 +1,97 @@
|
|||||||
package com.flagship.abox.manager.designsystem
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.outlined.AccountCircle
|
||||||
import androidx.compose.material.icons.filled.LocationOn
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ButtonDefaults
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
|
import androidx.compose.foundation.selection.toggleable
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
|
||||||
|
import androidx.compose.material3.Icon as MaterialIcon
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxAppSurface(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = LocalABoxColors.current
|
||||||
|
val blurEnabled = LocalABoxBlurEnabled.current
|
||||||
|
Surface(
|
||||||
|
modifier = modifier,
|
||||||
|
color = if (blurEnabled) colors.surfaceContainer.copy(alpha = 0.92f) else colors.surface,
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compatibility surface retained for existing feature modules. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxSurface(
|
fun ABoxSurface(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) = ABoxAppSurface(modifier = modifier, content = content)
|
||||||
Surface(modifier = modifier, content = content)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxButton(
|
fun ABoxButton(
|
||||||
@@ -56,7 +102,7 @@ fun ABoxButton(
|
|||||||
) {
|
) {
|
||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = modifier,
|
modifier = modifier.defaultMinSize(minHeight = 48.dp),
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
colors = ButtonDefaults.buttonColors(),
|
colors = ButtonDefaults.buttonColors(),
|
||||||
content = content,
|
content = content,
|
||||||
@@ -64,14 +110,437 @@ fun ABoxButton(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
|
fun ABoxIconButton(
|
||||||
CircularProgressIndicator(modifier = modifier)
|
key: ABoxIconKey,
|
||||||
|
contentDescription: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
require(contentDescription.isNotBlank()) {
|
||||||
|
"ABoxIconButton contentDescription must not be blank"
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp),
|
||||||
|
) {
|
||||||
|
ABoxIcon(key = key, contentDescription = contentDescription)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Circular user avatar displaying the initial character of username or a default person icon.
|
fun ABoxSwitch(
|
||||||
* Styled with an accented profile ring.
|
checked: Boolean,
|
||||||
*/
|
onCheckedChange: ((Boolean) -> Unit)?,
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
) {
|
||||||
|
require(contentDescription.isNotBlank()) {
|
||||||
|
"ABoxSwitch contentDescription must not be blank"
|
||||||
|
}
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
||||||
|
.toggleable(
|
||||||
|
value = checked,
|
||||||
|
enabled = enabled && onCheckedChange != null,
|
||||||
|
role = Role.Switch,
|
||||||
|
onValueChange = { onCheckedChange?.invoke(it) },
|
||||||
|
)
|
||||||
|
.semantics {
|
||||||
|
this.contentDescription = contentDescription
|
||||||
|
},
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = checked,
|
||||||
|
onCheckedChange = null,
|
||||||
|
enabled = enabled,
|
||||||
|
modifier = Modifier.clearAndSetSemantics { },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxFilterChip(
|
||||||
|
selected: Boolean,
|
||||||
|
label: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
leadingIcon: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
FilterChip(
|
||||||
|
selected = selected,
|
||||||
|
onClick = onClick,
|
||||||
|
label = { Text(label) },
|
||||||
|
modifier = modifier.defaultMinSize(minHeight = 48.dp),
|
||||||
|
leadingIcon = leadingIcon,
|
||||||
|
colors = FilterChipDefaults.filterChipColors(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
|
||||||
|
val reducedMotion = LocalABoxReducedMotion.current
|
||||||
|
if (reducedMotion) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
progress = { 0.5f },
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(modifier = modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxLoadingState(
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
require(contentDescription.isNotBlank()) {
|
||||||
|
"ABoxLoadingState contentDescription must not be blank"
|
||||||
|
}
|
||||||
|
val reducedMotion = LocalABoxReducedMotion.current
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics {
|
||||||
|
this.contentDescription = contentDescription
|
||||||
|
stateDescription = if (reducedMotion) "静态加载" else "动态加载"
|
||||||
|
},
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ABoxLoadingIndicator(modifier = Modifier.size(32.dp))
|
||||||
|
Text(text = contentDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ABoxStatus {
|
||||||
|
ONLINE,
|
||||||
|
OFFLINE,
|
||||||
|
PENDING,
|
||||||
|
WARNING,
|
||||||
|
ERROR,
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxStatusBanner(
|
||||||
|
state: ABoxStatus,
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
message: String = contentDescription,
|
||||||
|
) {
|
||||||
|
require(contentDescription.isNotBlank()) {
|
||||||
|
"ABoxStatusBanner contentDescription must not be blank"
|
||||||
|
}
|
||||||
|
val colors = LocalABoxColors.current
|
||||||
|
val (iconKey, color) = when (state) {
|
||||||
|
ABoxStatus.ONLINE -> ABoxIconKey.ONLINE to colors.success
|
||||||
|
ABoxStatus.OFFLINE -> ABoxIconKey.OFFLINE to colors.offline
|
||||||
|
ABoxStatus.PENDING -> ABoxIconKey.PENDING to colors.pending
|
||||||
|
ABoxStatus.WARNING -> ABoxIconKey.WARNING to colors.warning
|
||||||
|
ABoxStatus.ERROR -> ABoxIconKey.ERROR to colors.error
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.semantics { this.contentDescription = contentDescription },
|
||||||
|
color = color.copy(alpha = 0.14f),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
ABoxIcon(key = iconKey, contentDescription = "${contentDescription}图标", modifier = Modifier.size(22.dp))
|
||||||
|
Text(text = message, color = color, style = MaterialTheme.typography.labelLarge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxEmptyState(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
action: (@Composable () -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ABoxIcon(ABoxIconKey.DEVICES, contentDescription = title, modifier = Modifier.size(40.dp))
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxErrorState(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
onRetry: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
ABoxIcon(ABoxIconKey.ERROR, contentDescription = title, modifier = Modifier.size(40.dp))
|
||||||
|
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||||
|
Text(text = description, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
if (onRetry != null) {
|
||||||
|
ABoxButton(onClick = onRetry) { Text("重试") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxCard(
|
||||||
|
title: String,
|
||||||
|
subtitle: String? = null,
|
||||||
|
onClick: (() -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
leadingContent: (@Composable () -> Unit)? = null,
|
||||||
|
quickAction: ABoxCardQuickAction? = null,
|
||||||
|
content: (@Composable ColumnScope.() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
shape = RoundedCornerShape(LocalABoxShapes.current.mediumCornerRadius),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
leadingContent?.let {
|
||||||
|
Box(modifier = Modifier.padding(end = 12.dp), contentAlignment = Alignment.Center) {
|
||||||
|
it()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.then(
|
||||||
|
if (onClick != null) {
|
||||||
|
Modifier
|
||||||
|
.clickable(
|
||||||
|
role = Role.Button,
|
||||||
|
onClickLabel = "打开$title",
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.semantics { role = Role.Button }
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 2,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
content?.invoke(this)
|
||||||
|
}
|
||||||
|
if (quickAction != null) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
ABoxIconButton(
|
||||||
|
key = quickAction.key,
|
||||||
|
contentDescription = quickAction.contentDescription,
|
||||||
|
onClick = quickAction.onClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ABoxCardQuickAction(
|
||||||
|
val key: ABoxIconKey,
|
||||||
|
val contentDescription: String,
|
||||||
|
val onClick: () -> Unit,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxDialog(
|
||||||
|
visible: Boolean,
|
||||||
|
title: String,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
confirmText: String = "确定",
|
||||||
|
dismissText: String = "取消",
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
if (visible) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
modifier = modifier,
|
||||||
|
title = { Text(title) },
|
||||||
|
text = content,
|
||||||
|
confirmButton = {
|
||||||
|
ABoxButton(onClick = onConfirm) { Text(confirmText) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismissRequest) { Text(dismissText) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ABoxBottomSheet(
|
||||||
|
visible: Boolean,
|
||||||
|
onDismissRequest: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
if (visible) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismissRequest,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxPreferenceGroup(
|
||||||
|
title: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
content: @Composable ColumnScope.() -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
)
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ABoxNavigationItem(
|
||||||
|
val key: ABoxIconKey,
|
||||||
|
val label: String,
|
||||||
|
val contentDescription: String = label,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxNavigationSuite(
|
||||||
|
items: List<ABoxNavigationItem>,
|
||||||
|
selectedKey: ABoxIconKey,
|
||||||
|
onItemSelected: (ABoxIconKey) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
ABoxNavigationBar(modifier = modifier) {
|
||||||
|
items.forEach { item ->
|
||||||
|
ABoxNavigationBarItem(
|
||||||
|
selected = item.key == selectedKey,
|
||||||
|
onClick = { onItemSelected(item.key) },
|
||||||
|
icon = {
|
||||||
|
ABoxIcon(
|
||||||
|
key = item.key,
|
||||||
|
contentDescription = item.contentDescription,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
label = { Text(item.label) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxDeviceDetailScaffold(
|
||||||
|
title: String,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
subtitle: String? = null,
|
||||||
|
icon: ABoxIconKey = ABoxIconKey.UNKNOWN_DEVICE,
|
||||||
|
action: (@Composable () -> Unit)? = null,
|
||||||
|
content: @Composable (PaddingValues) -> Unit,
|
||||||
|
) {
|
||||||
|
Scaffold(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
topBar = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
ABoxIconButton(
|
||||||
|
key = ABoxIconKey.BACK,
|
||||||
|
contentDescription = "返回",
|
||||||
|
onClick = onBack,
|
||||||
|
)
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primaryContainer,
|
||||||
|
modifier = Modifier.padding(start = 4.dp),
|
||||||
|
) {
|
||||||
|
ABoxIcon(
|
||||||
|
key = icon,
|
||||||
|
contentDescription = subtitle ?: title,
|
||||||
|
modifier = Modifier.padding(9.dp).size(24.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
if (subtitle != null) {
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
action?.invoke()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Circular user avatar displaying the initial character of username or a default person icon. */
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxUserAvatar(
|
fun ABoxUserAvatar(
|
||||||
name: String?,
|
name: String?,
|
||||||
@@ -83,6 +552,12 @@ fun ABoxUserAvatar(
|
|||||||
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
|
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
|
||||||
|
.clickable(onClickLabel = contentDescription, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
.background(MaterialTheme.colorScheme.primaryContainer)
|
||||||
@@ -90,8 +565,7 @@ fun ABoxUserAvatar(
|
|||||||
width = 2.dp,
|
width = 2.dp,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
|
||||||
shape = CircleShape,
|
shape = CircleShape,
|
||||||
)
|
),
|
||||||
.clickable(onClickLabel = contentDescription, onClick = onClick),
|
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (initial != null) {
|
if (initial != null) {
|
||||||
@@ -103,53 +577,48 @@ fun ABoxUserAvatar(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
MaterialIcon(
|
||||||
imageVector = Icons.Default.Person,
|
imageVector = ABoxIconMapperForAccount,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
modifier = Modifier.size(size * 0.6f),
|
modifier = Modifier.size(size * 0.6f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle
|
||||||
* Material 3 pill top bar containing leading icon, title/provider label,
|
|
||||||
* an optional '+' action button, and user avatar.
|
/** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxTopPillBar(
|
fun ABoxTopPillBar(
|
||||||
title: String,
|
title: String,
|
||||||
onAvatarClick: () -> Unit,
|
onAvatarClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
userName: String? = null,
|
userName: String? = null,
|
||||||
leadingIcon: ImageVector = Icons.Default.LocationOn,
|
leadingIcon: ImageVector = Icons.Outlined.Bolt,
|
||||||
onAddClick: (() -> Unit)? = null,
|
onAddClick: (() -> Unit)? = null,
|
||||||
addActionContentDescription: String? = "Add",
|
addActionContentDescription: String? = "添加",
|
||||||
avatarContentDescription: String? = "Account",
|
avatarContentDescription: String? = "账户",
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier.fillMaxWidth().height(64.dp),
|
||||||
.fillMaxWidth()
|
shape = RoundedCornerShape(32.dp),
|
||||||
.height(56.dp),
|
|
||||||
shape = RoundedCornerShape(28.dp),
|
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
tonalElevation = 2.dp,
|
tonalElevation = 2.dp,
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Icon(
|
MaterialIcon(
|
||||||
imageVector = leadingIcon,
|
imageVector = leadingIcon,
|
||||||
contentDescription = null,
|
contentDescription = title,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.padding(start = 8.dp).size(22.dp),
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||||
@@ -158,20 +627,13 @@ fun ABoxTopPillBar(
|
|||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (onAddClick != null) {
|
if (onAddClick != null) {
|
||||||
IconButton(
|
ABoxIconButton(
|
||||||
|
key = ABoxIconKey.ADD,
|
||||||
|
contentDescription = addActionContentDescription ?: "添加",
|
||||||
onClick = onAddClick,
|
onClick = onAddClick,
|
||||||
modifier = Modifier.size(36.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Add,
|
|
||||||
contentDescription = addActionContentDescription,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
ABoxUserAvatar(
|
ABoxUserAvatar(
|
||||||
name = userName,
|
name = userName,
|
||||||
onClick = onAvatarClick,
|
onClick = onAvatarClick,
|
||||||
@@ -182,9 +644,6 @@ fun ABoxTopPillBar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ABox standard Material 3 Navigation Bar with pill indicator styling.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxNavigationBar(
|
fun ABoxNavigationBar(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -198,9 +657,6 @@ fun ABoxNavigationBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* ABox standard Navigation Bar Item.
|
|
||||||
*/
|
|
||||||
@Composable
|
@Composable
|
||||||
fun RowScope.ABoxNavigationBarItem(
|
fun RowScope.ABoxNavigationBarItem(
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
@@ -226,3 +682,16 @@ fun RowScope.ABoxNavigationBarItem(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxBackLayer(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
BackHandler(enabled = enabled, onBack = onBack)
|
||||||
|
Box(modifier = modifier.fillMaxSize()) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ package com.flagship.abox.manager.designsystem
|
|||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Shapes
|
import androidx.compose.material3.Shapes
|
||||||
import androidx.compose.ui.unit.dp
|
import com.flagship.abox.manager.designsystem.tokens.ABoxShapeTokens
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
|
||||||
|
|
||||||
val ABoxShapes = Shapes(
|
fun ABoxShapes(tokens: ABoxShapeTokens = ABoxThemeDefaults.material3Shapes) = Shapes(
|
||||||
small = RoundedCornerShape(8.dp),
|
small = RoundedCornerShape(tokens.smallCornerRadius),
|
||||||
medium = RoundedCornerShape(16.dp),
|
medium = RoundedCornerShape(tokens.mediumCornerRadius),
|
||||||
large = RoundedCornerShape(24.dp),
|
large = RoundedCornerShape(tokens.largeCornerRadius),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,41 +1,205 @@
|
|||||||
package com.flagship.abox.manager.designsystem
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import android.os.Build
|
||||||
import androidx.compose.material3.MaterialTheme
|
import android.provider.Settings
|
||||||
|
import androidx.compose.material3.ColorScheme
|
||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicDarkColorScheme
|
||||||
|
import androidx.compose.material3.dynamicLightColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import com.flagship.abox.manager.designsystem.renderer.ABoxRendererAdapters
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
|
||||||
primary = Color(0xFF006780),
|
val LocalABoxSpacing = staticCompositionLocalOf { ABoxSpacing() }
|
||||||
onPrimary = Color.White,
|
val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography }
|
||||||
primaryContainer = Color(0xFFB8EAFF),
|
val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes }
|
||||||
onPrimaryContainer = Color(0xFF001F29),
|
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
|
||||||
secondary = Color(0xFF4D616A),
|
val LocalABoxReducedMotion = staticCompositionLocalOf { false }
|
||||||
surface = Color(0xFFF8FAFC),
|
val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
|
||||||
background = Color(0xFFF8FAFC),
|
val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD }
|
||||||
)
|
val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false }
|
||||||
|
val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false }
|
||||||
private val DarkColorScheme = darkColorScheme(
|
|
||||||
primary = Color(0xFF5DD5F8),
|
|
||||||
onPrimary = Color(0xFF003544),
|
|
||||||
primaryContainer = Color(0xFF004D61),
|
|
||||||
onPrimaryContainer = Color(0xFFB8EAFF),
|
|
||||||
secondary = Color(0xFFB4CBD4),
|
|
||||||
surface = Color(0xFF101416),
|
|
||||||
background = Color(0xFF101416),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxTheme(
|
fun ABoxTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
preferences: UiPreferences,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
MaterialTheme(
|
val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme()
|
||||||
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme,
|
val context = LocalContext.current
|
||||||
typography = ABoxTypography,
|
val isDarkTheme = when (preferences.themeMode) {
|
||||||
shapes = ABoxShapes,
|
AppThemeMode.SYSTEM -> systemInDarkTheme
|
||||||
|
AppThemeMode.LIGHT -> false
|
||||||
|
AppThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val dynamicColorScheme = if (isDarkTheme) {
|
||||||
|
dynamicDarkColorScheme(context)
|
||||||
|
} else {
|
||||||
|
dynamicLightColorScheme(context)
|
||||||
|
}
|
||||||
|
dynamicColorScheme.toABoxColorTokens()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
ABoxThemeContent(
|
||||||
|
preferences = preferences,
|
||||||
|
systemInDarkTheme = systemInDarkTheme,
|
||||||
|
sdkInt = Build.VERSION.SDK_INT,
|
||||||
|
dynamicColorTokens = dynamicTokens,
|
||||||
|
reducedMotion = systemAnimatorDurationScale(context) == 0f,
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun ABoxThemeContent(
|
||||||
|
preferences: UiPreferences,
|
||||||
|
systemInDarkTheme: Boolean,
|
||||||
|
sdkInt: Int,
|
||||||
|
dynamicColorTokens: ABoxColorTokens?,
|
||||||
|
reducedMotion: Boolean,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val resolution = ABoxThemeResolver.resolve(
|
||||||
|
preferences = preferences,
|
||||||
|
systemInDarkTheme = systemInDarkTheme,
|
||||||
|
sdkInt = sdkInt,
|
||||||
|
dynamicColorTokens = dynamicColorTokens,
|
||||||
|
)
|
||||||
|
val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val scaledDensity = Density(
|
||||||
|
density = density.density * (preferences.interfaceScale.percent / 100f),
|
||||||
|
fontScale = density.fontScale,
|
||||||
|
)
|
||||||
|
|
||||||
|
CompositionLocalProvider(
|
||||||
|
LocalDensity provides scaledDensity,
|
||||||
|
LocalABoxColors provides resolution.colorTokens,
|
||||||
|
LocalABoxSpacing provides ABoxSpacing(),
|
||||||
|
LocalABoxTypography provides ABoxTypography,
|
||||||
|
LocalABoxShapes provides resolution.shapeTokens,
|
||||||
|
LocalABoxRenderer provides resolution.renderer,
|
||||||
|
LocalABoxReducedMotion provides reducedMotion,
|
||||||
|
LocalABoxBlurEnabled provides resolution.blurEnabled,
|
||||||
|
LocalABoxNavigationPresentation provides preferences.navigationPresentation,
|
||||||
|
LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled,
|
||||||
|
LocalABoxPredictiveBackEnabled provides false,
|
||||||
|
) {
|
||||||
|
renderer.Render(
|
||||||
|
resolution = resolution,
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun systemAnimatorDurationScale(context: android.content.Context): Float =
|
||||||
|
runCatching {
|
||||||
|
Settings.Global.getFloat(
|
||||||
|
context.contentResolver,
|
||||||
|
Settings.Global.ANIMATOR_DURATION_SCALE,
|
||||||
|
1f,
|
||||||
|
)
|
||||||
|
}.getOrDefault(1f)
|
||||||
|
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxTheme(
|
||||||
|
darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(),
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
ABoxTheme(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
themeMode = if (darkTheme) AppThemeMode.DARK else AppThemeMode.LIGHT,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun materialColorScheme(resolution: ABoxThemeResolution): ColorScheme {
|
||||||
|
val colors = resolution.colorTokens
|
||||||
|
val onPrimary = if (resolution.isDarkTheme) Color(0xFF003544) else Color.White
|
||||||
|
val onSurface = if (resolution.isDarkTheme) Color(0xFFE0E3E5) else Color(0xFF191C1E)
|
||||||
|
val onSurfaceVariant = if (resolution.isDarkTheme) Color(0xFFC0C8CC) else Color(0xFF40484C)
|
||||||
|
val primaryContainer = colors.primary.copy(alpha = if (resolution.isDarkTheme) 0.35f else 0.20f)
|
||||||
|
val secondaryContainer = colors.surfaceContainer
|
||||||
|
|
||||||
|
return if (resolution.isDarkTheme) {
|
||||||
|
darkColorScheme(
|
||||||
|
primary = colors.primary,
|
||||||
|
onPrimary = onPrimary,
|
||||||
|
primaryContainer = primaryContainer,
|
||||||
|
onPrimaryContainer = colors.primary,
|
||||||
|
secondary = colors.pending,
|
||||||
|
onSecondary = Color(0xFF3F2E00),
|
||||||
|
secondaryContainer = secondaryContainer,
|
||||||
|
onSecondaryContainer = onSurface,
|
||||||
|
tertiary = colors.success,
|
||||||
|
onTertiary = Color(0xFF00391E),
|
||||||
|
surface = colors.surface,
|
||||||
|
onSurface = onSurface,
|
||||||
|
surfaceVariant = colors.surfaceContainer,
|
||||||
|
onSurfaceVariant = onSurfaceVariant,
|
||||||
|
background = colors.surface,
|
||||||
|
onBackground = onSurface,
|
||||||
|
outline = colors.outline,
|
||||||
|
error = colors.error,
|
||||||
|
onError = Color(0xFF690005),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
lightColorScheme(
|
||||||
|
primary = colors.primary,
|
||||||
|
onPrimary = onPrimary,
|
||||||
|
primaryContainer = primaryContainer,
|
||||||
|
onPrimaryContainer = Color(0xFF001F29),
|
||||||
|
secondary = colors.pending,
|
||||||
|
onSecondary = Color.White,
|
||||||
|
secondaryContainer = secondaryContainer,
|
||||||
|
onSecondaryContainer = onSurface,
|
||||||
|
tertiary = colors.success,
|
||||||
|
onTertiary = Color.White,
|
||||||
|
surface = colors.surface,
|
||||||
|
onSurface = onSurface,
|
||||||
|
surfaceVariant = colors.surfaceContainer,
|
||||||
|
onSurfaceVariant = onSurfaceVariant,
|
||||||
|
background = colors.surface,
|
||||||
|
onBackground = onSurface,
|
||||||
|
outline = colors.outline,
|
||||||
|
error = colors.error,
|
||||||
|
onError = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ColorScheme.toABoxColorTokens() = ABoxColorTokens(
|
||||||
|
primary = primary,
|
||||||
|
surface = surface,
|
||||||
|
surfaceContainer = surfaceContainer,
|
||||||
|
outline = outline,
|
||||||
|
success = tertiary,
|
||||||
|
warning = secondary,
|
||||||
|
error = error,
|
||||||
|
offline = outline,
|
||||||
|
pending = secondary,
|
||||||
|
deviceSocket = primary,
|
||||||
|
deviceSensor = tertiary,
|
||||||
|
deviceInfrared = secondary,
|
||||||
|
)
|
||||||
|
|||||||
@@ -2,4 +2,5 @@ package com.flagship.abox.manager.designsystem
|
|||||||
|
|
||||||
import androidx.compose.material3.Typography
|
import androidx.compose.material3.Typography
|
||||||
|
|
||||||
|
/** Default typography supplied through [LocalABoxTypography]. */
|
||||||
val ABoxTypography = Typography()
|
val ABoxTypography = Typography()
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.icon
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||||
|
import androidx.compose.material.icons.outlined.AccountCircle
|
||||||
|
import androidx.compose.material.icons.outlined.Add
|
||||||
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
|
import androidx.compose.material.icons.outlined.Close
|
||||||
|
import androidx.compose.material.icons.outlined.Devices
|
||||||
|
import androidx.compose.material.icons.outlined.Edit
|
||||||
|
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||||
|
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||||
|
import androidx.compose.material.icons.outlined.History
|
||||||
|
import androidx.compose.material.icons.outlined.Palette
|
||||||
|
import androidx.compose.material.icons.outlined.Settings
|
||||||
|
import androidx.compose.material.icons.outlined.Tune
|
||||||
|
import androidx.compose.material.icons.outlined.Vibration
|
||||||
|
import androidx.compose.material.icons.outlined.ViewCarousel
|
||||||
|
import androidx.compose.material.icons.outlined.Layers
|
||||||
|
import androidx.compose.material.icons.outlined.Power
|
||||||
|
import androidx.compose.material.icons.outlined.PowerSettingsNew
|
||||||
|
import androidx.compose.material.icons.outlined.Refresh
|
||||||
|
import androidx.compose.material.icons.outlined.Schedule
|
||||||
|
import androidx.compose.material.icons.outlined.Sensors
|
||||||
|
import androidx.compose.material.icons.outlined.Thermostat
|
||||||
|
import androidx.compose.material.icons.outlined.Warning
|
||||||
|
import androidx.compose.material.icons.outlined.WaterDrop
|
||||||
|
import androidx.compose.material.icons.outlined.Wifi
|
||||||
|
import androidx.compose.material.icons.outlined.WifiOff
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.material3.Icon as MaterialIcon
|
||||||
|
|
||||||
|
/** Stable semantic identifiers for every icon exposed by the ABox design system. */
|
||||||
|
enum class ABoxIconKey {
|
||||||
|
BACK,
|
||||||
|
CLOSE,
|
||||||
|
ADD,
|
||||||
|
EDIT,
|
||||||
|
REFRESH,
|
||||||
|
ACCOUNT,
|
||||||
|
PROVIDER,
|
||||||
|
DEVICES,
|
||||||
|
SCENES,
|
||||||
|
ACTIVITY,
|
||||||
|
SOCKET,
|
||||||
|
SENSOR,
|
||||||
|
INFRARED,
|
||||||
|
POWER,
|
||||||
|
TEMPERATURE,
|
||||||
|
HUMIDITY,
|
||||||
|
ONLINE,
|
||||||
|
OFFLINE,
|
||||||
|
PENDING,
|
||||||
|
WARNING,
|
||||||
|
ERROR,
|
||||||
|
UNKNOWN_DEVICE,
|
||||||
|
SETTINGS,
|
||||||
|
THEME,
|
||||||
|
EFFECTS,
|
||||||
|
FEEDBACK,
|
||||||
|
SCALE,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Design-system-owned device categories; this module does not depend on domain models. */
|
||||||
|
enum class ABoxDeviceType {
|
||||||
|
SOCKET,
|
||||||
|
SENSOR,
|
||||||
|
INFRARED,
|
||||||
|
POWER,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
object ABoxIconMapper {
|
||||||
|
fun forDeviceType(type: ABoxDeviceType?): ABoxIconKey = when (type) {
|
||||||
|
ABoxDeviceType.SOCKET -> ABoxIconKey.SOCKET
|
||||||
|
ABoxDeviceType.SENSOR -> ABoxIconKey.SENSOR
|
||||||
|
ABoxDeviceType.INFRARED -> ABoxIconKey.INFRARED
|
||||||
|
ABoxDeviceType.POWER -> ABoxIconKey.POWER
|
||||||
|
null, ABoxDeviceType.UNKNOWN -> ABoxIconKey.UNKNOWN_DEVICE
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun vectorFor(key: ABoxIconKey): ImageVector = when (key) {
|
||||||
|
ABoxIconKey.BACK -> Icons.AutoMirrored.Outlined.ArrowBack
|
||||||
|
ABoxIconKey.CLOSE -> Icons.Outlined.Close
|
||||||
|
ABoxIconKey.ADD -> Icons.Outlined.Add
|
||||||
|
ABoxIconKey.EDIT -> Icons.Outlined.Edit
|
||||||
|
ABoxIconKey.REFRESH -> Icons.Outlined.Refresh
|
||||||
|
ABoxIconKey.ACCOUNT -> Icons.Outlined.AccountCircle
|
||||||
|
ABoxIconKey.PROVIDER -> Icons.Outlined.Bolt
|
||||||
|
ABoxIconKey.DEVICES -> Icons.Outlined.Devices
|
||||||
|
ABoxIconKey.SCENES -> Icons.Outlined.Layers
|
||||||
|
ABoxIconKey.ACTIVITY -> Icons.Outlined.History
|
||||||
|
ABoxIconKey.SOCKET -> Icons.Outlined.Power
|
||||||
|
ABoxIconKey.SENSOR -> Icons.Outlined.Sensors
|
||||||
|
ABoxIconKey.INFRARED -> Icons.Outlined.Bolt
|
||||||
|
ABoxIconKey.POWER -> Icons.Outlined.PowerSettingsNew
|
||||||
|
ABoxIconKey.TEMPERATURE -> Icons.Outlined.Thermostat
|
||||||
|
ABoxIconKey.HUMIDITY -> Icons.Outlined.WaterDrop
|
||||||
|
ABoxIconKey.ONLINE -> Icons.Outlined.Wifi
|
||||||
|
ABoxIconKey.OFFLINE -> Icons.Outlined.WifiOff
|
||||||
|
ABoxIconKey.PENDING -> Icons.Outlined.Schedule
|
||||||
|
ABoxIconKey.WARNING -> Icons.Outlined.Warning
|
||||||
|
ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline
|
||||||
|
ABoxIconKey.UNKNOWN_DEVICE -> Icons.AutoMirrored.Outlined.HelpOutline
|
||||||
|
ABoxIconKey.SETTINGS -> Icons.Outlined.Settings
|
||||||
|
ABoxIconKey.THEME -> Icons.Outlined.Palette
|
||||||
|
ABoxIconKey.EFFECTS -> Icons.Outlined.ViewCarousel
|
||||||
|
ABoxIconKey.FEEDBACK -> Icons.Outlined.Vibration
|
||||||
|
ABoxIconKey.SCALE -> Icons.Outlined.Tune
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ABoxIcon(
|
||||||
|
key: ABoxIconKey,
|
||||||
|
contentDescription: String,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
require(contentDescription.isNotBlank()) {
|
||||||
|
"ABoxIcon contentDescription must not be blank"
|
||||||
|
}
|
||||||
|
MaterialIcon(
|
||||||
|
imageVector = ABoxIconMapper.vectorFor(key),
|
||||||
|
contentDescription = contentDescription,
|
||||||
|
modifier = modifier,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.renderer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
|
||||||
|
internal interface ABoxRendererAdapter {
|
||||||
|
@Composable
|
||||||
|
fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object ABoxRendererAdapters {
|
||||||
|
fun forRenderer(renderer: DesignRenderer): ABoxRendererAdapter = when (renderer) {
|
||||||
|
DesignRenderer.MATERIAL3 -> Material3RendererAdapter
|
||||||
|
DesignRenderer.MIUIX -> MiuixRendererAdapter
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.renderer
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxShapes
|
||||||
|
import com.flagship.abox.manager.designsystem.ABoxTypography
|
||||||
|
import com.flagship.abox.manager.designsystem.materialColorScheme
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
|
||||||
|
|
||||||
|
internal object Material3RendererAdapter : ABoxRendererAdapter {
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
resolution: ABoxThemeResolution,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = materialColorScheme(resolution),
|
||||||
|
typography = ABoxTypography,
|
||||||
|
shapes = ABoxShapes(resolution.shapeTokens),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.renderer
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
|
||||||
|
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.darkColorScheme
|
||||||
|
import top.yukonga.miuix.kmp.theme.lightColorScheme
|
||||||
|
|
||||||
|
internal object MiuixRendererAdapter : ABoxRendererAdapter {
|
||||||
|
@Composable
|
||||||
|
override fun Render(
|
||||||
|
resolution: ABoxThemeResolution,
|
||||||
|
content: @Composable () -> Unit,
|
||||||
|
) {
|
||||||
|
val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme()
|
||||||
|
MiuixTheme(colors = colors) {
|
||||||
|
androidx.compose.material3.MaterialTheme(
|
||||||
|
colorScheme = com.flagship.abox.manager.designsystem.materialColorScheme(resolution),
|
||||||
|
typography = com.flagship.abox.manager.designsystem.ABoxTypography,
|
||||||
|
shapes = com.flagship.abox.manager.designsystem.ABoxShapes(resolution.shapeTokens),
|
||||||
|
content = content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.tokens
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.AccentPreference
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStandard
|
||||||
|
import com.flagship.abox.manager.preferences.ColorStyle
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxColorTokens(
|
||||||
|
val primary: Color,
|
||||||
|
val surface: Color,
|
||||||
|
val surfaceContainer: Color,
|
||||||
|
val outline: Color,
|
||||||
|
val success: Color,
|
||||||
|
val warning: Color,
|
||||||
|
val error: Color,
|
||||||
|
val offline: Color,
|
||||||
|
val pending: Color,
|
||||||
|
val deviceSocket: Color,
|
||||||
|
val deviceSensor: Color,
|
||||||
|
val deviceInfrared: Color,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxSpacing(
|
||||||
|
val dp4: Dp = 4.dp,
|
||||||
|
val dp8: Dp = 8.dp,
|
||||||
|
val dp12: Dp = 12.dp,
|
||||||
|
val dp16: Dp = 16.dp,
|
||||||
|
val dp20: Dp = 20.dp,
|
||||||
|
val dp24: Dp = 24.dp,
|
||||||
|
val dp32: Dp = 32.dp,
|
||||||
|
val minimumTouchTarget: Dp = 48.dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxComponentState(
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val pending: Boolean = false,
|
||||||
|
val offline: Boolean = false,
|
||||||
|
val dangerous: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxShapeTokens(
|
||||||
|
val smallCornerRadius: Dp,
|
||||||
|
val mediumCornerRadius: Dp,
|
||||||
|
val largeCornerRadius: Dp,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
data class ABoxThemeResolution(
|
||||||
|
val isDarkTheme: Boolean,
|
||||||
|
val colorTokens: ABoxColorTokens,
|
||||||
|
val shapeTokens: ABoxShapeTokens,
|
||||||
|
val blurEnabled: Boolean,
|
||||||
|
val renderer: DesignRenderer,
|
||||||
|
)
|
||||||
|
|
||||||
|
object ABoxThemeDefaults {
|
||||||
|
val lightColors = ABoxColorTokens(
|
||||||
|
primary = Color(0xFF006780),
|
||||||
|
surface = Color(0xFFF8FAFC),
|
||||||
|
surfaceContainer = Color(0xFFE2E8F0),
|
||||||
|
outline = Color(0xFF70797D),
|
||||||
|
success = Color(0xFF1B7F4B),
|
||||||
|
warning = Color(0xFF8A5800),
|
||||||
|
error = Color(0xFFBA1A1A),
|
||||||
|
offline = Color(0xFF5F6B72),
|
||||||
|
pending = Color(0xFF8A5800),
|
||||||
|
deviceSocket = Color(0xFF006E90),
|
||||||
|
deviceSensor = Color(0xFF476B23),
|
||||||
|
deviceInfrared = Color(0xFF77536F),
|
||||||
|
)
|
||||||
|
val darkColors = ABoxColorTokens(
|
||||||
|
primary = Color(0xFF5DD5F8),
|
||||||
|
surface = Color(0xFF101416),
|
||||||
|
surfaceContainer = Color(0xFF1F292D),
|
||||||
|
outline = Color(0xFF8A969B),
|
||||||
|
success = Color(0xFF7EDFA7),
|
||||||
|
warning = Color(0xFFFFB95B),
|
||||||
|
error = Color(0xFFFFB4AB),
|
||||||
|
offline = Color(0xFFBFC9CD),
|
||||||
|
pending = Color(0xFFFFB95B),
|
||||||
|
deviceSocket = Color(0xFF5DD5F8),
|
||||||
|
deviceSensor = Color(0xFFAFD67C),
|
||||||
|
deviceInfrared = Color(0xFFE6B5D6),
|
||||||
|
)
|
||||||
|
val material3Shapes = ABoxShapeTokens(
|
||||||
|
smallCornerRadius = 8.dp,
|
||||||
|
mediumCornerRadius = 16.dp,
|
||||||
|
largeCornerRadius = 24.dp,
|
||||||
|
)
|
||||||
|
val miuixShapes = ABoxShapeTokens(
|
||||||
|
smallCornerRadius = 12.dp,
|
||||||
|
mediumCornerRadius = 20.dp,
|
||||||
|
largeCornerRadius = 28.dp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
object ABoxThemeResolver {
|
||||||
|
fun resolve(
|
||||||
|
preferences: UiPreferences,
|
||||||
|
systemInDarkTheme: Boolean,
|
||||||
|
sdkInt: Int,
|
||||||
|
dynamicColorTokens: ABoxColorTokens?,
|
||||||
|
): ABoxThemeResolution {
|
||||||
|
val isDarkTheme = when (preferences.themeMode) {
|
||||||
|
AppThemeMode.SYSTEM -> systemInDarkTheme
|
||||||
|
AppThemeMode.LIGHT -> false
|
||||||
|
AppThemeMode.DARK -> true
|
||||||
|
}
|
||||||
|
val brandTokens = if (isDarkTheme) {
|
||||||
|
ABoxThemeDefaults.darkColors
|
||||||
|
} else {
|
||||||
|
ABoxThemeDefaults.lightColors
|
||||||
|
}
|
||||||
|
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
|
||||||
|
dynamicColorTokens
|
||||||
|
} else {
|
||||||
|
ABoxAccentPalette.resolve(
|
||||||
|
base = brandTokens,
|
||||||
|
accent = preferences.accentPreference,
|
||||||
|
style = preferences.colorStyle,
|
||||||
|
standard = preferences.colorStandard,
|
||||||
|
dark = isDarkTheme,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val shapeTokens = when (preferences.renderer) {
|
||||||
|
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
|
||||||
|
DesignRenderer.MIUIX -> ABoxThemeDefaults.miuixShapes
|
||||||
|
}
|
||||||
|
|
||||||
|
return ABoxThemeResolution(
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
colorTokens = colorTokens,
|
||||||
|
shapeTokens = shapeTokens,
|
||||||
|
blurEnabled = preferences.blurEnabled && preferences.renderer == DesignRenderer.MIUIX,
|
||||||
|
renderer = preferences.renderer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object ABoxAccentPalette {
|
||||||
|
fun preview(
|
||||||
|
accent: AccentPreference,
|
||||||
|
style: ColorStyle,
|
||||||
|
standard: ColorStandard,
|
||||||
|
dark: Boolean,
|
||||||
|
): Color = resolve(
|
||||||
|
base = if (dark) ABoxThemeDefaults.darkColors else ABoxThemeDefaults.lightColors,
|
||||||
|
accent = accent,
|
||||||
|
style = style,
|
||||||
|
standard = standard,
|
||||||
|
dark = dark,
|
||||||
|
).primary
|
||||||
|
|
||||||
|
fun resolve(
|
||||||
|
base: ABoxColorTokens,
|
||||||
|
accent: AccentPreference,
|
||||||
|
style: ColorStyle,
|
||||||
|
standard: ColorStandard,
|
||||||
|
dark: Boolean,
|
||||||
|
): ABoxColorTokens {
|
||||||
|
val source = source(accent, dark)
|
||||||
|
val primary = when (style) {
|
||||||
|
ColorStyle.TONAL_SPOT -> lerp(source, base.surface, if (dark) 0.08f else 0.12f)
|
||||||
|
ColorStyle.EXPRESSIVE -> expressive(accent, dark)
|
||||||
|
ColorStyle.FIDELITY -> source
|
||||||
|
}
|
||||||
|
val deviceSensor = when (style) {
|
||||||
|
ColorStyle.EXPRESSIVE -> expressiveSensor(accent, dark)
|
||||||
|
ColorStyle.FIDELITY -> lerp(source, base.success, 0.38f)
|
||||||
|
ColorStyle.TONAL_SPOT -> base.deviceSensor
|
||||||
|
}
|
||||||
|
val deviceInfrared = when (style) {
|
||||||
|
ColorStyle.EXPRESSIVE -> expressiveInfrared(accent, dark)
|
||||||
|
ColorStyle.FIDELITY -> lerp(source, base.deviceInfrared, 0.46f)
|
||||||
|
ColorStyle.TONAL_SPOT -> base.deviceInfrared
|
||||||
|
}
|
||||||
|
val styled = base.copy(
|
||||||
|
primary = primary,
|
||||||
|
deviceSocket = primary,
|
||||||
|
deviceSensor = deviceSensor,
|
||||||
|
deviceInfrared = deviceInfrared,
|
||||||
|
)
|
||||||
|
return when (standard) {
|
||||||
|
ColorStandard.SPEC_2021 -> styled
|
||||||
|
ColorStandard.SPEC_2025 -> styled.copy(
|
||||||
|
surface = if (dark) Color(0xFF0C1114) else Color(0xFFFAF9FC),
|
||||||
|
surfaceContainer = if (dark) Color(0xFF242C30) else Color(0xFFE9E8EE),
|
||||||
|
outline = if (dark) Color(0xFFA7B2B7) else Color(0xFF596267),
|
||||||
|
primary = lerp(primary, if (dark) Color.White else Color.Black, 0.08f),
|
||||||
|
deviceSocket = lerp(primary, if (dark) Color.White else Color.Black, 0.08f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun source(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF4F6FA9)
|
||||||
|
AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF2F7D57)
|
||||||
|
AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFFA66700)
|
||||||
|
AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA94C68)
|
||||||
|
AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF7659A8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expressive(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE -> if (dark) Color(0xFF72D6FF) else Color(0xFF006B91)
|
||||||
|
AccentPreference.GREEN -> if (dark) Color(0xFF69E6B1) else Color(0xFF007A54)
|
||||||
|
AccentPreference.AMBER -> if (dark) Color(0xFFFFC24B) else Color(0xFF9A5C00)
|
||||||
|
AccentPreference.ROSE -> if (dark) Color(0xFFFFA8C4) else Color(0xFFB12F62)
|
||||||
|
AccentPreference.VIOLET -> if (dark) Color(0xFFC9B5FF) else Color(0xFF7048B8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expressiveSensor(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE, AccentPreference.VIOLET -> if (dark) Color(0xFF78DFA2) else Color(0xFF26764A)
|
||||||
|
AccentPreference.GREEN, AccentPreference.AMBER -> if (dark) Color(0xFF79D6F3) else Color(0xFF006985)
|
||||||
|
AccentPreference.ROSE -> if (dark) Color(0xFFFFC45F) else Color(0xFF8B5B00)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expressiveInfrared(accent: AccentPreference, dark: Boolean): Color = when (accent) {
|
||||||
|
AccentPreference.BLUE, AccentPreference.GREEN -> if (dark) Color(0xFFD1B8FF) else Color(0xFF7453A5)
|
||||||
|
AccentPreference.AMBER, AccentPreference.ROSE -> if (dark) Color(0xFF9FCBFF) else Color(0xFF38639A)
|
||||||
|
AccentPreference.VIOLET -> if (dark) Color(0xFFFFB0C7) else Color(0xFFA43E62)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.android.tools.screenshot.PreviewTest
|
||||||
|
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "M3 light", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMaterialLight() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "M3 dark", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMaterialDark() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.DARK)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Miuix light", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMiuixLight() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Miuix dark", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotMiuixDark() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.DARK)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Large font", widthDp = 360, heightDp = 760, fontScale = 1.3f)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotLargeFont() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview(name = "Blur fallback", widthDp = 360, heightDp = 760)
|
||||||
|
@Composable
|
||||||
|
fun ABoxThemeScreenshotBlurFallback() {
|
||||||
|
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT, blurEnabled = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ABoxScreenshotFixture(
|
||||||
|
renderer: DesignRenderer,
|
||||||
|
themeMode: AppThemeMode,
|
||||||
|
blurEnabled: Boolean = false,
|
||||||
|
) {
|
||||||
|
ABoxTheme(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
renderer = renderer,
|
||||||
|
themeMode = themeMode,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
blurEnabled = blurEnabled,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
ABoxAppSurface(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
ABoxTopPillBar(
|
||||||
|
title = "我的设备",
|
||||||
|
onAvatarClick = {},
|
||||||
|
userName = "小明",
|
||||||
|
onAddClick = {},
|
||||||
|
addActionContentDescription = "添加设备",
|
||||||
|
avatarContentDescription = "账户",
|
||||||
|
)
|
||||||
|
ABoxCard(
|
||||||
|
title = "客厅插座",
|
||||||
|
subtitle = "在线 · 正常",
|
||||||
|
onClick = {},
|
||||||
|
quickAction = ABoxCardQuickAction(
|
||||||
|
key = ABoxIconKey.REFRESH,
|
||||||
|
contentDescription = "刷新设备",
|
||||||
|
onClick = {},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.PENDING,
|
||||||
|
contentDescription = "正在处理",
|
||||||
|
)
|
||||||
|
ABoxStatusBanner(
|
||||||
|
state = ABoxStatus.OFFLINE,
|
||||||
|
contentDescription = "设备离线",
|
||||||
|
)
|
||||||
|
ABoxLoadingState(contentDescription = "正在加载")
|
||||||
|
ABoxEmptyState(
|
||||||
|
title = "暂无设备",
|
||||||
|
description = "添加设备后会显示在这里。",
|
||||||
|
)
|
||||||
|
ABoxErrorState(
|
||||||
|
title = "加载失败",
|
||||||
|
description = "请稍后重试。",
|
||||||
|
onRetry = {},
|
||||||
|
)
|
||||||
|
ABoxFilterChip(
|
||||||
|
selected = true,
|
||||||
|
label = "全部",
|
||||||
|
onClick = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import com.android.tools.screenshot.PreviewTest
|
||||||
|
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||||
|
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||||
|
import com.flagship.abox.manager.preferences.UiPreferences
|
||||||
|
|
||||||
|
@PreviewTest
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun DesignSystemSmokePreview() {
|
||||||
|
ABoxTheme(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
renderer = DesignRenderer.MATERIAL3,
|
||||||
|
themeMode = AppThemeMode.LIGHT,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text("截图基线")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,37 @@
|
|||||||
|
package com.flagship.abox.manager.designsystem.icon
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class ABoxIconMappingTest {
|
||||||
|
@Test
|
||||||
|
fun unknownDeviceNeverMapsToPower() {
|
||||||
|
assertNotEquals(ABoxIconKey.POWER, ABoxIconMapper.forDeviceType(null))
|
||||||
|
assertEquals(ABoxIconKey.UNKNOWN_DEVICE, ABoxIconMapper.forDeviceType(null))
|
||||||
|
assertEquals(
|
||||||
|
ABoxIconKey.UNKNOWN_DEVICE,
|
||||||
|
ABoxIconMapper.forDeviceType(ABoxDeviceType.UNKNOWN),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun knownDeviceCategoriesMapToTheirTypedIcons() {
|
||||||
|
assertEquals(
|
||||||
|
ABoxIconKey.SOCKET,
|
||||||
|
ABoxIconMapper.forDeviceType(ABoxDeviceType.SOCKET),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ABoxIconKey.SENSOR,
|
||||||
|
ABoxIconMapper.forDeviceType(ABoxDeviceType.SENSOR),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ABoxIconKey.INFRARED,
|
||||||
|
ABoxIconMapper.forDeviceType(ABoxDeviceType.INFRARED),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
ABoxIconKey.POWER,
|
||||||
|
ABoxIconMapper.forDeviceType(ABoxDeviceType.POWER),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
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(
|
||||||
|
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
|
||||||
|
fun dynamicColorDisabledUsesBrandTokens() {
|
||||||
|
val resolution = ABoxThemeResolver.resolve(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
themeMode = AppThemeMode.LIGHT,
|
||||||
|
dynamicColorEnabled = false,
|
||||||
|
),
|
||||||
|
systemInDarkTheme = false,
|
||||||
|
sdkInt = 31,
|
||||||
|
dynamicColorTokens = dynamicTokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ABoxAccentPalette.resolve(
|
||||||
|
ABoxThemeDefaults.lightColors,
|
||||||
|
UiPreferences().accentPreference,
|
||||||
|
UiPreferences().colorStyle,
|
||||||
|
UiPreferences().colorStandard,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
resolution.colorTokens,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pre31DynamicColorFallsBackToBrandTokens() {
|
||||||
|
val resolution = ABoxThemeResolver.resolve(
|
||||||
|
preferences = UiPreferences(
|
||||||
|
themeMode = AppThemeMode.DARK,
|
||||||
|
dynamicColorEnabled = true,
|
||||||
|
),
|
||||||
|
systemInDarkTheme = false,
|
||||||
|
sdkInt = 30,
|
||||||
|
dynamicColorTokens = dynamicTokens,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
ABoxAccentPalette.resolve(
|
||||||
|
ABoxThemeDefaults.darkColors,
|
||||||
|
UiPreferences().accentPreference,
|
||||||
|
UiPreferences().colorStyle,
|
||||||
|
UiPreferences().colorStandard,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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<Any>(), result.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
错误丢失
认证态不再接收或展示
actionState.errorMessage和inProgress,而账户操作会先关闭弹窗再发起切换提供方、结束会话或忘记凭据。若网络或存储操作失败且会话仍保持认证态,错误只被传给登录页,当前界面不会提供任何失败反馈或重试提示。