Compare commits

...
26 Commits
Author SHA1 Message Date
KremeCN 54aa74126f feat(ui): complete app experience redesign 2026-08-27 11:45:30 +08:00
KremeCN 816606234b docs: update release hardware validation 2026-08-26 21:30:45 +08:00
KremeCN cd4a5ea04c test: align infrared UI instrumentation coverage 2026-08-26 21:24:20 +08:00
KremeCN d8d9eb4f28 fix: remove legacy text icons and placeholders 2026-08-26 21:14:09 +08:00
KremeCN 7a107cc55a docs: record device data capability boundaries 2026-08-26 20:50:17 +08:00
KremeCN 0e4a50b3dd feat: add isolated activity event writer 2026-08-26 20:48:54 +08:00
KremeCN 7afcea296e feat: add persisted activity timeline 2026-08-26 20:44:15 +08:00
KremeCN 8e3d7e604b feat: select configured devices in scene editor 2026-08-26 20:37:30 +08:00
KremeCN 1139606f9f fix: reject unconfigured scene device fallbacks 2026-08-26 20:32:07 +08:00
KremeCN 5a25c31b39 feat: guard automation scheduling by profile 2026-08-26 20:29:13 +08:00
KremeCN fd67820c63 feat: add fail closed automation evaluation 2026-08-26 20:26:59 +08:00
KremeCN cce0441476 feat: evaluate automation conditions safely 2026-08-26 20:20:44 +08:00
KremeCN 713106df14 feat: stream scene execution progress 2026-08-26 20:15:54 +08:00
KremeCN ca4c34f21c feat: add observable scene execution foundation 2026-08-26 20:13:11 +08:00
KremeCN 867096d7ef feat: add automation persistence foundation 2026-08-26 20:08:37 +08:00
KremeCN 25ed255ddf feat: define activity event contracts 2026-08-26 20:03:22 +08:00
KremeCN 176a70cf7f fix: clean up infrared learning cancellation 2026-08-26 19:58:51 +08:00
KremeCN 26ab5095e5 feat: route infrared through selected device 2026-08-26 19:55:30 +08:00
KremeCN 4798ae32ba feat: preserve infrared ownership in schema 2026-08-26 19:49:39 +08:00
KremeCN b537cd1824 fix: isolate infrared slots by hardware 2026-08-26 19:42:16 +08:00
KremeCN 171d1daa50 feat: unify device detail presentation 2026-08-26 19:39:07 +08:00
KremeCN 9ba17c7d72 feat: render partial sensor readings 2026-08-26 19:32:12 +08:00
KremeCN a30ab9e376 feat: confirm socket controls by readback 2026-08-26 19:27:40 +08:00
KremeCN 876acd1e7b feat: replace device shortcuts with device center 2026-08-26 19:23:04 +08:00
KremeCN e14b4cee27 feat: add profile scoped device catalog 2026-08-26 19:11:36 +08:00
KremeCN 6e78b71d31 feat: redesign account and provider dialog 2026-08-26 19:04:57 +08:00
135 changed files with 7357 additions and 1470 deletions
@@ -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,
+2
View File
@@ -67,8 +67,10 @@ 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: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"))
@@ -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()
}
}
@@ -1,26 +1,24 @@
package com.flagship.abox.manager.ui.preferences package com.flagship.abox.manager.ui.preferences
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertWidthIsAtLeast import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.preferences.DesignRenderer import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -31,60 +29,100 @@ class SettingsScreenTest {
val composeRule = createAndroidComposeRule<ComponentActivity>() val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test @Test
fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() { fun interfaceStyleOpensMenuBeforeChangingRenderer() {
val actions = mutableListOf<SettingsUiAction>()
composeRule.setContent { composeRule.setContent {
ABoxTheme { ABoxTheme {
SettingsScreen( SettingsScreen(
state = SettingsUiState( state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true),
preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), onAction = actions::add,
loaded = true,
),
onAction = {},
) )
} }
} }
composeRule.onNodeWithText("界面设置").assertIsDisplayed() composeRule.onNodeWithTag("settings-style-entry").performClick()
composeRule.onNodeWithText("渲染器").assertIsDisplayed()
composeRule.onNodeWithText("Material 3").assertIsSelected() assertTrue(actions.isEmpty())
composeRule.onNodeWithText("跟随系统").assertIsSelected() composeRule.onNodeWithTag("settings-style-menu").assertIsDisplayed()
composeRule.onNodeWithText("动态取色").assertIsDisplayed() composeRule.onNodeWithText("Miuix").performClick()
composeRule.onNodeWithText("系统辅助功能").assertIsDisplayed() assertEquals(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX), actions.single())
composeRule.onNodeWithTag("settings-dynamic-color")
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("settings-blur")
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("settings-haptics")
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
} }
@Test @Test
fun switchingRendererKeepsCurrentlyRenderedTestScreenPresent() { fun materialThemeShowsEveryMaterialOptionInReferenceOrder() {
showTheme(DesignRenderer.MATERIAL3)
assertVerticalOrder(
"settings-material-colors",
"settings-theme-mode",
"settings-color-style",
"settings-color-standard",
"settings-predictive-back",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-liquid-glass").assertCountEquals(0)
}
@Test
fun miuixThemeShowsEveryMiuixOptionInReferenceOrder() {
showTheme(DesignRenderer.MIUIX)
assertVerticalOrder(
"settings-theme-mode",
"settings-miuix-accent",
"settings-blur",
"settings-floating-navigation",
"settings-liquid-glass",
"settings-predictive-back",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-color-style").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-color-standard").assertCountEquals(0)
}
@Test
fun themeDetailRemainsOpenWhenRendererChangesFromHomeMenu() {
var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3)) var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3))
val experienceState = SettingsExperienceState()
composeRule.setContent { composeRule.setContent {
ABoxTheme(preferences = preferences) { ABoxTheme(preferences = preferences) {
Column { SettingsExperience(
Text("当前测试内容")
SettingsScreen(
state = SettingsUiState(preferences = preferences, loaded = true), state = SettingsUiState(preferences = preferences, loaded = true),
experienceState = experienceState,
onAction = { action -> onAction = { action ->
if (action is SettingsUiAction.SetRenderer) { if (action is SettingsUiAction.SetRenderer) preferences = preferences.copy(renderer = action.value)
preferences = preferences.copy(renderer = action.value)
}
}, },
modifier = Modifier.weight(1f), 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 = {},
) )
} }
} }
} }
composeRule.onNodeWithText("Miuix").performClick() private fun assertVerticalOrder(vararg tags: String) {
composeRule.waitForIdle() 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 })
composeRule.onNodeWithText("当前测试内容").assertIsDisplayed()
composeRule.onNodeWithText("Miuix").assertIsSelected()
} }
} }
@@ -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,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()
}
+2
View File
@@ -1,7 +1,9 @@
<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="true"
android:allowBackup="false" android:allowBackup="false"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="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,
) )
} }
@@ -5,12 +5,13 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect 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.saveable.rememberSaveable
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
@@ -24,12 +25,15 @@ import com.flagship.abox.manager.domain.Username
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.AuthenticatedNavigation import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
import com.flagship.abox.manager.ui.preferences.SettingsRoute
import com.flagship.abox.manager.ui.preferences.SettingsExperienceState
import com.flagship.abox.manager.ui.preferences.SettingsViewModel import com.flagship.abox.manager.ui.preferences.SettingsViewModel
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
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.AccountProviderBottomSheet import com.flagship.abox.manager.ui.shell.AccountProviderDialog
import com.flagship.abox.manager.ui.session.SessionUiAction
@Composable @Composable
fun ABoxManagerApp( fun ABoxManagerApp(
@@ -43,6 +47,11 @@ fun ABoxManagerApp(
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val actionError = actionState.errorMessage?.asString() 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()
@@ -74,42 +83,68 @@ 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 = actionError,
onLogout = sessionActionsViewModel::logout, onLogout = sessionActionsViewModel::logout,
onSessionAction = sessionActionsViewModel::dispatch,
showAccountDialog = showAccountDialog,
showSettings = showSettings,
settingsExperienceState = settingsExperienceState,
onShowAccountDialogChange = { showAccountDialog = it },
onShowSettingsChange = { showSettings = it },
) )
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
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,
) { ) {
var showAccountSheet by remember { mutableStateOf(false) } if (showSettings) {
BackHandler { onShowSettingsChange(false) }
SettingsRoute(
experienceState = settingsExperienceState,
onExit = { onShowSettingsChange(false) },
)
} else {
AuthenticatedNavigation( AuthenticatedNavigation(
username = username, username = username,
profile = profile, profile = profile,
onLogout = onLogout, onLogout = onLogout,
onProviderClick = { showAccountSheet = true }, onProviderClick = { onShowAccountDialogChange(true) },
onAvatarClick = { showAccountSheet = true }, onAvatarClick = { onShowAccountDialogChange(true) },
) )
}
if (showAccountSheet) { if (showAccountDialog) {
AccountProviderBottomSheet( AccountProviderDialog(
userName = username.value, userName = username.value,
providerName = profile.displayName, providerName = profile.displayName,
onDismissRequest = { showAccountSheet = false }, onDismissRequest = { onShowAccountDialogChange(false) },
onLogout = onLogout, onOpenSettings = {
actionInProgress = actionInProgress, onShowAccountDialogChange(false)
errorMessage = errorMessage, onShowSettingsChange(true)
},
onSwitchProvider = {
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.SwitchProvider)
},
onEndSession = {
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.EndSession)
},
onForgetCredentials = {
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.ForgetSavedCredentials)
},
) )
} }
} }
@@ -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
}
@@ -2,11 +2,14 @@ package com.flagship.abox.manager.ui.navigation
import android.net.Uri import android.net.Uri
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import android.os.Build
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -14,11 +17,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@@ -40,8 +46,10 @@ import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute
import com.flagship.abox.manager.automation.ui.activity.ActivityRoute
import com.flagship.abox.manager.ui.scene.SceneListScreen 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.SceneRoutes
import com.flagship.abox.manager.ui.scene.sceneEditorDestination import com.flagship.abox.manager.ui.scene.sceneEditorDestination
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
@@ -52,6 +60,9 @@ import com.flagship.abox.manager.ui.shell.MainAppShell
import com.flagship.abox.manager.ui.shell.MainTab import com.flagship.abox.manager.ui.shell.MainTab
import com.flagship.abox.manager.ui.shell.detailPresentation import com.flagship.abox.manager.ui.shell.detailPresentation
import com.flagship.abox.manager.ui.shell.widthClass import com.flagship.abox.manager.ui.shell.widthClass
import com.flagship.abox.manager.designsystem.LocalABoxPredictiveBackEnabled
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.collect
import com.flagship.abox.manager.ui.socket.SocketScreen import com.flagship.abox.manager.ui.socket.SocketScreen
import com.flagship.abox.manager.ui.socket.SocketUiEvent import com.flagship.abox.manager.ui.socket.SocketUiEvent
import com.flagship.abox.manager.ui.socket.SocketViewModel import com.flagship.abox.manager.ui.socket.SocketViewModel
@@ -183,7 +194,21 @@ fun AuthenticatedNavigationStateHost(
selectedDevice = selectedDevice, selectedDevice = selectedDevice,
detailPresentation = selectedPresentation, detailPresentation = selectedPresentation,
) )
val canNavigateBack = selectedDevice != null || currentRoute != MainTab.DEVICES.route
val predictiveBackEnabled = LocalABoxPredictiveBackEnabled.current &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
var backProgress by remember { mutableFloatStateOf(0f) }
Box(
modifier = Modifier.fillMaxSize()
.clip(RoundedCornerShape((28f * backProgress).dp))
.graphicsLayer {
val scale = 1f - 0.04f * backProgress
scaleX = scale
scaleY = scale
translationX = 28.dp.toPx() * backProgress
},
) {
content( content(
snapshot, snapshot,
{ tab -> { tab ->
@@ -205,12 +230,23 @@ fun AuthenticatedNavigationStateHost(
}, },
{ savedSelection = null }, { savedSelection = null },
) )
BackHandler(enabled = selectedDevice != null || navController.previousBackStackEntry != null) {
savedSelection = null
if (currentRoute != MainTab.DEVICES.route) {
navController.popBackStack()
} }
PredictiveBackHandler(enabled = canNavigateBack && predictiveBackEnabled) { events ->
try {
events.collect { event -> backProgress = event.progress }
savedSelection = null
navController.popCurrentDestinationUnlessRoot()
} catch (cancelled: CancellationException) {
throw cancelled
} finally {
backProgress = 0f
}
}
BackHandler(enabled = canNavigateBack && !predictiveBackEnabled) {
savedSelection = null
navController.popCurrentDestinationUnlessRoot()
} }
} }
@@ -221,6 +257,7 @@ fun AuthenticatedNavigation(
onLogout: () -> Unit, onLogout: () -> Unit,
onProviderClick: () -> Unit, onProviderClick: () -> Unit,
onAvatarClick: () -> Unit, onAvatarClick: () -> Unit,
onOpenSettings: () -> Unit = {},
windowSizeClass: ABoxWindowSizeClass? = null, windowSizeClass: ABoxWindowSizeClass? = null,
) { ) {
val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp) val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp)
@@ -237,33 +274,28 @@ fun AuthenticatedNavigation(
profile.id, profile.id,
selection.deviceName, selection.deviceName,
) )
DeviceType.INFRARED -> "${Routes.INFRARED_HUB}/${Uri.encode(profile.id.value)}" DeviceType.INFRARED -> Routes.infraredHub(profile.id.value, selection.deviceName.value)
} }
}, },
selectionForRoute = { route -> profile.selectionForRoute(route) }, selectionForRoute = { route -> profile.selectionForRoute(route) },
) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection -> ) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection ->
val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES
MainAppShell(
currentTab = currentTab,
windowSizeClass = resolvedWindowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
content = { paddingValues ->
AuthenticatedGraphContent( AuthenticatedGraphContent(
navController = navController, navController = navController,
profile = profile, profile = profile,
paddingValues = paddingValues, username = username,
currentTab = currentTab,
windowSizeClass = resolvedWindowSizeClass,
isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE && isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
snapshot.currentRoute == MainTab.DEVICES.route, snapshot.currentRoute == MainTab.DEVICES.route,
selectedDevice = snapshot.selectedDevice, selectedDevice = snapshot.selectedDevice,
onTabClick = onTabClick,
onSelectDevice = onSelectDevice, onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection, onClearDeviceSelection = onClearDeviceSelection,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
onLogout = onLogout, onLogout = onLogout,
) onOpenSettings = onOpenSettings,
},
) )
} }
} }
@@ -273,45 +305,36 @@ fun AuthenticatedNavigation(
private fun AuthenticatedGraphContent( private fun AuthenticatedGraphContent(
navController: NavHostController, navController: NavHostController,
profile: ProviderProfile, profile: ProviderProfile,
paddingValues: PaddingValues, username: Username,
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
isExpandedDevicePane: Boolean, isExpandedDevicePane: Boolean,
selectedDevice: DeviceSelection?, selectedDevice: DeviceSelection?,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit, onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit, onClearDeviceSelection: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) { ) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (isExpandedDevicePane && selectedDevice != null) {
Row(modifier = Modifier.fillMaxSize()) {
AuthenticatedNavHost(
modifier = Modifier.weight(1f),
navController = navController,
profile = profile,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout,
)
DeviceDetailPane(
modifier = Modifier.weight(1f),
selection = selectedDevice,
onBack = onClearDeviceSelection,
)
}
} else {
AuthenticatedNavHost( AuthenticatedNavHost(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
navController = navController, navController = navController,
profile = profile, profile = profile,
username = username,
currentTab = currentTab,
windowSizeClass = windowSizeClass,
isExpandedDevicePane = isExpandedDevicePane,
selectedDevice = selectedDevice,
onTabClick = onTabClick,
onSelectDevice = onSelectDevice, onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection, onClearDeviceSelection = onClearDeviceSelection,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
onLogout = onLogout, onLogout = onLogout,
onOpenSettings = onOpenSettings,
) )
}
}
} }
@Composable @Composable
@@ -319,9 +342,18 @@ private fun AuthenticatedNavHost(
modifier: Modifier, modifier: Modifier,
navController: NavHostController, navController: NavHostController,
profile: ProviderProfile, profile: ProviderProfile,
username: Username,
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
isExpandedDevicePane: Boolean,
selectedDevice: DeviceSelection?,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit, onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit, onClearDeviceSelection: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
onLogout: () -> Unit, onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) { ) {
NavHost( NavHost(
navController = navController, navController = navController,
@@ -329,9 +361,42 @@ private fun AuthenticatedNavHost(
modifier = modifier, modifier = modifier,
) { ) {
composable(MainTab.DEVICES.route) { composable(MainTab.DEVICES.route) {
DeviceRoot(onSelectDevice = onSelectDevice) 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) { 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( SceneListScreen(
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
@@ -339,8 +404,25 @@ private fun AuthenticatedNavHost(
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
) )
} }
}
}
composable(MainTab.ACTIVITY.route) { composable(MainTab.ACTIVITY.route) {
ActivityDestination() 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( deviceDetailGraph(
onBackClick = { onBackClick = {
@@ -354,9 +436,12 @@ private fun AuthenticatedNavHost(
) )
sceneExecutionDestination(onBack = navController::popBackStack) sceneExecutionDestination(onBack = navController::popBackStack)
composable( composable(
route = "${Routes.INFRARED_HUB}/{profileId}", route = Routes.INFRARED_HUB,
arguments = listOf(navArgument("profileId") { type = NavType.StringType }), arguments = listOf(
) { navArgument("profileId") { type = NavType.StringType },
navArgument("deviceName") { type = NavType.StringType },
),
) { entry ->
InfraredHubRoute( InfraredHubRoute(
onBack = { onBack = {
onClearDeviceSelection() onClearDeviceSelection()
@@ -368,7 +453,8 @@ private fun AuthenticatedNavHost(
) )
}, },
onOpenSettings = { onOpenSettings = {
navController.navigate("${Routes.INFRARED_SETTINGS}/${Uri.encode(profile.id.value)}") val deviceName = entry.arguments?.getString("deviceName").orEmpty()
navController.navigate(Routes.infraredSettings(profile.id.value, deviceName))
}, },
onSessionInvalid = onLogout, onSessionInvalid = onLogout,
) )
@@ -386,8 +472,11 @@ private fun AuthenticatedNavHost(
) )
} }
composable( composable(
route = "${Routes.INFRARED_SETTINGS}/{profileId}", route = Routes.INFRARED_SETTINGS,
arguments = listOf(navArgument("profileId") { type = NavType.StringType }), arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("deviceName") { type = NavType.StringType },
),
) { ) {
InfraredControlRoute( InfraredControlRoute(
onBack = navController::popBackStack, onBack = navController::popBackStack,
@@ -397,21 +486,6 @@ private fun AuthenticatedNavHost(
} }
} }
@Composable
private fun DeviceRoot(
modifier: Modifier = Modifier,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
) {
DeviceListScreen(
modifier = modifier,
onSocketClick = { onSelectDevice(DeviceName("Zigbee插座控制器"), DeviceType.SOCKET) },
onTemperatureHumidityClick = {
onSelectDevice(DeviceName("温湿度传感器"), DeviceType.TEMPERATURE_HUMIDITY)
},
onInfraredClick = { onSelectDevice(DeviceName("红外遥控器"), DeviceType.INFRARED) },
)
}
@Composable @Composable
private fun DeviceDetailPane( private fun DeviceDetailPane(
selection: DeviceSelection, selection: DeviceSelection,
@@ -452,16 +526,6 @@ private fun DeviceDetailPane(
} }
} }
@Composable
private fun ActivityDestination() {
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text(
text = androidx.compose.ui.res.stringResource(R.string.activity_placeholder_title),
style = MaterialTheme.typography.headlineMedium,
)
}
}
private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? { private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? {
val parts = route?.split('/') ?: return null val parts = route?.split('/') ?: return null
return when { return when {
@@ -475,9 +539,9 @@ private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection?
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()), deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
type = DeviceType.TEMPERATURE_HUMIDITY, type = DeviceType.TEMPERATURE_HUMIDITY,
) )
parts.size >= 2 && parts[0] == Routes.INFRARED_HUB -> DeviceSelection( parts.size >= 3 && parts[0] == "infrared_hub" -> DeviceSelection(
profileId = id, profileId = id,
deviceName = DeviceName("红外遥控器"), deviceName = DeviceName(parts[2].decodeRoutePart()),
type = DeviceType.INFRARED, type = DeviceType.INFRARED,
) )
else -> null else -> null
@@ -497,3 +561,9 @@ private fun NavBackStackEntry.actualRoute(): String? {
} }
return route return route
} }
private fun NavHostController.popCurrentDestinationUnlessRoot() {
if (currentBackStackEntry?.actualRoute() != MainTab.DEVICES.route) {
popBackStack()
}
}
@@ -1,12 +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"
const val AUTHENTICATED = "authenticated" const val AUTHENTICATED = "authenticated"
const val SETTINGS = "settings"
const val INFRARED_HUB = "infrared_hub" const val INFRARED_HUB = "infrared_hub/{profileId}/{deviceName}"
const val INFRARED_PANEL = "infrared_panel" const val INFRARED_PANEL = "infrared_panel"
const val INFRARED_SETTINGS = "infrared_settings" const val INFRARED_SETTINGS = "infrared_settings/{profileId}/{deviceName}"
fun infraredHub(profileId: String, deviceName: String): String =
"infrared_hub/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
fun infraredSettings(profileId: String, deviceName: String): String =
"infrared_settings/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
} }
@@ -1,227 +1,573 @@
package com.flagship.abox.manager.ui.preferences package com.flagship.abox.manager.ui.preferences
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import android.os.Build
import androidx.compose.runtime.Composable 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.Alignment
import androidx.compose.ui.Modifier 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.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.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.runtime.saveable.Saver
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxCard
import com.flagship.abox.manager.designsystem.ABoxErrorState import com.flagship.abox.manager.designsystem.ABoxErrorState
import com.flagship.abox.manager.designsystem.ABoxFilterChip import com.flagship.abox.manager.designsystem.LocalABoxShapes
import com.flagship.abox.manager.designsystem.ABoxPreferenceGroup import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.ABoxSwitch import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.preferences.AppThemeMode import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer 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.NavigationPresentation
private enum class SettingsDestination { HOME, THEME }
@Stable
class SettingsExperienceState(initialDestination: String = SettingsDestination.HOME.name) {
var destination: String by mutableStateOf(initialDestination)
companion object {
val Saver: Saver<SettingsExperienceState, String> = Saver(
save = { it.destination },
restore = { SettingsExperienceState(it) },
)
}
}
@Composable @Composable
fun SettingsRoute( fun SettingsRoute(
experienceState: SettingsExperienceState = SettingsExperienceState(),
onExit: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel(), viewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value val state = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsScreen(state = state, onAction = viewModel::onAction) SettingsExperience(state, experienceState, viewModel::onAction, onExit)
}
@Composable
fun SettingsExperience(
state: SettingsUiState,
experienceState: SettingsExperienceState,
onAction: (SettingsUiAction) -> Unit,
onExit: () -> Unit,
) {
val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) }
.getOrDefault(SettingsDestination.HOME)
BackHandler {
if (destination == SettingsDestination.THEME) experienceState.destination = SettingsDestination.HOME.name
else onExit()
}
when (destination) {
SettingsDestination.HOME -> SettingsHomeScreen(
state = state,
onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name },
onBack = onExit,
onAction = onAction,
)
SettingsDestination.THEME -> ThemeSettingsScreen(
state = state,
onBack = { experienceState.destination = SettingsDestination.HOME.name },
onAction = onAction,
)
}
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
state: SettingsUiState, state: SettingsUiState,
onAction: (SettingsUiAction) -> Unit, onAction: (SettingsUiAction) -> Unit,
modifier: Modifier = Modifier, 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,
) { ) {
Scaffold( var styleMenuExpanded by remember { mutableStateOf(false) }
modifier = modifier.fillMaxSize(), SettingsPage(modifier = modifier, title = stringResource(R.string.settings_title), onBack = onBack) {
topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) }, SettingsGroup {
) { contentPadding -> Box(Modifier.fillMaxWidth()) {
Column( SettingsLinkRow(
modifier = Modifier icon = ABoxIconKey.SETTINGS,
.fillMaxSize() title = stringResource(R.string.settings_style_entry),
.padding(contentPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ABoxPreferenceGroup(title = stringResource(R.string.settings_style_section)) {
PreferenceRow(
title = stringResource(R.string.settings_renderer_label),
description = stringResource(R.string.settings_renderer_description), 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"),
) { ) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { DesignRenderer.entries.forEach { renderer ->
ABoxFilterChip( DropdownMenuItem(
selected = state.preferences.renderer == DesignRenderer.MATERIAL3, text = { Text(rendererLabel(renderer)) },
label = stringResource(R.string.settings_renderer_material3), trailingIcon = {
onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MATERIAL3)) }, if (renderer == state.preferences.renderer) Icon(Icons.Outlined.Check, null)
) },
ABoxFilterChip( onClick = {
selected = state.preferences.renderer == DesignRenderer.MIUIX, styleMenuExpanded = false
label = stringResource(R.string.settings_renderer_miuix), onAction(SettingsUiAction.SetRenderer(renderer))
onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) }, },
) )
} }
} }
} }
ABoxPreferenceGroup(title = stringResource(R.string.settings_theme_section)) {
PreferenceRow(
title = stringResource(R.string.settings_theme_label),
description = null,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeChip(AppThemeMode.SYSTEM, state, onAction, R.string.settings_theme_system)
ThemeChip(AppThemeMode.LIGHT, state, onAction, R.string.settings_theme_light)
ThemeChip(AppThemeMode.DARK, state, onAction, R.string.settings_theme_dark)
} }
} GroupDivider()
SwitchPreference( SettingsLinkRow(
title = stringResource(R.string.settings_dynamic_color), icon = ABoxIconKey.THEME,
description = stringResource(R.string.settings_dynamic_color_description), title = stringResource(R.string.settings_theme_entry),
checked = state.preferences.dynamicColorEnabled, description = stringResource(R.string.settings_theme_entry_description),
modifier = Modifier.testTag("settings-dynamic-color"), value = null,
onCheckedChange = { onAction(SettingsUiAction.SetDynamicColor(it)) }, modifier = Modifier.testTag("settings-theme-entry"),
onClick = onOpenTheme,
) )
} }
ABoxPreferenceGroup(title = stringResource(R.string.settings_surface_section)) { SettingsGroup {
SwitchPreference( SettingsSwitchRow(
title = stringResource(R.string.settings_blur), icon = ABoxIconKey.FEEDBACK,
description = stringResource(R.string.settings_blur_description),
checked = state.preferences.blurEnabled,
modifier = Modifier.testTag("settings-blur"),
onCheckedChange = { onAction(SettingsUiAction.SetBlur(it)) },
)
PreferenceRow(
title = stringResource(R.string.settings_navigation),
description = null,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
NavigationChip(
presentation = NavigationPresentation.STANDARD,
selected = state.preferences.navigationPresentation == NavigationPresentation.STANDARD,
label = stringResource(R.string.settings_navigation_standard),
onAction = onAction,
)
NavigationChip(
presentation = NavigationPresentation.FLOATING,
selected = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
label = stringResource(R.string.settings_navigation_floating),
onAction = onAction,
)
}
}
}
ABoxPreferenceGroup(title = stringResource(R.string.settings_feedback_section)) {
SwitchPreference(
title = stringResource(R.string.settings_haptics), title = stringResource(R.string.settings_haptics),
description = stringResource(R.string.settings_haptics_description), description = stringResource(R.string.settings_haptics_description),
checked = state.preferences.hapticsEnabled, checked = state.preferences.hapticsEnabled,
modifier = Modifier.testTag("settings-haptics"), tag = "settings-haptics",
onCheckedChange = { onAction(SettingsUiAction.SetHaptics(it)) }, ) { onAction(SettingsUiAction.SetHaptics(it)) }
)
} }
ABoxPreferenceGroup(title = stringResource(R.string.settings_accessibility_section)) { SettingsGroup {
ABoxCard( SettingsInfoRow(
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_accessibility_card_title), title = stringResource(R.string.settings_accessibility_card_title),
subtitle = stringResource(R.string.settings_accessibility_description), description = stringResource(R.string.settings_accessibility_description),
) )
} }
SettingsError(state)
}
}
state.error?.let { error -> @Composable
ABoxErrorState( private fun ThemeSettingsScreen(
title = stringResource(R.string.settings_error_save), state: SettingsUiState,
description = error.asString(), 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) {
SettingsGroup(modifier = Modifier.testTag("settings-material-colors")) {
SettingsSwitchRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_dynamic_color),
description = stringResource(R.string.settings_dynamic_color_description),
checked = state.preferences.dynamicColorEnabled,
tag = "settings-dynamic-color",
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
GroupDivider()
AccentSelector(state, onAction)
}
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
EnumSettingRow(
modifier = Modifier.testTag("settings-color-style"),
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_color_style),
value = colorStyleLabel(state.preferences.colorStyle),
values = ColorStyle.entries,
label = { colorStyleLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) },
)
EnumSettingRow(
modifier = Modifier.testTag("settings-color-standard"),
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_color_standard),
value = colorStandardLabel(state.preferences.colorStandard),
values = ColorStandard.entries,
label = { colorStandardLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) },
)
PredictiveBackSetting(state, onAction)
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@Composable
private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val renderEffectsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
val floatingEnabled = state.preferences.navigationPresentation == NavigationPresentation.FLOATING
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
SettingsGroup(modifier = Modifier.testTag("settings-miuix-accent")) {
SettingsSwitchRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_dynamic_color),
description = stringResource(R.string.settings_dynamic_color_description),
checked = state.preferences.dynamicColorEnabled,
tag = "settings-dynamic-color",
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
GroupDivider()
AccentSelector(state, onAction)
}
SettingsGroup(modifier = Modifier.testTag("settings-blur")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_blur),
description = stringResource(R.string.settings_blur_description),
checked = state.preferences.blurEnabled,
tag = "settings-blur-switch",
enabled = renderEffectsSupported,
unavailableDescription = if (renderEffectsSupported) null else stringResource(R.string.settings_requires_android_13),
) { onAction(SettingsUiAction.SetBlur(it)) }
}
SettingsGroup(modifier = Modifier.testTag("settings-floating-navigation")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_navigation_floating),
description = stringResource(R.string.settings_navigation_floating_description),
checked = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
tag = "settings-floating-navigation-switch",
) { onAction(SettingsUiAction.SetNavigation(if (it) NavigationPresentation.FLOATING else NavigationPresentation.STANDARD)) }
}
SettingsGroup(modifier = Modifier.testTag("settings-liquid-glass")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_liquid_glass),
description = stringResource(R.string.settings_liquid_glass_description),
checked = state.preferences.liquidGlassEnabled,
tag = "settings-liquid-glass-switch",
enabled = renderEffectsSupported && floatingEnabled,
unavailableDescription = when {
!floatingEnabled -> stringResource(R.string.settings_requires_floating_navigation)
!renderEffectsSupported -> stringResource(R.string.settings_requires_android_13)
else -> null
},
) { onAction(SettingsUiAction.SetLiquidGlass(it)) }
}
PredictiveBackSetting(state, onAction)
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@Composable
private fun PredictiveBackSetting(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
SettingsGroup(modifier = Modifier.testTag("settings-predictive-back")) {
SettingsSwitchRow(
icon = ABoxIconKey.BACK,
title = stringResource(R.string.settings_predictive_back),
description = stringResource(R.string.settings_predictive_back_description),
checked = state.preferences.predictiveBackEnabled,
tag = "settings-predictive-back-switch",
enabled = supported,
unavailableDescription = if (supported) null else stringResource(R.string.settings_requires_android_13),
) { onAction(SettingsUiAction.SetPredictiveBack(it)) }
}
}
@Composable
private fun SettingsPage(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
Row(
modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.action_back)) }
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
) )
} }
}
@Composable
private fun SettingsGroup(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
color = MaterialTheme.colorScheme.surfaceContainer,
content = { Column(content = content) },
)
}
@Composable
private fun ThemePreview(state: SettingsUiState) {
Surface(
modifier = Modifier.fillMaxWidth().height(190.dp),
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.width(184.dp).height(142.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(22.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
Spacer(Modifier.width(8.dp))
Box(Modifier.height(10.dp).weight(1f).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape))
}
Box(Modifier.fillMaxWidth().height(58.dp).background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp)))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(3) { Box(Modifier.weight(1f).height(18.dp).background(if (it == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, CircleShape)) }
}
}
}
Text(
text = "${rendererLabel(state.preferences.renderer)} · ${state.preferences.interfaceScale.percent}%",
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
@Composable @Composable
private fun ThemeChip( private fun RendererSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
mode: AppThemeMode, 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, state: SettingsUiState,
onAction: (SettingsUiAction) -> Unit, onAction: (SettingsUiAction) -> Unit,
labelRes: Int, modifier: Modifier = Modifier,
) { ) {
ABoxFilterChip( Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
selected = state.preferences.themeMode == mode, AppThemeMode.entries.forEach { mode ->
label = stringResource(labelRes), val selected = state.preferences.themeMode == mode
onClick = { onAction(SettingsUiAction.SetThemeMode(mode)) }, Surface(
) modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetThemeMode(mode)) },
} shape = RoundedCornerShape(14.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
@Composable
private fun NavigationChip(
presentation: NavigationPresentation,
selected: Boolean,
label: String,
onAction: (SettingsUiAction) -> Unit,
) {
ABoxFilterChip(
selected = selected,
label = label,
onClick = { onAction(SettingsUiAction.SetNavigation(presentation)) },
)
}
@Composable
private fun PreferenceRow(
title: String,
description: String?,
content: @Composable () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text(text = title, style = MaterialTheme.typography.titleMedium) Text(themeLabel(mode), Modifier.padding(vertical = 14.dp), textAlign = TextAlign.Center, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
description?.let { }
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
content()
} }
} }
@Composable @Composable
private fun SwitchPreference( private fun <T> EnumSettingRow(
modifier: Modifier,
icon: ABoxIconKey,
title: String,
value: String,
values: List<T>,
label: @Composable (T) -> String,
onSelected: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
SettingsGroup(modifier = modifier) {
Box(Modifier.fillMaxWidth()) {
SettingsLinkRow(
icon = icon,
title = title,
description = stringResource(R.string.settings_tap_to_choose),
value = value,
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
values.forEach { option ->
val optionLabel = label(option)
DropdownMenuItem(
text = { Text(optionLabel) },
trailingIcon = { if (optionLabel == value) Icon(Icons.Outlined.Check, null) },
onClick = {
expanded = false
onSelected(option)
},
)
}
}
}
}
}
@Composable
private fun AccentSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.settings_accent_label), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth().alpha(if (state.preferences.dynamicColorEnabled) 0.42f else 1f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
AccentPreference.entries.forEach { accent ->
val color = accentColor(accent)
Box(
modifier = Modifier.size(44.dp).background(color, CircleShape)
.then(if (state.preferences.accentPreference == accent) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
.clickable(enabled = !state.preferences.dynamicColorEnabled) { onAction(SettingsUiAction.SetAccent(accent)) },
contentAlignment = Alignment.Center,
) {
if (state.preferences.accentPreference == accent) Icon(Icons.Outlined.Check, null, tint = Color.White)
}
}
}
}
}
@Composable
private fun ScaleSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
SettingsRowHeader(ABoxIconKey.SCALE, stringResource(R.string.settings_scale), stringResource(R.string.settings_scale_description))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
InterfaceScale.entries.forEach { scale ->
val selected = state.preferences.interfaceScale == scale
Surface(
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetInterfaceScale(scale)) },
shape = RoundedCornerShape(14.dp),
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
) { Text("${scale.percent}%", Modifier.padding(vertical = 12.dp), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) }
}
}
}
}
@Composable
private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, onClick: () -> Unit) {
Row(modifier.fillMaxWidth().clickable(onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
SettingsRowHeader(icon, title, description, Modifier.weight(1f))
if (value != null) Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Outlined.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun SettingsSwitchRow(
icon: ABoxIconKey,
title: String, title: String,
description: String, description: String,
checked: Boolean, checked: Boolean,
modifier: Modifier, tag: String,
onCheckedChange: (Boolean) -> Unit, enabled: Boolean = true,
unavailableDescription: String? = null,
onChange: (Boolean) -> Unit,
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), Modifier.fillMaxWidth().clickable(enabled = enabled) { onChange(!checked) }.padding(14.dp).alpha(if (enabled) 1f else 0.5f),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Column(modifier = Modifier.weight(1f)) { SettingsRowHeader(icon, title, unavailableDescription ?: description, Modifier.weight(1f))
Text(text = title, style = MaterialTheme.typography.titleMedium) Switch(checked, onCheckedChange = onChange, enabled = enabled, modifier = Modifier.testTag(tag))
Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
ABoxSwitch(
checked = checked,
onCheckedChange = onCheckedChange,
contentDescription = title,
modifier = modifier,
)
} }
} }
@Composable
private fun SettingsInfoRow(icon: ABoxIconKey, title: String, description: String) {
SettingsRowHeader(icon, title, description, Modifier.padding(16.dp))
}
@Composable
private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: String, modifier: Modifier = Modifier) {
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ABoxIcon(icon, title, Modifier.size(28.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable private fun GroupDivider() = Box(Modifier.fillMaxWidth().padding(start = 58.dp).height(1.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)))
@Composable private fun SettingsError(state: SettingsUiState) { state.error?.let { ABoxErrorState(stringResource(R.string.settings_error_save), it.asString()) } }
@Composable private fun rendererLabel(renderer: DesignRenderer) = stringResource(if (renderer == DesignRenderer.MATERIAL3) R.string.settings_renderer_primary else R.string.settings_renderer_secondary)
@Composable private fun themeLabel(mode: AppThemeMode) = stringResource(when (mode) { AppThemeMode.SYSTEM -> R.string.settings_theme_system; AppThemeMode.LIGHT -> R.string.settings_theme_light; AppThemeMode.DARK -> R.string.settings_theme_dark })
@Composable private fun colorStyleLabel(value: ColorStyle) = stringResource(when (value) { ColorStyle.TONAL_SPOT -> R.string.settings_color_style_tonal; ColorStyle.EXPRESSIVE -> R.string.settings_color_style_expressive; ColorStyle.FIDELITY -> R.string.settings_color_style_fidelity })
@Composable private fun colorStandardLabel(value: ColorStandard) = stringResource(when (value) { ColorStandard.SPEC_2021 -> R.string.settings_color_standard_2021; ColorStandard.SPEC_2025 -> R.string.settings_color_standard_2025 })
private fun accentColor(accent: AccentPreference) = when (accent) { AccentPreference.BLUE -> Color(0xFF4F6FA9); AccentPreference.GREEN -> Color(0xFF2F7D57); AccentPreference.AMBER -> Color(0xFFA66700); AccentPreference.ROSE -> Color(0xFFA94C68); AccentPreference.VIOLET -> Color(0xFF7659A8) }
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.preferences.AppThemeMode 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.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.NavigationPresentation
import com.flagship.abox.manager.preferences.PreferenceField import com.flagship.abox.manager.preferences.PreferenceField
import com.flagship.abox.manager.preferences.UiPreferences import com.flagship.abox.manager.preferences.UiPreferences
@@ -62,6 +66,30 @@ class SettingsViewModel @Inject constructor(
field = PreferenceField.HAPTICS, field = PreferenceField.HAPTICS,
transform = { it.copy(hapticsEnabled = action.value) }, 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) SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null)
} }
} }
@@ -118,5 +146,11 @@ sealed interface SettingsUiAction {
data class SetBlur(val value: Boolean) : SettingsUiAction data class SetBlur(val value: Boolean) : SettingsUiAction
data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction
data class SetHaptics(val value: Boolean) : 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 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? {
@@ -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.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.Settings,
label = stringResource(R.string.account_interface_settings),
description = stringResource(R.string.account_interface_settings_desc),
tag = "account_dialog_open_settings",
onClick = onOpenSettings,
)
AccountSheetAction(
icon = Icons.AutoMirrored.Outlined.Logout,
label = stringResource(R.string.account_logout),
description = stringResource(R.string.account_logout_desc),
tag = "account_dialog_end_session",
dangerous = true,
onClick = { confirmLogout = true },
)
Spacer(Modifier.height(28.dp))
}
}
if (confirmLogout) {
AlertDialog(
onDismissRequest = { 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)
}
}
}
}
@@ -25,6 +25,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
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.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -35,6 +36,10 @@ import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxNavigationBar import com.flagship.abox.manager.designsystem.ABoxNavigationBar
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
import com.flagship.abox.manager.designsystem.ABoxUserAvatar import com.flagship.abox.manager.designsystem.ABoxUserAvatar
import com.flagship.abox.manager.designsystem.LocalABoxNavigationPresentation
import com.flagship.abox.manager.designsystem.LocalABoxRenderer
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.NavigationPresentation
/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */ /** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
@Composable @Composable
@@ -46,13 +51,18 @@ fun MainAppShell(
onTabClick: (MainTab) -> Unit, onTabClick: (MainTab) -> Unit,
onProviderClick: () -> Unit, onProviderClick: () -> Unit,
onAvatarClick: () -> Unit, onAvatarClick: () -> Unit,
showNavigationChrome: Boolean = true,
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
) { ) {
val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
val renderer = LocalABoxRenderer.current
val floating = renderer == DesignRenderer.MIUIX &&
LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
if (showNavigationChrome) {
ProviderTopBar( ProviderTopBar(
currentTab = currentTab, currentTab = currentTab,
providerName = providerName, providerName = providerName,
@@ -60,14 +70,55 @@ fun MainAppShell(
onProviderClick = onProviderClick, onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick, onAvatarClick = onAvatarClick,
) )
}
}, },
bottomBar = { bottomBar = {
if (!useRail) { if (showNavigationChrome && !useRail) {
ABoxNavigationBar { 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 -> MainTab.entries.forEach { tab ->
val selected = tab == currentTab val selected = tab == currentTab
ABoxNavigationBarItem( ABoxNavigationBarItem(
selected = selected, selected = tab == currentTab,
onClick = { onTabClick(tab) }, onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"), modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = { icon = {
@@ -81,20 +132,18 @@ fun MainAppShell(
} }
} }
} }
}, } else {
) { innerPadding -> ABoxNavigationBar {
Row( MainTab.entries.forEach { tab ->
modifier = Modifier.fillMaxSize(), val selected = tab == currentTab
) { ABoxNavigationBarItem(
if (useRail) { selected = selected,
MainNavigationRail( onClick = { onTabClick(tab) },
currentTab = currentTab, modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
onTabClick = onTabClick, icon = { Icon(if (selected) tab.selectedIcon else tab.unselectedIcon, stringResource(tab.labelRes)) },
label = { Text(stringResource(tab.labelRes)) },
) )
} }
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
content(innerPadding)
}
} }
} }
} }
@@ -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 {
@@ -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">&gt; 高于</string> <string name="scene_chip_above">&gt; 高于</string>
<string name="scene_chip_below">&lt; 低于</string> <string name="scene_chip_below">&lt; 低于</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 3</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 3</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>
+144 -49
View File
@@ -82,9 +82,9 @@
<string name="scene_section_other">Other Options &amp; Appearance</string> <string name="scene_section_other">Other Options &amp; 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">&gt; Above</string> <string name="scene_chip_above">&gt; Above</string>
<string name="scene_chip_below">&lt; Below</string> <string name="scene_chip_below">&lt; 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>
@@ -261,30 +261,125 @@
<string name="error_unknown">An unexpected error occurred.</string> <string name="error_unknown">An unexpected error occurred.</string>
<!-- Interface settings --> <!-- Interface settings -->
<string name="settings_title">界面设置</string> <string name="settings_title">Interface settings</string>
<string name="settings_style_section">样式</string> <string name="settings_style_section">Style</string>
<string name="settings_renderer_label">渲染器</string> <string name="settings_renderer_label">Renderer</string>
<string name="settings_renderer_description">选择应用使用的界面渲染方式</string> <string name="settings_renderer_description">Choose the interface renderer used by the app</string>
<string name="settings_renderer_material3">Material 3</string> <string name="settings_renderer_material3">Material 3</string>
<string name="settings_renderer_miuix">Miuix</string> <string name="settings_renderer_miuix">Miuix</string>
<string name="settings_theme_section">主题</string> <string name="settings_theme_section">Theme</string>
<string name="settings_theme_label">主题模式</string> <string name="settings_theme_label">Theme mode</string>
<string name="settings_theme_system">跟随系统</string> <string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">浅色</string> <string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">深色</string> <string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">动态取色</string> <string name="settings_dynamic_color">Dynamic color</string>
<string name="settings_dynamic_color_description">使用系统壁纸颜色生成界面配色</string> <string name="settings_dynamic_color_description">Generate interface colors from the system wallpaper</string>
<string name="settings_surface_section">界面效果</string> <string name="settings_surface_section">Surface effects</string>
<string name="settings_blur">模糊效果</string> <string name="settings_blur">Blur effects</string>
<string name="settings_blur_description">在支持的界面中启用背景模糊</string> <string name="settings_blur_description">Enable background blur on supported screens</string>
<string name="settings_navigation">导航样式</string> <string name="settings_navigation">Navigation style</string>
<string name="settings_navigation_standard">标准导航栏</string> <string name="settings_navigation_standard">Standard navigation</string>
<string name="settings_navigation_floating">悬浮导航栏</string> <string name="settings_navigation_floating">Floating navigation</string>
<string name="settings_feedback_section">交互反馈</string> <string name="settings_feedback_section">Interaction feedback</string>
<string name="settings_haptics">触觉反馈</string> <string name="settings_haptics">Haptic feedback</string>
<string name="settings_haptics_description">操作时提供轻微震动反馈</string> <string name="settings_haptics_description">Provide subtle vibration feedback for actions</string>
<string name="settings_accessibility_section">系统辅助功能</string> <string name="settings_accessibility_section">System accessibility</string>
<string name="settings_accessibility_card_title">遵循系统辅助功能设置</string> <string name="settings_accessibility_card_title">Follow system accessibility settings</string>
<string name="settings_accessibility_description">动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。</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">无法保存界面设置,请重试</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 3</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">Interface settings</string>
<string name="account_interface_settings_desc">Theme, renderer and interaction preferences</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,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)
}
}
@@ -2,7 +2,11 @@ package com.flagship.abox.manager.ui.preferences
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.preferences.AppThemeMode 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.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.ui.common.UiText.StringResource
import com.flagship.abox.manager.preferences.NavigationPresentation import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.UiPreferences import com.flagship.abox.manager.preferences.UiPreferences
@@ -83,6 +87,39 @@ class SettingsViewModelTest {
assertEquals(repository.value, viewModel.uiState.value.preferences) 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( private class RecordingUiPreferencesRepository(
initial: UiPreferences = UiPreferences(), initial: UiPreferences = UiPreferences(),
@@ -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()
@@ -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)
}
} }
@@ -17,6 +17,7 @@ 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.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
@@ -476,13 +477,19 @@ fun ABoxDeviceDetailScaffold(
title: String, title: String,
onBack: () -> Unit, onBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
subtitle: String? = null,
icon: ABoxIconKey = ABoxIconKey.UNKNOWN_DEVICE,
action: (@Composable () -> Unit)? = null,
content: @Composable (PaddingValues) -> Unit, content: @Composable (PaddingValues) -> Unit,
) { ) {
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
topBar = { topBar = {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp), modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
ABoxIconButton( ABoxIconButton(
@@ -490,11 +497,38 @@ fun ABoxDeviceDetailScaffold(
contentDescription = "返回", contentDescription = "返回",
onClick = onBack, 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(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp), 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, content = content,
@@ -12,6 +12,8 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf 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.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.designsystem.renderer.ABoxRendererAdapters
import com.flagship.abox.manager.preferences.DesignRenderer import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens
@@ -20,6 +22,7 @@ import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver
import com.flagship.abox.manager.preferences.AppThemeMode import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.UiPreferences import com.flagship.abox.manager.preferences.UiPreferences
val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors } val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
@@ -29,6 +32,9 @@ val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shap
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 } val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
val LocalABoxReducedMotion = staticCompositionLocalOf { false } val LocalABoxReducedMotion = staticCompositionLocalOf { false }
val LocalABoxBlurEnabled = staticCompositionLocalOf { false } val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD }
val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false }
val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false }
@Composable @Composable
fun ABoxTheme( fun ABoxTheme(
@@ -78,8 +84,14 @@ internal fun ABoxThemeContent(
dynamicColorTokens = dynamicColorTokens, dynamicColorTokens = dynamicColorTokens,
) )
val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer) val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
val density = LocalDensity.current
val scaledDensity = Density(
density = density.density * (preferences.interfaceScale.percent / 100f),
fontScale = density.fontScale,
)
CompositionLocalProvider( CompositionLocalProvider(
LocalDensity provides scaledDensity,
LocalABoxColors provides resolution.colorTokens, LocalABoxColors provides resolution.colorTokens,
LocalABoxSpacing provides ABoxSpacing(), LocalABoxSpacing provides ABoxSpacing(),
LocalABoxTypography provides ABoxTypography, LocalABoxTypography provides ABoxTypography,
@@ -87,6 +99,9 @@ internal fun ABoxThemeContent(
LocalABoxRenderer provides resolution.renderer, LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides reducedMotion, LocalABoxReducedMotion provides reducedMotion,
LocalABoxBlurEnabled provides resolution.blurEnabled, LocalABoxBlurEnabled provides resolution.blurEnabled,
LocalABoxNavigationPresentation provides preferences.navigationPresentation,
LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled,
LocalABoxPredictiveBackEnabled provides preferences.predictiveBackEnabled,
) { ) {
renderer.Render( renderer.Render(
resolution = resolution, resolution = resolution,
@@ -11,6 +11,11 @@ import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.ErrorOutline import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.automirrored.outlined.HelpOutline import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History 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.Layers
import androidx.compose.material.icons.outlined.Power import androidx.compose.material.icons.outlined.Power
import androidx.compose.material.icons.outlined.PowerSettingsNew import androidx.compose.material.icons.outlined.PowerSettingsNew
@@ -51,6 +56,11 @@ enum class ABoxIconKey {
WARNING, WARNING,
ERROR, ERROR,
UNKNOWN_DEVICE, UNKNOWN_DEVICE,
SETTINGS,
THEME,
EFFECTS,
FEEDBACK,
SCALE,
} }
/** Design-system-owned device categories; this module does not depend on domain models. */ /** Design-system-owned device categories; this module does not depend on domain models. */
@@ -94,6 +104,11 @@ object ABoxIconMapper {
ABoxIconKey.WARNING -> Icons.Outlined.Warning ABoxIconKey.WARNING -> Icons.Outlined.Warning
ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline
ABoxIconKey.UNKNOWN_DEVICE -> Icons.AutoMirrored.Outlined.HelpOutline 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
} }
} }
@@ -6,10 +6,7 @@ import com.flagship.abox.manager.preferences.DesignRenderer
internal interface ABoxRendererAdapter { internal interface ABoxRendererAdapter {
@Composable @Composable
fun Render( fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit)
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
)
} }
internal object ABoxRendererAdapters { internal object ABoxRendererAdapters {
@@ -13,11 +13,11 @@ internal object MiuixRendererAdapter : ABoxRendererAdapter {
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme() val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme()
MiuixTheme( MiuixTheme(colors = colors) {
colors = colors, androidx.compose.material3.MaterialTheme(
) { colorScheme = com.flagship.abox.manager.designsystem.materialColorScheme(resolution),
Material3RendererAdapter.Render( typography = com.flagship.abox.manager.designsystem.ABoxTypography,
resolution = resolution, shapes = com.flagship.abox.manager.designsystem.ABoxShapes(resolution.shapeTokens),
content = content, content = content,
) )
} }
@@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color
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.preferences.AppThemeMode 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.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences import com.flagship.abox.manager.preferences.UiPreferences
@@ -121,7 +122,7 @@ object ABoxThemeResolver {
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) { val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
dynamicColorTokens dynamicColorTokens
} else { } else {
brandTokens brandTokens.withAccent(preferences.accentPreference, isDarkTheme)
} }
val shapeTokens = when (preferences.renderer) { val shapeTokens = when (preferences.renderer) {
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
@@ -137,3 +138,14 @@ object ABoxThemeResolver {
) )
} }
} }
private fun ABoxColorTokens.withAccent(accent: AccentPreference, dark: Boolean): ABoxColorTokens {
val selected = when (accent) {
AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF385F9D)
AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF247A4B)
AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFF8A5800)
AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA43C59)
AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF6D52A2)
}
return copy(primary = selected, deviceSocket = selected)
}
@@ -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)
}
}
@@ -0,0 +1,27 @@
package com.flagship.abox.manager.devicecatalog
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ProviderType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
class RoutingDeviceCatalog(
private val profileStore: ProviderProfileStore,
private val abox: DeviceCatalog,
private val local: DeviceCatalog,
) : DeviceCatalog {
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = flow {
val active = profileStore.activeProfile()
if (active?.id != profileId) {
emit(DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active")))
return@flow
}
emitAll(if (active.type == ProviderType.ABOX) abox.observeDevices(profileId) else local.observeDevices(profileId))
}
}
@@ -0,0 +1,22 @@
package com.flagship.abox.manager.devicecatalog.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface ConfiguredDeviceDao {
@Query("SELECT * FROM configured_devices WHERE profileId = :profileId ORDER BY deviceName")
fun observe(profileId: String): Flow<List<ConfiguredDeviceEntity>>
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insert(device: ConfiguredDeviceEntity)
@Query("DELETE FROM configured_devices WHERE profileId = :profileId AND deviceName = :deviceName")
suspend fun remove(profileId: String, deviceName: String): Int
@Query("UPDATE configured_devices SET id = :newId, deviceName = :newName WHERE profileId = :profileId AND deviceName = :oldName")
suspend fun rename(profileId: String, oldName: String, newName: String, newId: String): Int
}
@@ -0,0 +1,16 @@
package com.flagship.abox.manager.devicecatalog.data
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "configured_devices",
indices = [Index(value = ["profileId", "deviceName"], unique = true)],
)
data class ConfiguredDeviceEntity(
@PrimaryKey val id: String,
val profileId: String,
val deviceName: String,
val deviceType: String,
)
@@ -0,0 +1,74 @@
package com.flagship.abox.manager.devicecatalog.data
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
interface ConfiguredDeviceRepository {
fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>>
suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor>
suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit>
suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult<Unit> =
DomainResult.Failure(DomainError.InvalidRequest(detail = "Device rename is not supported"))
}
class RoomConfiguredDeviceRepository(
private val dao: ConfiguredDeviceDao,
) : ConfiguredDeviceRepository, DeviceCatalog {
override fun observe(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> =
dao.observe(profileId.value).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) }
override fun observeDevices(profileId: ProviderProfileId): Flow<DomainResult<List<DeviceDescriptor>>> = observe(profileId)
override suspend fun add(profileId: ProviderProfileId, name: DeviceName, type: DeviceType): DomainResult<DeviceDescriptor> {
if (profileId.value.isBlank() || name.value.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Profile and device name are required"))
}
val descriptor = DeviceDescriptor(DeviceId("${profileId.value}:${name.value}"), profileId, name, type)
return try {
dao.insert(descriptor.toEntity())
DomainResult.Success(descriptor)
} catch (error: Exception) {
DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name"))
}
}
override suspend fun remove(profileId: ProviderProfileId, deviceName: DeviceName): DomainResult<Unit> = try {
dao.remove(profileId.value, deviceName.value)
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = error.message ?: "Unable to remove device"))
}
override suspend fun rename(profileId: ProviderProfileId, oldName: DeviceName, newName: DeviceName): DomainResult<Unit> {
if (newName.value.isBlank()) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name is required"))
return try {
val changed = dao.rename(profileId.value, oldName.value, newName.value, "${profileId.value}:${newName.value}")
if (changed == 1) DomainResult.Success(Unit)
else DomainResult.Failure(DomainError.InvalidRequest(detail = "Device was not found"))
} catch (error: Exception) {
DomainResult.Failure(DomainError.InvalidRequest(detail = error.message ?: "Duplicate device name"))
}
}
}
private fun ConfiguredDeviceEntity.toDomain() = DeviceDescriptor(
id = DeviceId(id),
profileId = ProviderProfileId(profileId),
name = DeviceName(deviceName),
type = DeviceType.valueOf(deviceType),
)
private fun DeviceDescriptor.toEntity() = ConfiguredDeviceEntity(
id = id.value,
profileId = profileId.value,
deviceName = name.value,
deviceType = type.name,
)
@@ -0,0 +1,9 @@
package com.flagship.abox.manager.devicecatalog.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [ConfiguredDeviceEntity::class], version = 1, exportSchema = false)
abstract class DeviceCatalogDatabase : RoomDatabase() {
abstract fun configuredDeviceDao(): ConfiguredDeviceDao
}
@@ -0,0 +1,31 @@
package com.flagship.abox.manager.devicecatalog.di
import android.content.Context
import androidx.room.Room
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.devicecatalog.data.DeviceCatalogDatabase
import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object DeviceCatalogModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): DeviceCatalogDatabase =
Room.databaseBuilder(context, DeviceCatalogDatabase::class.java, "abox_devices.db").build()
@Provides
@Singleton
fun provideRepository(database: DeviceCatalogDatabase): RoomConfiguredDeviceRepository =
RoomConfiguredDeviceRepository(database.configuredDeviceDao())
@Provides
@Singleton
fun provideConfiguredDeviceRepository(repository: RoomConfiguredDeviceRepository): ConfiguredDeviceRepository = repository
}
@@ -64,6 +64,16 @@ class DataStoreUiPreferencesRepository internal constructor(
navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD) navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD)
?: NavigationPresentation.STANDARD, ?: NavigationPresentation.STANDARD,
hapticsEnabled = preferences[HAPTICS] ?: true, hapticsEnabled = preferences[HAPTICS] ?: true,
interfaceScale = preferences[INTERFACE_SCALE]?.enumOrDefault(InterfaceScale.STANDARD)
?: InterfaceScale.STANDARD,
accentPreference = preferences[ACCENT]?.enumOrDefault(AccentPreference.BLUE)
?: AccentPreference.BLUE,
colorStyle = preferences[COLOR_STYLE]?.enumOrDefault(ColorStyle.TONAL_SPOT)
?: ColorStyle.TONAL_SPOT,
colorStandard = preferences[COLOR_STANDARD]?.enumOrDefault(ColorStandard.SPEC_2021)
?: ColorStandard.SPEC_2021,
liquidGlassEnabled = preferences[LIQUID_GLASS] ?: false,
predictiveBackEnabled = preferences[PREDICTIVE_BACK] ?: false,
) )
private fun MutablePreferences.putAll(preferences: UiPreferences) { private fun MutablePreferences.putAll(preferences: UiPreferences) {
@@ -73,6 +83,12 @@ class DataStoreUiPreferencesRepository internal constructor(
this[BLUR] = preferences.blurEnabled this[BLUR] = preferences.blurEnabled
this[NAVIGATION] = preferences.navigationPresentation.name this[NAVIGATION] = preferences.navigationPresentation.name
this[HAPTICS] = preferences.hapticsEnabled this[HAPTICS] = preferences.hapticsEnabled
this[INTERFACE_SCALE] = preferences.interfaceScale.name
this[ACCENT] = preferences.accentPreference.name
this[COLOR_STYLE] = preferences.colorStyle.name
this[COLOR_STANDARD] = preferences.colorStandard.name
this[LIQUID_GLASS] = preferences.liquidGlassEnabled
this[PREDICTIVE_BACK] = preferences.predictiveBackEnabled
} }
private inline fun <reified T : Enum<T>> String.enumOrDefault(default: T): T = private inline fun <reified T : Enum<T>> String.enumOrDefault(default: T): T =
@@ -89,5 +105,11 @@ class DataStoreUiPreferencesRepository internal constructor(
val BLUR = booleanPreferencesKey("blur") val BLUR = booleanPreferencesKey("blur")
val NAVIGATION = stringPreferencesKey("navigation") val NAVIGATION = stringPreferencesKey("navigation")
val HAPTICS = booleanPreferencesKey("haptics") val HAPTICS = booleanPreferencesKey("haptics")
val INTERFACE_SCALE = stringPreferencesKey("interface_scale")
val ACCENT = stringPreferencesKey("accent")
val COLOR_STYLE = stringPreferencesKey("color_style")
val COLOR_STANDARD = stringPreferencesKey("color_standard")
val LIQUID_GLASS = booleanPreferencesKey("liquid_glass")
val PREDICTIVE_BACK = booleanPreferencesKey("predictive_back")
} }
} }
@@ -16,6 +16,31 @@ enum class NavigationPresentation {
FLOATING, FLOATING,
} }
enum class InterfaceScale(val percent: Int) {
COMPACT(85),
STANDARD(100),
LARGE(115),
}
enum class AccentPreference {
BLUE,
GREEN,
AMBER,
ROSE,
VIOLET,
}
enum class ColorStyle {
TONAL_SPOT,
EXPRESSIVE,
FIDELITY,
}
enum class ColorStandard {
SPEC_2021,
SPEC_2025,
}
enum class PreferenceField { enum class PreferenceField {
RENDERER, RENDERER,
THEME_MODE, THEME_MODE,
@@ -23,6 +48,12 @@ enum class PreferenceField {
BLUR, BLUR,
NAVIGATION, NAVIGATION,
HAPTICS, HAPTICS,
INTERFACE_SCALE,
ACCENT,
COLOR_STYLE,
COLOR_STANDARD,
LIQUID_GLASS,
PREDICTIVE_BACK,
} }
data class UiPreferences( data class UiPreferences(
@@ -32,4 +63,10 @@ data class UiPreferences(
val blurEnabled: Boolean = false, val blurEnabled: Boolean = false,
val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD, val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD,
val hapticsEnabled: Boolean = true, val hapticsEnabled: Boolean = true,
val interfaceScale: InterfaceScale = InterfaceScale.STANDARD,
val accentPreference: AccentPreference = AccentPreference.BLUE,
val colorStyle: ColorStyle = ColorStyle.TONAL_SPOT,
val colorStandard: ColorStandard = ColorStandard.SPEC_2021,
val liquidGlassEnabled: Boolean = false,
val predictiveBackEnabled: Boolean = false,
) )
@@ -45,6 +45,24 @@ class DataStoreUiPreferencesRepositoryTest {
assertEquals(UiPreferencesWriteResult.Success, repository.update { assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(hapticsEnabled = false) it.copy(hapticsEnabled = false)
}) })
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(interfaceScale = InterfaceScale.LARGE)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(accentPreference = AccentPreference.GREEN)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(colorStyle = ColorStyle.EXPRESSIVE)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(colorStandard = ColorStandard.SPEC_2025)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(liquidGlassEnabled = true)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(predictiveBackEnabled = false)
})
assertEquals( assertEquals(
UiPreferences( UiPreferences(
@@ -54,6 +72,12 @@ class DataStoreUiPreferencesRepositoryTest {
blurEnabled = true, blurEnabled = true,
navigationPresentation = NavigationPresentation.FLOATING, navigationPresentation = NavigationPresentation.FLOATING,
hapticsEnabled = false, hapticsEnabled = false,
interfaceScale = InterfaceScale.LARGE,
accentPreference = AccentPreference.GREEN,
colorStyle = ColorStyle.EXPRESSIVE,
colorStandard = ColorStandard.SPEC_2025,
liquidGlassEnabled = true,
predictiveBackEnabled = false,
), ),
repository.observe().first(), repository.observe().first(),
) )
@@ -0,0 +1,55 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager.automation"
compileSdk = 37
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
kapt {
correctErrorTypes = true
}
dependencies {
api(project(":domain"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
kapt(libs.room.compiler)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.hilt.navigation.compose)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
implementation(libs.kotlinx.coroutines.core)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.room.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,83 @@
package com.flagship.abox.manager.automation
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.automation.data.ActivityEventEntity
import com.flagship.abox.manager.automation.data.AutomationDatabase
import com.flagship.abox.manager.automation.data.RoomActivityRepository
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityEventId
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTriggerId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AutomationDatabaseTest {
private lateinit var database: AutomationDatabase
private lateinit var activityRepository: RoomActivityRepository
private lateinit var runtimeRepository: RoomAutomationRuntimeRepository
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AutomationDatabase::class.java).build()
activityRepository = RoomActivityRepository(database.automationDao())
runtimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
}
@After
fun tearDown() = database.close()
@Test
fun activityEventsAreIsolatedByProfileAndPageByCursor() = runTest {
activityRepository.append(event("profile-a", "a", 10L))
activityRepository.append(event("profile-a", "b", 10L))
activityRepository.append(event("profile-b", "c", 10L))
val first = activityRepository.observeLatest(ProviderProfileId("profile-a"), 1).first()
val firstEvent = (first as DomainResult.Success).value.single()
val next = activityRepository.loadBefore(
ProviderProfileId("profile-a"),
ActivityEventCursor(firstEvent.occurredAtEpochMillis, firstEvent.id),
1,
)
assertEquals("b", firstEvent.id.value)
assertEquals(listOf("a"), (next as DomainResult.Success).value.map { it.id.value })
}
@Test
fun occurrenceClaimIsAtomic() = runTest {
val first = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
val second = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
assertTrue((first as DomainResult.Success).value)
assertEquals(false, (second as DomainResult.Success).value)
}
private fun event(profile: String, id: String, time: Long) = ActivityEvent(
id = ActivityEventId(id),
profileId = ProviderProfileId(profile),
occurredAtEpochMillis = time,
kind = ActivityEventKind.SOCKET_COMMAND,
source = ActivitySource.USER,
outcome = ActivityOutcome.SUCCEEDED,
target = ActivityTarget(ActivityTargetType.DEVICE, id, "设备"),
)
}
@@ -0,0 +1,26 @@
package com.flagship.abox.manager.automation.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface AutomationDao {
@Query("SELECT * FROM activity_events WHERE profileId = :profileId ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit")
fun observeLatest(profileId: String, limit: Int): Flow<List<ActivityEventEntity>>
@Query(
"SELECT * FROM activity_events WHERE profileId = :profileId AND " +
"(occurredAtEpochMillis < :beforeTime OR (occurredAtEpochMillis = :beforeTime AND id < :beforeId)) " +
"ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit",
)
suspend fun loadBefore(profileId: String, beforeTime: Long, beforeId: String, limit: Int): List<ActivityEventEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertEvent(event: ActivityEventEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long
}
@@ -0,0 +1,9 @@
package com.flagship.abox.manager.automation.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false)
abstract class AutomationDatabase : RoomDatabase() {
abstract fun automationDao(): AutomationDao
}
@@ -0,0 +1,33 @@
package com.flagship.abox.manager.automation.data
import androidx.room.Entity
import androidx.room.Index
@Entity(
tableName = "activity_events",
primaryKeys = ["profileId", "id"],
indices = [Index(value = ["profileId", "occurredAtEpochMillis", "id"])],
)
data class ActivityEventEntity(
val profileId: String,
val id: String,
val occurredAtEpochMillis: Long,
val kind: String,
val source: String,
val outcome: String,
val targetType: String,
val targetStableId: String,
val targetDisplayName: String,
val errorCode: String?,
)
@Entity(
tableName = "automation_occurrences",
primaryKeys = ["profileId", "sceneId", "triggerId", "occurrenceKey"],
)
data class AutomationOccurrenceEntity(
val profileId: String,
val sceneId: String,
val triggerId: String,
val occurrenceKey: String,
)
@@ -0,0 +1,70 @@
package com.flagship.abox.manager.automation.data
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityEventId
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RoomActivityRepository(private val dao: AutomationDao) : ActivityRepository {
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> =
dao.observeLatest(profileId.value, limit.coerceAtLeast(0)).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) }
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int): DomainResult<List<ActivityEvent>> = try {
DomainResult.Success(dao.loadBefore(profileId.value, before.occurredAtEpochMillis, before.id.value, limit.coerceAtLeast(0)).map { it.toDomain() })
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
}
override suspend fun append(event: ActivityEvent): DomainResult<Unit> = try {
dao.insertEvent(event.toEntity())
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
}
}
private fun ActivityEventEntity.toDomain() = ActivityEvent(
id = ActivityEventId(id),
profileId = ProviderProfileId(profileId),
occurredAtEpochMillis = occurredAtEpochMillis,
kind = kind.toActivityEventKind(),
source = source.toActivitySource(),
outcome = outcome.toActivityOutcome(),
target = ActivityTarget(targetType.toActivityTargetType(), targetStableId, targetDisplayName),
errorCode = errorCode,
)
private fun String.toActivityEventKind(): ActivityEventKind =
runCatching { ActivityEventKind.valueOf(this) }.getOrDefault(ActivityEventKind.SCENE_ACTION)
private fun String.toActivitySource(): ActivitySource =
runCatching { ActivitySource.valueOf(this) }.getOrDefault(ActivitySource.SYSTEM)
private fun String.toActivityOutcome(): ActivityOutcome =
runCatching { ActivityOutcome.valueOf(this) }.getOrDefault(ActivityOutcome.FAILED)
private fun String.toActivityTargetType(): ActivityTargetType =
runCatching { ActivityTargetType.valueOf(this) }.getOrDefault(ActivityTargetType.SCENE_ACTION)
private fun ActivityEvent.toEntity() = ActivityEventEntity(
profileId = profileId.value,
id = id.value,
occurredAtEpochMillis = occurredAtEpochMillis,
kind = kind.name,
source = source.name,
outcome = outcome.name,
targetType = target.type.name,
targetStableId = target.stableId,
targetDisplayName = target.displayName,
errorCode = errorCode,
)
@@ -0,0 +1,29 @@
package com.flagship.abox.manager.automation.data
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTriggerId
data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean)
interface AutomationRuntimeRepository {
suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult<Boolean>
}
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
override suspend fun claimOccurrence(
profileId: ProviderProfileId,
sceneId: SceneId,
triggerId: SceneTriggerId,
occurrenceKey: String,
): DomainResult<Boolean> = try {
val inserted = dao.claimOccurrence(
AutomationOccurrenceEntity(profileId.value, sceneId.value, triggerId.value, occurrenceKey),
)
DomainResult.Success(inserted != -1L)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
}
}
@@ -0,0 +1,48 @@
package com.flagship.abox.manager.automation.di
import android.content.Context
import androidx.room.Room
import com.flagship.abox.manager.automation.data.AutomationDatabase
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
import com.flagship.abox.manager.automation.data.RoomActivityRepository
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway
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
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object AutomationModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase =
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build()
@Provides
@Singleton
fun provideActivityRepository(database: AutomationDatabase): ActivityRepository = RoomActivityRepository(database.automationDao())
@Provides
@Singleton
fun provideActivityEventWriter(repository: ActivityRepository): ActivityEventWriter =
ActivityEventWriter(repository, CoroutineScope(SupervisorJob() + Dispatchers.IO))
@Provides
@Singleton
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
@Provides
@Singleton
fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway =
AndroidNotificationGateway(context)
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.automation.notification
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.annotation.SuppressLint
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
if (android.os.Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
}
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
)
return try {
@SuppressLint("MissingPermission")
fun postNotification() {
NotificationManagerCompat.from(context).notify(
profileId.value.hashCode(),
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(message)
.setAutoCancel(true)
.build(),
)
}
postNotification()
DomainResult.Success(Unit)
} catch (_: SecurityException) {
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
}
}
private companion object { const val CHANNEL_ID = "automation_events" }
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.automation.runtime
import com.flagship.abox.manager.domain.ProviderProfileStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import com.flagship.abox.manager.domain.ProviderProfileId
class AutomationBootstrapper(
private val profileStore: ProviderProfileStore,
private val scheduler: AutomationScheduler,
private val scope: CoroutineScope,
) {
private var previousProfileId: ProviderProfileId? = null
fun start() {
scope.launch {
profileStore.observeActiveProfile().collectLatest { active ->
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
previousProfileId = active?.id
scheduler.reconcileActiveProfile()
}
}
}
}
@@ -0,0 +1,10 @@
package com.flagship.abox.manager.automation.runtime
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result =
if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success()
}
@@ -0,0 +1,53 @@
package com.flagship.abox.manager.automation.runtime
import com.flagship.abox.manager.domain.AutomationType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
import kotlinx.coroutines.flow.first
interface AutomationScheduler {
suspend fun reconcileActiveProfile(): DomainResult<Unit>
suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit>
suspend fun cancel(profileId: ProviderProfileId): DomainResult<Unit>
}
interface AutomationScheduleStore {
suspend fun replace(profileId: ProviderProfileId, scenes: List<Scene>): DomainResult<Unit>
suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit>
}
class ProfileAutomationScheduler(
private val profileStore: ProviderProfileStore,
private val sessionGateway: SessionGateway,
private val sceneRepository: SceneRepository,
private val scheduleStore: AutomationScheduleStore,
) : AutomationScheduler {
override suspend fun reconcileActiveProfile(): DomainResult<Unit> {
val active = profileStore.activeProfile() ?: return DomainResult.Success(Unit)
return reconcile(active.id)
}
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
val active = profileStore.activeProfile()
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
return when (val validity = sessionGateway.validateSession(profileId)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> result
is DomainResult.Success -> scheduleStore.replace(profileId, result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC })
}
} else {
scheduleStore.remove(profileId)
}
is DomainResult.Failure -> scheduleStore.remove(profileId)
}
}
override suspend fun cancel(profileId: ProviderProfileId): DomainResult<Unit> = scheduleStore.remove(profileId)
}
@@ -0,0 +1,32 @@
package com.flagship.abox.manager.automation.runtime
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import java.util.concurrent.TimeUnit
class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore {
private val workManager = WorkManager.getInstance(context)
override suspend fun replace(profileId: ProviderProfileId, scenes: List<Scene>): DomainResult<Unit> {
if (scenes.isEmpty()) return remove(profileId)
val request = PeriodicWorkRequestBuilder<AutomationPollWorker>(15, TimeUnit.MINUTES)
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
.build()
workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request)
return DomainResult.Success(Unit)
}
override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> {
workManager.cancelUniqueWork(workName(profileId))
return DomainResult.Success(Unit)
}
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}"
companion object { const val KEY_PROFILE_ID = "profile_id" }
}
@@ -0,0 +1,15 @@
package com.flagship.abox.manager.automation.ui.activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.domain.ProviderProfileId
@Composable
fun ActivityRoute(profileId: ProviderProfileId, modifier: Modifier = Modifier, viewModel: ActivityViewModel = hiltViewModel()) {
val state = viewModel.state.collectAsStateWithLifecycle().value
LaunchedEffect(profileId) { viewModel.initialize(profileId) }
ActivityScreen(state = state, onLoadEarlier = viewModel::loadEarlier, modifier = modifier)
}
@@ -0,0 +1,94 @@
package com.flagship.abox.manager.automation.ui.activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.automation.R
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun ActivityScreen(
state: ActivityUiState,
onLoadEarlier: () -> Unit,
modifier: Modifier = Modifier,
zoneId: ZoneId = ZoneId.systemDefault(),
) {
Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.activity_title), style = MaterialTheme.typography.headlineSmall)
if (!state.loading && state.groups.isEmpty()) {
Text(
if (state.error) stringResource(R.string.activity_error) else stringResource(R.string.activity_empty),
modifier = Modifier.testTag("activity_empty"),
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("activity_timeline")) {
state.groups.forEach { group ->
item { Text(group.label, style = MaterialTheme.typography.titleMedium) }
items(group.events, key = { it.id.value }) { event -> ActivityEventRow(event, zoneId) }
}
if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text(stringResource(R.string.activity_load_earlier)) } }
}
}
}
}
@Composable
private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) {
val outcome = when (event.outcome) {
ActivityOutcome.SUCCEEDED -> R.string.activity_outcome_success
ActivityOutcome.FAILED -> R.string.activity_outcome_failed
ActivityOutcome.CANCELLED -> R.string.activity_outcome_cancelled
ActivityOutcome.AWAITING_CONFIRMATION -> R.string.activity_outcome_awaiting
}
val kind = when (event.kind) {
ActivityEventKind.SOCKET_COMMAND -> R.string.activity_kind_socket_command
ActivityEventKind.SOCKET_CONFIRMATION -> R.string.activity_kind_socket_confirmation
ActivityEventKind.SENSOR_REFRESH -> R.string.activity_kind_sensor_refresh
ActivityEventKind.INFRARED_SEND -> R.string.activity_kind_infrared_send
ActivityEventKind.INFRARED_LEARN -> R.string.activity_kind_infrared_learn
ActivityEventKind.SCENE_EXECUTION -> R.string.activity_kind_scene_execution
ActivityEventKind.SCENE_ACTION -> R.string.activity_kind_scene_action
else -> R.string.activity_title
}
val source = when (event.source) {
ActivitySource.USER -> R.string.activity_source_user
ActivitySource.AUTOMATION -> R.string.activity_source_automation
ActivitySource.SYSTEM -> R.string.activity_source_system
}
Card(
modifier = Modifier.fillMaxWidth().testTag("activity_event_${event.id.value}"),
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(kind), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge)
Text(
"${stringResource(outcome)} · ${stringResource(source)} · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}",
style = MaterialTheme.typography.bodyMedium,
)
event.errorCode?.let { Text(stringResource(R.string.activity_error_code, it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
}
}
}
@@ -0,0 +1,90 @@
package com.flagship.abox.manager.automation.ui.activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Instant
import java.time.ZoneId
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ActivityGroup(val label: String, val events: List<ActivityEvent>)
data class ActivityUiState(
val profileId: ProviderProfileId? = null,
val events: List<ActivityEvent> = emptyList(),
val groups: List<ActivityGroup> = emptyList(),
val loading: Boolean = true,
val loadingEarlier: Boolean = false,
val hasMore: Boolean = true,
val error: Boolean = false,
)
@HiltViewModel
class ActivityViewModel @Inject constructor(
private val repository: ActivityRepository,
) : ViewModel() {
private val _state = MutableStateFlow(ActivityUiState())
val state: StateFlow<ActivityUiState> = _state.asStateFlow()
private var observation: Job? = null
fun initialize(profileId: ProviderProfileId, zoneId: ZoneId = ZoneId.systemDefault()) {
if (_state.value.profileId == profileId && observation?.isActive == true) return
observation?.cancel()
_state.value = ActivityUiState(profileId = profileId)
observation = viewModelScope.launch {
repository.observeLatest(profileId, 100)
.catch { _state.value = _state.value.copy(loading = false, error = true) }
.collectLatest { result ->
when (result) {
is DomainResult.Success -> publish(result.value, zoneId)
is DomainResult.Failure -> _state.value = _state.value.copy(loading = false, error = true)
}
}
}
}
fun loadEarlier(zoneId: ZoneId = ZoneId.systemDefault()) {
val current = _state.value
val profileId = current.profileId ?: return
val last = current.events.lastOrNull() ?: return
if (current.loadingEarlier) return
viewModelScope.launch {
_state.value = _state.value.copy(loadingEarlier = true)
val result = repository.loadBefore(profileId, ActivityEventCursor(last.occurredAtEpochMillis, last.id), 100)
when (result) {
is DomainResult.Success -> publish((current.events + result.value).distinctBy { it.id }, zoneId)
is DomainResult.Failure -> _state.value = _state.value.copy(error = true)
}
_state.value = _state.value.copy(loadingEarlier = false, hasMore = resultHasMore(result))
}
}
private fun publish(events: List<ActivityEvent>, zoneId: ZoneId) {
val sorted = events.sortedWith(compareByDescending<ActivityEvent> { it.occurredAtEpochMillis }.thenByDescending { it.id.value })
val today = Instant.now().atZone(zoneId).toLocalDate()
val groups = sorted.groupBy {
val date = Instant.ofEpochMilli(it.occurredAtEpochMillis).atZone(zoneId).toLocalDate()
when (date) {
today -> "今天"
today.minusDays(1) -> "昨天"
else -> "更早记录"
}
}.map { ActivityGroup(it.key, it.value) }
_state.value = _state.value.copy(events = sorted, groups = groups, loading = false, error = false)
}
private fun resultHasMore(result: DomainResult<List<ActivityEvent>>): Boolean =
result is DomainResult.Success && result.value.size >= 100
}
@@ -0,0 +1,21 @@
<resources>
<string name="activity_title">活动记录</string>
<string name="activity_empty">暂无活动记录</string>
<string name="activity_error">活动记录加载失败</string>
<string name="activity_load_earlier">加载更早记录</string>
<string name="activity_source_user">手动操作</string>
<string name="activity_source_automation">自动化</string>
<string name="activity_source_system">系统</string>
<string name="activity_outcome_success">成功</string>
<string name="activity_outcome_failed">失败</string>
<string name="activity_outcome_cancelled">已取消</string>
<string name="activity_outcome_awaiting">等待确认</string>
<string name="activity_kind_socket_command">插座控制</string>
<string name="activity_kind_socket_confirmation">插座状态确认</string>
<string name="activity_kind_sensor_refresh">传感器刷新</string>
<string name="activity_kind_infrared_send">红外发送</string>
<string name="activity_kind_infrared_learn">红外学习</string>
<string name="activity_kind_scene_execution">场景执行</string>
<string name="activity_kind_scene_action">场景动作</string>
<string name="activity_error_code">错误码:%1$s</string>
</resources>
@@ -0,0 +1,21 @@
<resources>
<string name="activity_title">Activity</string>
<string name="activity_empty">No activity yet</string>
<string name="activity_error">Activity could not be loaded.</string>
<string name="activity_load_earlier">Load earlier activity</string>
<string name="activity_source_user">Manual</string>
<string name="activity_source_automation">Automation</string>
<string name="activity_source_system">System</string>
<string name="activity_outcome_success">Succeeded</string>
<string name="activity_outcome_failed">Failed</string>
<string name="activity_outcome_cancelled">Cancelled</string>
<string name="activity_outcome_awaiting">Awaiting confirmation</string>
<string name="activity_kind_socket_command">Socket command</string>
<string name="activity_kind_socket_confirmation">Socket confirmation</string>
<string name="activity_kind_sensor_refresh">Sensor refresh</string>
<string name="activity_kind_infrared_send">Infrared signal</string>
<string name="activity_kind_infrared_learn">Infrared learning</string>
<string name="activity_kind_scene_execution">Scene execution</string>
<string name="activity_kind_scene_action">Scene action</string>
<string name="activity_error_code">Error code: %1$s</string>
</resources>
@@ -0,0 +1,113 @@
package com.flagship.abox.manager.automation.ui.activity
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityEventId
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class ActivityViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val scope = TestScope(dispatcher)
private val profile = ProviderProfileId("profile")
private val zone = ZoneId.of("Asia/Singapore")
private lateinit var repository: TestActivityRepository
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
repository = TestActivityRepository()
}
@After
fun tearDown() = Dispatchers.resetMain()
@Test
fun realEmptyRepositoryShowsEmptyState() = scope.runTest {
val viewModel = ActivityViewModel(repository)
viewModel.initialize(profile, zone)
advanceUntilIdle()
assertTrue(viewModel.state.value.events.isEmpty())
assertTrue(viewModel.state.value.groups.isEmpty())
assertEquals(false, viewModel.state.value.error)
}
@Test
fun eventsGroupByLocalDate() = scope.runTest {
val now = Instant.now().atZone(zone).truncatedTo(ChronoUnit.MINUTES)
repository.latest.value = DomainResult.Success(
listOf(
event("today", now.toInstant().toEpochMilli()),
event("yesterday", now.minusDays(1).toInstant().toEpochMilli()),
event("earlier", now.minusDays(3).toInstant().toEpochMilli()),
),
)
val viewModel = ActivityViewModel(repository)
viewModel.initialize(profile, zone)
advanceUntilIdle()
assertEquals(listOf("今天", "昨天", "更早记录"), viewModel.state.value.groups.map { it.label })
}
@Test
fun loadEarlierRemovesDuplicateEventsByStableId() = scope.runTest {
val newest = event("newest", 30L)
val older = event("older", 20L)
repository.latest.value = DomainResult.Success(listOf(newest))
repository.before = DomainResult.Success(listOf(newest, older))
val viewModel = ActivityViewModel(repository)
viewModel.initialize(profile, zone)
advanceUntilIdle()
viewModel.loadEarlier(zone)
advanceUntilIdle()
assertEquals(listOf("newest", "older"), viewModel.state.value.events.map { it.id.value })
}
private fun event(id: String, time: Long) = ActivityEvent(
id = ActivityEventId(id),
profileId = profile,
occurredAtEpochMillis = time,
kind = ActivityEventKind.SOCKET_COMMAND,
source = ActivitySource.USER,
outcome = ActivityOutcome.SUCCEEDED,
target = ActivityTarget(ActivityTargetType.DEVICE, id, id),
)
private class TestActivityRepository : ActivityRepository {
val latest = MutableStateFlow<DomainResult<List<ActivityEvent>>>(DomainResult.Success(emptyList()))
var before: DomainResult<List<ActivityEvent>> = DomainResult.Success(emptyList())
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> = latest
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = this.before
override suspend fun append(event: ActivityEvent): DomainResult<Unit> = DomainResult.Success(Unit)
}
}
@@ -57,6 +57,7 @@ dependencies {
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)
testImplementation(libs.junit) testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.infrared.hub
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredHubScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun rendersHardwareSummaryAndRemoteCollection() {
val profileId = ProviderProfileId("p1")
val remote = VirtualRemote(
id = VirtualRemoteId("remote-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
displayName = "电视遥控器",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 1L,
)
composeRule.setContent {
InfraredHubScreen(
state = InfraredHubUiState(
profileId = profileId,
savedDeviceName = DeviceName("ir-1"),
remotes = listOf(remote),
loading = false,
),
onBack = {},
onNavigateToSettings = {},
onOpenRemote = {},
onDeleteRemote = {},
onCreateRemote = { _, _, _, _, _ -> },
onSessionInvalid = {},
onMessageShown = {},
)
}
composeRule.onNodeWithTag("infrared_hardware_summary").assertIsDisplayed()
composeRule.onNodeWithTag("infrared_remote_collection").assertIsDisplayed()
}
}
@@ -0,0 +1,66 @@
package com.flagship.abox.manager.infrared.panel
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RemotePanelScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun acPanelUsesTemperatureStepperSurface() {
composeRule.setContent {
RemotePanelScreen(
state = RemotePanelUiState(remote = remote(PanelLayoutStyle.AC_COMPACT, RemoteDeviceType.AIR_CONDITIONER), loading = false),
onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {},
onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {},
onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {},
onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {},
)
}
composeRule.onNodeWithTag("ac_temperature_stepper").assertIsDisplayed()
composeRule.onNodeWithTag("ac_mode_selector").assertIsDisplayed()
}
@Test
fun tvPanelUsesDirectionalPadAndRockers() {
composeRule.setContent {
RemotePanelScreen(
state = RemotePanelUiState(remote = remote(PanelLayoutStyle.TV_NAV, RemoteDeviceType.TV), loading = false),
onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {},
onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {},
onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {},
onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {},
)
}
composeRule.onNodeWithTag("tv_direction_pad").assertIsDisplayed()
composeRule.onNodeWithTag("tv_volume_channel_rockers").assertIsDisplayed()
}
private fun remote(layout: PanelLayoutStyle, type: RemoteDeviceType) = VirtualRemote(
id = VirtualRemoteId("remote-${layout.name}"),
profileId = ProviderProfileId("p1"),
deviceName = DeviceName("ir-1"),
displayName = "测试遥控器",
type = type,
layoutStyle = layout,
transmissionMode = if (layout == PanelLayoutStyle.AC_COMPACT) TransmissionMode.DYNAMIC_SCRATCH else TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 1L,
)
}
@@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -3,17 +3,13 @@ package com.flagship.abox.manager.infrared.ui
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performTextInput import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -25,53 +21,32 @@ class InfraredControlScreenTest {
val composeTestRule = createComposeRule() val composeTestRule = createComposeRule()
@Test @Test
fun rendersControlScreenAndTriggersKeyOperations() { fun rendersDeviceConfigurationAndAcceptsDeviceNameInput() {
val testKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = ProviderProfileId("profile1"),
deviceName = DeviceName("ir-1"),
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
val state = InfraredControlUiState( val state = InfraredControlUiState(
profileId = ProviderProfileId("profile1"), profileId = ProviderProfileId("profile1"),
providerType = ProviderType.LOCAL_EXPERIENCE, providerType = ProviderType.LOCAL_EXPERIENCE,
savedDeviceName = DeviceName("ir-1"), savedDeviceName = DeviceName("ir-1"),
keys = listOf(testKey),
loading = false, loading = false,
) )
var sendClicked = false var changedName: String? = null
var deleteClicked = false
composeTestRule.setContent { composeTestRule.setContent {
InfraredControlScreen( InfraredControlScreen(
state = state, state = state,
onBack = {}, onBack = {},
onSessionInvalid = {}, onSessionInvalid = {},
onDeviceNameChanged = {}, onDeviceNameChanged = { changedName = it },
onSaveDeviceName = {}, onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = { sendClicked = true },
onRename = { _, _ -> },
onDelete = { deleteClicked = true },
onMessageShown = {}, onMessageShown = {},
nextSlot = 2,
) )
} }
composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed() composeTestRule.onNodeWithText("运行环境与连接信息").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick() composeTestRule.onNodeWithTag("infrared_device_name_input")
assertTrue(sendClicked) .assertIsDisplayed()
.performTextInput("-updated")
composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick() composeTestRule.waitForIdle()
composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick() assertEquals("-updated", changedName)
assertTrue(deleteClicked)
} }
} }
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
VirtualRemoteEntity::class, VirtualRemoteEntity::class,
RemoteKeyBindingEntity::class, RemoteKeyBindingEntity::class,
], ],
version = 2, version = 3,
exportSchema = false, exportSchema = false,
) )
abstract class InfraredDatabase : RoomDatabase() { abstract class InfraredDatabase : RoomDatabase() {
@@ -65,6 +65,11 @@ data class VirtualRemoteEntity(
indices = [ indices = [
Index(value = ["remoteId"]), Index(value = ["remoteId"]),
Index(value = ["profileId"]), Index(value = ["profileId"]),
Index(
value = ["profileId", "hardwareDeviceName", "assignedSlot"],
unique = true,
name = "index_remote_key_bindings_hardware_slot",
),
], ],
) )
data class RemoteKeyBindingEntity( data class RemoteKeyBindingEntity(
@@ -72,6 +77,7 @@ data class RemoteKeyBindingEntity(
val id: String, val id: String,
val remoteId: String, val remoteId: String,
val profileId: String, val profileId: String,
val hardwareDeviceName: String,
val buttonId: String, val buttonId: String,
val displayName: String, val displayName: String,
val assignedSlot: String?, val assignedSlot: String?,
@@ -0,0 +1,20 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE remote_key_bindings ADD COLUMN hardwareDeviceName TEXT NOT NULL DEFAULT ''",
)
db.execSQL(
"UPDATE remote_key_bindings SET hardwareDeviceName = " +
"(SELECT deviceName FROM virtual_remotes WHERE virtual_remotes.id = remote_key_bindings.remoteId)",
)
db.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS index_remote_key_bindings_hardware_slot " +
"ON remote_key_bindings(profileId, hardwareDeviceName, assignedSlot)",
)
}
}
@@ -206,19 +206,36 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi
id = id, id = id,
remoteId = VirtualRemoteId(remoteId), remoteId = VirtualRemoteId(remoteId),
profileId = ProviderProfileId(profileId), profileId = ProviderProfileId(profileId),
hardwareDeviceName = DeviceName(hardwareDeviceName),
buttonId = buttonId, buttonId = buttonId,
displayName = displayName, displayName = displayName,
assignedSlot = assignedSlot, assignedSlot = assignedSlot,
rawHexCode = rawHexCode, rawHexCode = rawHexCode,
status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY), status = resolvedStatus(),
lastErrorCode = lastErrorCode, lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail, lastErrorDetail = lastErrorDetail,
) )
private fun RemoteKeyBindingEntity.resolvedStatus(): InfraredKeyStatus {
val parsed = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.UNLEARNED)
return if (
parsed == InfraredKeyStatus.FAILED &&
lastErrorCode == null &&
lastErrorDetail == null &&
assignedSlot == null &&
rawHexCode == null
) {
InfraredKeyStatus.UNLEARNED
} else {
parsed
}
}
private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity( private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity(
id = id, id = id,
remoteId = remoteId.value, remoteId = remoteId.value,
profileId = profileId.value, profileId = profileId.value,
hardwareDeviceName = hardwareDeviceName.value,
buttonId = buttonId, buttonId = buttonId,
displayName = displayName, displayName = displayName,
assignedSlot = assignedSlot, assignedSlot = assignedSlot,
@@ -20,6 +20,7 @@ import com.flagship.abox.manager.infrared.data.local.InfraredDao
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository
import com.flagship.abox.manager.infrared.data.local.MIGRATION_2_3
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
@@ -49,7 +50,7 @@ object InfraredFeatureModule {
context, context,
InfraredDatabase::class.java, InfraredDatabase::class.java,
"abox_infrared.db", "abox_infrared.db",
).fallbackToDestructiveMigration().build() ).addMigrations(MIGRATION_2_3).build()
} }
@Provides @Provides
@@ -0,0 +1,414 @@
package com.flagship.abox.manager.infrared.hub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.KeyboardArrowUp
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material.icons.outlined.VolumeOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
@Composable
internal fun InfraredCenterControlSurface(
state: InfraredHubUiState,
onButtonClick: (String) -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleAcVerticalSwing: () -> Unit,
onToggleAcHorizontalSwing: () -> Unit,
onToggleAcEco: () -> Unit,
modifier: Modifier = Modifier,
) {
val remote = state.selectedRemote ?: return
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> CenterAcPanel(
state = state,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVerticalSwing = onToggleAcVerticalSwing,
onToggleHorizontalSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
modifier = modifier,
)
PanelLayoutStyle.TV_NAV -> CenterTvPanel(state, onButtonClick, modifier)
PanelLayoutStyle.MEDIA_PLAYER -> CenterMediaPanel(state, onButtonClick, modifier)
PanelLayoutStyle.GRID -> CenterGridPanel(
remote = remote,
bindings = state.bindings,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
onAddButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
modifier = modifier,
)
}
}
@Composable
private fun CenterAcPanel(
state: InfraredHubUiState,
onTogglePower: () -> Unit,
onAdjustTemp: (Int) -> Unit,
onSetMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleVerticalSwing: () -> Unit,
onToggleHorizontalSwing: () -> Unit,
onToggleEco: () -> Unit,
modifier: Modifier,
) {
val ac = state.acState
Column(
modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Box(Modifier.fillMaxWidth().padding(20.dp)) {
IconButton(
onClick = onTogglePower,
modifier = Modifier.align(Alignment.TopEnd).background(
if (ac.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
CircleShape,
),
) {
Icon(Icons.Outlined.PowerSettingsNew, "空调电源", tint = Color.White)
}
Row(
Modifier.fillMaxWidth().padding(top = 38.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
StepButton(Icons.Outlined.Remove, "降低温度") { onAdjustTemp(-1) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("${ac.temperature}°", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.infrared_set_temperature), style = MaterialTheme.typography.labelMedium)
}
StepButton(Icons.Outlined.Add, "升高温度") { onAdjustTemp(1) }
}
}
}
CompactOptionGroup(stringResource(R.string.infrared_running_mode)) {
AcMode.entries.forEach { mode ->
FilterChip(ac.mode == mode, { onSetMode(mode) }, { Text(mode.centerLabel()) })
}
}
CompactOptionGroup(stringResource(R.string.infrared_fan_speed)) {
AcFanSpeed.entries.forEach { speed ->
FilterChip(ac.fanSpeed == speed, { onSetFanSpeed(speed) }, { Text(speed.centerLabel()) })
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CompactToggle(stringResource(R.string.infrared_vertical_swing), ac.verticalSwing, onToggleVerticalSwing, Modifier.weight(1f))
CompactToggle(stringResource(R.string.infrared_horizontal_swing), ac.horizontalSwing, onToggleHorizontalSwing, Modifier.weight(1f))
CompactToggle(stringResource(R.string.infrared_eco), ac.eco, onToggleEco, Modifier.weight(1f))
}
Text(
stringResource(R.string.infrared_transmission_notice),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun CenterTvPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) {
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteIconKey("TV_POWER", stringResource(R.string.infrared_key_tv_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick)
RemoteIconKey("TV_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick)
RemoteIconKey("TV_SOURCE", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Tv, state, onButtonClick)
}
Card(
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth(),
) {
Box(Modifier.fillMaxWidth().height(260.dp), contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(220.dp)) {
Box(Modifier.size(220.dp), contentAlignment = Alignment.Center) {
DirectionKey("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, stringResource(R.string.infrared_up), Alignment.TopCenter, state, onButtonClick)
DirectionKey("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, stringResource(R.string.infrared_down), Alignment.BottomCenter, state, onButtonClick)
DirectionKey("TV_NAV_LEFT", Icons.AutoMirrored.Outlined.KeyboardArrowLeft, stringResource(R.string.infrared_left), Alignment.CenterStart, state, onButtonClick)
DirectionKey("TV_NAV_RIGHT", Icons.AutoMirrored.Outlined.KeyboardArrowRight, stringResource(R.string.infrared_right), Alignment.CenterEnd, state, onButtonClick)
Button(onClick = { onButtonClick("TV_OK") }, shape = CircleShape, modifier = Modifier.size(72.dp)) { Text("OK") }
}
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteTextKey("TV_BACK", stringResource(R.string.infrared_key_tv_back), state, onButtonClick, Modifier.weight(1f))
RemoteIconTextKey("TV_HOME", stringResource(R.string.infrared_key_tv_home), Icons.Outlined.Home, state, onButtonClick, Modifier.weight(1f))
RemoteIconTextKey("TV_MENU", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Menu, state, onButtonClick, Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Rocker(stringResource(R.string.infrared_volume), "TV_VOL_UP", "TV_VOL_DOWN", state, onButtonClick, Modifier.weight(1f))
Rocker(stringResource(R.string.infrared_channel), "TV_CH_UP", "TV_CH_DOWN", state, onButtonClick, Modifier.weight(1f))
}
}
}
@Composable
private fun CenterMediaPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) {
Column(modifier.padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteIconKey("MEDIA_POWER", stringResource(R.string.infrared_key_ac_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick)
RemoteIconKey("MEDIA_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick)
}
Text(stringResource(R.string.infrared_media_panel), style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.CenterHorizontally))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("MEDIA_PREV" to "上一项", "MEDIA_PLAY_PAUSE" to "播放/暂停", "MEDIA_NEXT" to "下一项").forEach { (id, label) ->
RemoteTextKey(id, label, state, onButtonClick, Modifier.weight(1f))
}
}
}
}
@Composable
private fun CenterGridPanel(
remote: VirtualRemote,
bindings: List<RemoteKeyBinding>,
learningButtonId: String?,
sendingButtonId: String?,
onButtonClick: (String) -> Unit,
onAddButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
modifier: Modifier,
) {
var showAddDialog by remember(remote.id) { mutableStateOf(false) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.infrared_remote_keys, remote.displayName),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Button(onClick = { showAddDialog = true }, shape = RoundedCornerShape(6.dp)) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.infrared_add_button), modifier = Modifier.padding(start = 6.dp))
}
}
if (bindings.isEmpty()) {
Text(stringResource(R.string.infrared_no_buttons_manage), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
LazyVerticalGrid(columns = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(bindings, key = { it.id }) { binding ->
Card(
modifier = Modifier.fillMaxWidth().clickable { onButtonClick(binding.buttonId) },
shape = RoundedCornerShape(6.dp),
) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
binding.displayName,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
when {
learningButtonId == binding.buttonId -> stringResource(R.string.infrared_button_learning)
sendingButtonId == binding.buttonId -> stringResource(R.string.infrared_button_sending)
binding.status == InfraredKeyStatus.READY -> stringResource(R.string.infrared_button_ready)
binding.status == InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_button_failed)
binding.status == InfraredKeyStatus.UNLEARNED -> stringResource(R.string.infrared_button_unlearned)
else -> stringResource(R.string.infrared_button_unlearned)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = { onDeleteButton(binding.buttonId) },
modifier = Modifier.size(48.dp),
) {
Icon(Icons.Outlined.Remove, contentDescription = stringResource(R.string.infrared_delete_button))
}
}
}
}
}
}
}
if (showAddDialog) {
AddGridButtonDialog(
onDismiss = { showAddDialog = false },
onConfirm = { name ->
onAddButton(name)
showAddDialog = false
},
)
}
}
@Composable
private fun AddGridButtonDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_add_button_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
stringResource(R.string.infrared_add_button_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.infrared_button_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
Text(stringResource(R.string.infrared_add_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
@Composable private fun StepButton(icon: ImageVector, description: String, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(52.dp).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(6.dp))) {
Icon(icon, description)
}
}
@Composable private fun CompactOptionGroup(title: String, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
Card(shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), content = content)
}
}
}
@Composable private fun CompactToggle(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
Surface(
modifier = modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(6.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
) { Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 14.dp)) }
}
@Composable private fun RemoteIconKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(84.dp).clickable { onClick(id) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(20.dp)) else Icon(icon, label)
Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp))
}
}
}
@Composable private fun BoxScope.DirectionKey(id: String, icon: ImageVector, description: String, alignment: Alignment, state: InfraredHubUiState, onClick: (String) -> Unit) {
IconButton(onClick = { onClick(id) }, modifier = Modifier.align(alignment).padding(10.dp)) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Icon(icon, description, Modifier.size(30.dp))
}
}
@Composable private fun RemoteTextKey(id: String, label: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Text(label)
}
}
@Composable private fun RemoteIconTextKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) {
Icon(icon, label, Modifier.size(18.dp)); Text(label, Modifier.padding(start = 5.dp))
}
}
@Composable private fun Rocker(title: String, upId: String, downId: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Card(modifier, shape = RoundedCornerShape(6.dp)) {
Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
IconButton({ onClick(upId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Add, "提高$title") }
IconButton({ onClick(downId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Remove, "降低$title") }
}
}
}
private fun AcMode.centerLabel() = when (this) { AcMode.COOL -> "制冷"; AcMode.HEAT -> "制热"; AcMode.AUTO -> "自动"; AcMode.DRY -> "除湿"; AcMode.FAN -> "送风" }
private fun AcFanSpeed.centerLabel() = when (this) { AcFanSpeed.AUTO -> "自动"; AcFanSpeed.LOW -> "低速"; AcFanSpeed.MEDIUM -> "中速"; AcFanSpeed.HIGH -> "高速" }
@@ -2,6 +2,8 @@ package com.flagship.abox.manager.infrared.hub
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.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
@@ -16,6 +18,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -24,6 +30,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
@@ -40,6 +48,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SwapVert
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -50,7 +61,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.LocalContext import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag 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
@@ -59,6 +71,10 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
@@ -69,6 +85,8 @@ import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText import com.flagship.abox.manager.infrared.remote.UiText
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
@Composable @Composable
fun InfraredHubRoute( fun InfraredHubRoute(
@@ -87,6 +105,21 @@ fun InfraredHubRoute(
onOpenRemote = onOpenRemote, onOpenRemote = onOpenRemote,
onDeleteRemote = viewModel::deleteRemote, onDeleteRemote = viewModel::deleteRemote,
onCreateRemote = viewModel::createRemote, onCreateRemote = viewModel::createRemote,
onSaveDeviceName = viewModel::saveDeviceName,
onMoveRemote = viewModel::moveRemote,
onUpdateRemote = viewModel::updateRemote,
onSelectRemote = viewModel::selectRemote,
onToggleLearnMode = viewModel::toggleLearnMode,
onButtonClick = viewModel::onButtonClick,
onAddCustomButton = viewModel::addCustomButton,
onDeleteButton = viewModel::deleteButton,
onToggleAcPower = viewModel::toggleAcPower,
onAdjustAcTemp = viewModel::adjustAcTemperature,
onSetAcMode = viewModel::setAcMode,
onSetFanSpeed = viewModel::setAcFanSpeed,
onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing,
onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing,
onToggleAcEco = viewModel::toggleAcEco,
onSessionInvalid = onSessionInvalid, onSessionInvalid = onSessionInvalid,
onMessageShown = viewModel::clearMessage, onMessageShown = viewModel::clearMessage,
) )
@@ -101,12 +134,30 @@ internal fun InfraredHubScreen(
onOpenRemote: (String) -> Unit, onOpenRemote: (String) -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit, onDeleteRemote: (VirtualRemoteId) -> Unit,
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit, onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
onSaveDeviceName: (String) -> Unit = {},
onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> },
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> },
onSessionInvalid: () -> Unit, onSessionInvalid: () -> Unit,
onMessageShown: () -> Unit, onMessageShown: () -> Unit,
onSelectRemote: (VirtualRemoteId) -> Unit = {},
onToggleLearnMode: () -> Unit = {},
onButtonClick: (String) -> Unit = {},
onAddCustomButton: (String) -> Unit = {},
onDeleteButton: (String) -> Unit = {},
onToggleAcPower: () -> Unit = {},
onAdjustAcTemp: (Int) -> Unit = {},
onSetAcMode: (AcMode) -> Unit = {},
onSetFanSpeed: (AcFanSpeed) -> Unit = {},
onToggleAcVerticalSwing: () -> Unit = {},
onToggleAcHorizontalSwing: () -> Unit = {},
onToggleAcEco: () -> Unit = {},
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val resources = LocalResources.current
var showCreateDialog by remember { mutableStateOf(false) } var showCreateDialog by remember { mutableStateOf(false) }
var showManagement by remember { mutableStateOf(false) }
BackHandler(enabled = showManagement) { showManagement = false }
LaunchedEffect(state.message) { LaunchedEffect(state.message) {
state.message?.let { msg -> state.message?.let { msg ->
@@ -114,7 +165,7 @@ internal fun InfraredHubScreen(
is HubUiMessage.Info -> { is HubUiMessage.Info -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
@@ -122,7 +173,7 @@ internal fun InfraredHubScreen(
is HubUiMessage.Error -> { is HubUiMessage.Error -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
@@ -135,102 +186,115 @@ internal fun InfraredHubScreen(
} }
} }
Scaffold( ABoxDeviceDetailScaffold(
topBar = { title = state.savedDeviceName.value,
TopAppBar( subtitle = if (showManagement) stringResource(R.string.infrared_settings_subtitle) else stringResource(R.string.infrared_center_subtitle),
title = { icon = ABoxIconKey.INFRARED,
Column { onBack = if (showManagement) ({ showManagement = false }) else onBack,
Text( action = {
text = stringResource(R.string.infrared_hub_title), if (!showManagement) {
fontWeight = FontWeight.Bold, ABoxIconButton(
) key = ABoxIconKey.EDIT,
state.providerDisplayName?.let { contentDescription = stringResource(R.string.infrared_manage_action),
Text( onClick = { showManagement = true },
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
actions = {
IconButton(
onClick = onNavigateToSettings,
modifier = Modifier.testTag("hub_settings_button"), modifier = Modifier.testTag("hub_settings_button"),
) { )
Text("⚙️", fontSize = 20.sp)
} }
}, },
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding -> ) { innerPadding ->
ABoxSurface( ABoxSurface(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding),
) { ) {
Column( if (showManagement) {
InfraredManagementScreen(
state = state,
onAddRemote = { showCreateDialog = true },
onDeleteRemote = onDeleteRemote,
onSaveDeviceName = onSaveDeviceName,
onMoveRemote = onMoveRemote,
onUpdateRemote = onUpdateRemote,
)
} else Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(16.dp), .padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
// 1. 硬件槽位配额看板 (100 槽位预算)
QuotaSummaryCard(quota = state.slotQuota)
// 2. 遥控器列表标题与新建按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_hub_my_remotes),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Button(
onClick = { showCreateDialog = true },
modifier = Modifier.testTag("hub_add_remote_button"),
) {
Text("➕ " + stringResource(R.string.infrared_hub_add_remote))
}
}
// 3. 遥控器网格卡片
if (state.remotes.isEmpty()) { if (state.remotes.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxWidth().weight(1f), modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
ABoxIcon(ABoxIconKey.INFRARED, stringResource(R.string.infrared_hub_no_remotes), modifier = Modifier.size(48.dp))
Text(stringResource(R.string.infrared_hub_no_remotes), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.infrared_no_remotes_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = { showManagement = true }) { Text(stringResource(R.string.infrared_open_management)) }
}
}
} else {
LazyRow(
modifier = Modifier.fillMaxWidth().testTag("infrared_remote_collection"),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.remotes, key = { it.id.value }) { remote ->
FilterChip(
selected = state.selectedRemoteId == remote.id,
onClick = { onSelectRemote(remote.id) },
label = { Text(remote.displayName) },
leadingIcon = {
ABoxIcon(
key = remoteIcon(remote.type),
contentDescription = remote.displayName,
modifier = Modifier.size(18.dp),
)
},
)
}
}
state.selectedRemote?.let { remote ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(remote.displayName, fontWeight = FontWeight.Bold)
Text( Text(
text = stringResource(R.string.infrared_hub_no_remotes), text = "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} else { if (remote.type == RemoteDeviceType.CUSTOM) {
LazyVerticalGrid( FilterChip(
columns = GridCells.Fixed(2), selected = state.isLearnMode,
modifier = Modifier.fillMaxWidth().weight(1f), onClick = onToggleLearnMode,
horizontalArrangement = Arrangement.spacedBy(12.dp), label = { Text(if (state.isLearnMode) stringResource(R.string.infrared_recording) else stringResource(R.string.infrared_learn_mode)) },
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
items(state.remotes, key = { it.id.value }) { remote ->
VirtualRemoteCard(
remote = remote,
onClick = { onOpenRemote(remote.id.value) },
onDelete = { onDeleteRemote(remote.id) },
) )
} }
} }
} }
InfraredCenterControlSurface(
state = state,
onButtonClick = onButtonClick,
onAddCustomButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
onToggleAcPower = onToggleAcPower,
onAdjustAcTemp = onAdjustAcTemp,
onSetAcMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleAcVerticalSwing = onToggleAcVerticalSwing,
onToggleAcHorizontalSwing = onToggleAcHorizontalSwing,
onToggleAcEco = onToggleAcEco,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
} }
} }
} }
@@ -244,6 +308,226 @@ internal fun InfraredHubScreen(
}, },
) )
} }
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.fillMaxSize().padding(bottom = 16.dp),
)
}
@Composable
private fun StableSingleLineField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun InfraredManagementScreen(
state: InfraredHubUiState,
onAddRemote: () -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit,
onSaveDeviceName: (String) -> Unit,
onMoveRemote: (VirtualRemoteId, Int) -> Unit,
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit,
) {
var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) }
var reorderRemoteId by remember { mutableStateOf<VirtualRemoteId?>(null) }
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.infrared_hardware_slots_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
InfraredHardwareSummary(
deviceName = state.savedDeviceName.value,
remoteCount = state.remotes.size,
quota = state.slotQuota,
)
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.infrared_hub_remotes_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.infrared_remote_count, state.remotes.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(onClick = onAddRemote) { Text(stringResource(R.string.infrared_add_remote)) }
}
state.remotes.forEach { remote ->
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) {
ABoxIcon(
remoteIcon(remote.type),
remote.displayName,
modifier = Modifier.padding(9.dp).size(24.dp),
)
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(remote.displayName, fontWeight = FontWeight.Bold)
Text(
"${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { reorderRemoteId = remote.id }) {
Icon(Icons.Outlined.SwapVert, contentDescription = stringResource(R.string.infrared_move_remote))
}
DropdownMenu(
expanded = reorderRemoteId == remote.id,
onDismissRequest = { reorderRemoteId = null },
) {
val index = state.remotes.indexOf(remote)
DropdownMenuItem(
text = { Text(stringResource(R.string.infrared_move_up)) },
enabled = index > 0,
onClick = { reorderRemoteId = null; onMoveRemote(remote.id, -1) },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.infrared_move_down)) },
enabled = index < state.remotes.lastIndex,
onClick = { reorderRemoteId = null; onMoveRemote(remote.id, 1) },
)
}
}
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote })
}
}
}
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.infrared_gateway_name_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.infrared_gateway_name_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StableSingleLineField(
value = deviceName,
onValueChange = { deviceName = it },
label = stringResource(R.string.infrared_device_name_label),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { onSaveDeviceName(deviceName.trim()) },
enabled = deviceName.isNotBlank() && deviceName != state.savedDeviceName.value,
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.infrared_save_name)) }
}
}
Spacer(Modifier.height(24.dp))
}
editingRemote?.let { remote ->
EditRemoteDialog(
remote = remote,
onDismiss = { editingRemote = null },
onSave = { name, layout, mode ->
editingRemote = null
onUpdateRemote(remote.id, name, layout, mode)
},
onDelete = {
editingRemote = null
onDeleteRemote(remote.id)
},
)
}
}
@Composable
private fun EditRemoteDialog(
remote: VirtualRemote,
onDismiss: () -> Unit,
onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit,
onDelete: () -> Unit,
) {
var name by remember(remote.id) { mutableStateOf(remote.displayName) }
var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) }
var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_edit_remote)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true)
Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold)
PanelLayoutStyle.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = layout == option, onClick = { layout = option })
Text(layoutLabel(option))
}
}
Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold)
TransmissionMode.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = mode == option, onClick = { mode = option })
Text(transmissionLabel(option))
}
}
TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } },
)
}
private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) {
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
@Composable
private fun layoutLabel(layout: PanelLayoutStyle): String = when (layout) {
PanelLayoutStyle.TV_NAV -> stringResource(R.string.infrared_layout_tv)
PanelLayoutStyle.AC_COMPACT -> stringResource(R.string.infrared_layout_ac)
PanelLayoutStyle.MEDIA_PLAYER -> stringResource(R.string.infrared_layout_media)
PanelLayoutStyle.GRID -> stringResource(R.string.infrared_layout_grid)
}
@Composable
private fun transmissionLabel(mode: TransmissionMode): String = when (mode) {
TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_static_slots)
TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes)
} }
@Composable @Composable
@@ -271,15 +555,18 @@ private fun VirtualRemoteCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val icon = when (remote.type) { val icon = when (remote.type) {
RemoteDeviceType.TV -> "📺" RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> "❄️" RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> "🌀" RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.CUSTOM -> "🎛️" RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
Text(icon, fontSize = 28.sp)
IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) {
Text("🗑️", fontSize = 16.sp)
} }
ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp))
ABoxIconButton(
key = ABoxIconKey.CLOSE,
contentDescription = "删除${remote.displayName}",
onClick = onDelete,
modifier = Modifier.testTag("delete_remote_${remote.id.value}"),
)
} }
Text( Text(
@@ -288,11 +575,11 @@ private fun VirtualRemoteCard(
) )
val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) { val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) {
"❄️ 状态机动态写码" "状态机动态写码"
} else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { } else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
"🚀 静态独占槽位" "静态独占槽位"
} else { } else {
"📦 动态写码" "动态写码"
} }
Text( Text(
@@ -305,9 +592,13 @@ private fun VirtualRemoteCard(
} }
@Composable @Composable
private fun QuotaSummaryCard(quota: SlotQuotaSummary) { private fun InfraredHardwareSummary(
deviceName: String,
remoteCount: Int,
quota: SlotQuotaSummary,
) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().testTag("infrared_hardware_summary"),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant, containerColor = MaterialTheme.colorScheme.surfaceVariant,
), ),
@@ -323,13 +614,28 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
text = "硬件槽位配额 (总计 100 槽位)", Surface(
fontWeight = FontWeight.Bold, shape = RoundedCornerShape(14.dp),
fontSize = 14.sp, color = MaterialTheme.colorScheme.primaryContainer,
) {
ABoxIcon(
key = ABoxIconKey.INFRARED,
contentDescription = "红外硬件",
modifier = Modifier.padding(10.dp).size(26.dp),
) )
}
Column {
Text(deviceName, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text( Text(
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用", text = "红外控制器 · $remoteCount 个虚拟遥控器",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = "${quota.usedCount}/${quota.totalCapacity}",
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontSize = 13.sp, fontSize = 13.sp,
@@ -345,7 +651,7 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
) )
Text( Text(
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个", text = "静态槽位 ${quota.assignedStaticSlots.size} 个 · 动态缓冲槽位 100 · 剩余 ${quota.remainingStaticCount} 个",
fontSize = 11.sp, fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -430,7 +736,7 @@ private fun CreateVirtualRemoteDialog(
OutlinedTextField( OutlinedTextField(
value = name, value = name,
onValueChange = { name = it }, onValueChange = { name = it },
label = { Text("遥控器名称(如:客厅电视、我的遥控器)") }, label = { Text("遥控器名称(如:电视、我的遥控器)") },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"), modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
) )
@@ -443,10 +749,10 @@ private fun CreateVirtualRemoteDialog(
) { ) {
OutlinedTextField( OutlinedTextField(
value = when (selectedCustomLayout) { value = when (selectedCustomLayout) {
PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)" PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)"
PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)" PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)"
PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)" PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)"
PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局" PanelLayoutStyle.AC_COMPACT -> "空调布局"
}, },
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
@@ -459,21 +765,21 @@ private fun CreateVirtualRemoteDialog(
onDismissRequest = { layoutDropdownExpanded = false }, onDismissRequest = { layoutDropdownExpanded = false },
) { ) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") }, text = { Text("电视导航布局(内置方向盘、音量、频道)") },
onClick = { onClick = {
selectedCustomLayout = PanelLayoutStyle.TV_NAV selectedCustomLayout = PanelLayoutStyle.TV_NAV
layoutDropdownExpanded = false layoutDropdownExpanded = false
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") }, text = { Text("媒体播放器布局(播放、快进、切歌)") },
onClick = { onClick = {
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
layoutDropdownExpanded = false layoutDropdownExpanded = false
}, },
) )
DropdownMenuItem( DropdownMenuItem(
text = { Text("🎛️ 自由网格布局 (可自由增删按键)") }, text = { Text("自由网格布局(可自由增删按键)") },
onClick = { onClick = {
selectedCustomLayout = PanelLayoutStyle.GRID selectedCustomLayout = PanelLayoutStyle.GRID
layoutDropdownExpanded = false layoutDropdownExpanded = false
@@ -538,7 +844,7 @@ private fun CreateVirtualRemoteDialog(
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS }, onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
) )
Column { Column {
Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium) Text("静态独占槽位模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
@@ -552,7 +858,7 @@ private fun CreateVirtualRemoteDialog(
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH }, onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
) )
Column { Column {
Text("📦 动态写码模式", fontWeight = FontWeight.Medium) Text("动态写码模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
@@ -563,7 +869,7 @@ private fun CreateVirtualRemoteDialog(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。",
fontSize = 12.sp, fontSize = 12.sp,
modifier = Modifier.padding(10.dp), modifier = Modifier.padding(10.dp),
color = MaterialTheme.colorScheme.onSecondaryContainer, color = MaterialTheme.colorScheme.onSecondaryContainer,
@@ -4,7 +4,10 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.infrared.remote.UiText import com.flagship.abox.manager.infrared.remote.UiText
sealed interface HubUiMessage { sealed interface HubUiMessage {
@@ -19,6 +22,13 @@ data class InfraredHubUiState(
val providerDisplayName: String? = null, val providerDisplayName: String? = null,
val savedDeviceName: DeviceName = DeviceName("红外控制器"), val savedDeviceName: DeviceName = DeviceName("红外控制器"),
val remotes: List<VirtualRemote> = emptyList(), val remotes: List<VirtualRemote> = emptyList(),
val selectedRemoteId: VirtualRemoteId? = null,
val selectedRemote: VirtualRemote? = null,
val bindings: List<RemoteKeyBinding> = emptyList(),
val acState: AcStateSnapshot = AcStateSnapshot(),
val isLearnMode: Boolean = false,
val learningButtonId: String? = null,
val sendingButtonId: String? = null,
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val loading: Boolean = true, val loading: Boolean = true,
val message: HubUiMessage? = null, val message: HubUiMessage? = null,
@@ -9,9 +9,23 @@ import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.TransmissionMode import com.flagship.abox.manager.domain.infrared.TransmissionMode
@@ -30,7 +44,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@@ -43,12 +60,26 @@ class InfraredHubViewModel @Inject constructor(
private val virtualRemoteRepository: VirtualRemoteRepository, private val virtualRemoteRepository: VirtualRemoteRepository,
private val createVirtualRemote: CreateVirtualRemote, private val createVirtualRemote: CreateVirtualRemote,
private val deleteVirtualRemote: DeleteVirtualRemote, private val deleteVirtualRemote: DeleteVirtualRemote,
private val sendVirtualRemoteAction: SendVirtualRemoteAction,
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
private val addVirtualRemoteKey: AddVirtualRemoteKey,
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
private val activityWriter: ActivityEventWriter,
) : ViewModel() { ) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"] private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val messageFlow = MutableStateFlow<HubUiMessage?>(null) private val messageFlow = MutableStateFlow<HubUiMessage?>(null)
private val selectedRemoteIdFlow = MutableStateFlow<VirtualRemoteId?>(null)
private val isLearnModeFlow = MutableStateFlow(false)
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
private var acDebounceJob: Job? = null
private val routeDeviceName: DeviceName? = savedStateHandle.get<String>("deviceName")
?.takeIf { it.isNotBlank() }
?.let(::DeviceName)
private data class HubSnapshot( private data class HubSnapshot(
val providerProfile: ProviderProfile? = null, val providerProfile: ProviderProfile? = null,
@@ -68,7 +99,12 @@ class InfraredHubViewModel @Inject constructor(
when (devResult) { when (devResult) {
is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error)) is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error))
is DomainResult.Success -> { is DomainResult.Success -> {
val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME val deviceName = routeDeviceName ?: devResult.value ?: return@flatMapLatest flowOf(
HubSnapshot(
providerProfile = active,
configError = DomainError.InvalidRequest(detail = "No infrared device configured"),
),
)
combine( combine(
virtualRemoteRepository.observeRemotes(profileId, deviceName), virtualRemoteRepository.observeRemotes(profileId, deviceName),
virtualRemoteRepository.observeSlotQuota(profileId, deviceName), virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
@@ -88,10 +124,51 @@ class InfraredHubViewModel @Inject constructor(
} }
} }
private data class PanelSnapshot(
val selectedId: VirtualRemoteId?,
val remote: VirtualRemote?,
val bindings: List<RemoteKeyBinding>,
)
private val panelSnapshotFlow = combine(hubSnapshotFlow, selectedRemoteIdFlow) { hub, selectedId ->
val resolvedId = selectedId?.takeIf { id -> hub.remotes.any { it.id == id } } ?: hub.remotes.firstOrNull()?.id
resolvedId to hub
}.flatMapLatest { (selectedId, hub) ->
if (selectedId == null) {
flowOf(PanelSnapshot(null, null, emptyList()))
} else {
virtualRemoteRepository.observeKeyBindings(selectedId).map { result ->
PanelSnapshot(
selectedId,
hub.remotes.firstOrNull { it.id == selectedId },
if (result is DomainResult.Success) result.value else emptyList(),
)
}
}
}
private data class CenterActions(
val learnMode: Boolean,
val learningButtonId: String?,
val sendingButtonId: String?,
val localAcState: AcStateSnapshot?,
)
private val centerActionsFlow = combine(
isLearnModeFlow,
learningButtonIdFlow,
sendingButtonIdFlow,
localAcStateFlow,
) { learnMode, learningId, sendingId, localAc ->
CenterActions(learnMode, learningId, sendingId, localAc)
}
val state: StateFlow<InfraredHubUiState> = combine( val state: StateFlow<InfraredHubUiState> = combine(
hubSnapshotFlow, hubSnapshotFlow,
panelSnapshotFlow,
centerActionsFlow,
messageFlow, messageFlow,
) { snapshot, msg -> ) { snapshot, panel, actions, msg ->
val resolvedMessage = msg ?: snapshot.configError?.let { error -> val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) { when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid
@@ -111,6 +188,13 @@ class InfraredHubViewModel @Inject constructor(
providerDisplayName = snapshot.providerProfile?.displayName, providerDisplayName = snapshot.providerProfile?.displayName,
savedDeviceName = snapshot.savedDevice, savedDeviceName = snapshot.savedDevice,
remotes = snapshot.remotes, remotes = snapshot.remotes,
selectedRemoteId = panel.selectedId,
selectedRemote = panel.remote,
bindings = panel.bindings,
acState = actions.localAcState ?: panel.remote?.acState ?: AcStateSnapshot(),
isLearnMode = actions.learnMode,
learningButtonId = actions.learningButtonId,
sendingButtonId = actions.sendingButtonId,
slotQuota = snapshot.slotQuota, slotQuota = snapshot.slotQuota,
loading = false, loading = false,
message = resolvedMessage, message = resolvedMessage,
@@ -160,6 +244,155 @@ class InfraredHubViewModel @Inject constructor(
} }
} }
fun selectRemote(remoteId: VirtualRemoteId) {
selectedRemoteIdFlow.value = remoteId
localAcStateFlow.value = null
isLearnModeFlow.value = false
}
fun moveRemote(remoteId: VirtualRemoteId, offset: Int) {
val remotes = state.value.remotes
val from = remotes.indexOfFirst { it.id == remoteId }
val to = from + offset
if (from !in remotes.indices || to !in remotes.indices) return
val current = remotes[from]
val target = remotes[to]
viewModelScope.launch {
val first = virtualRemoteRepository.updateRemote(
current.copy(createdAtEpochMillis = target.createdAtEpochMillis),
)
val second = virtualRemoteRepository.updateRemote(
target.copy(createdAtEpochMillis = current.createdAtEpochMillis),
)
if (first is DomainResult.Failure) handleDomainError(first.error)
else if (second is DomainResult.Failure) handleDomainError(second.error)
}
}
fun updateRemote(
remoteId: VirtualRemoteId,
displayName: String,
layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle,
transmissionMode: TransmissionMode,
) {
val remote = state.value.remotes.firstOrNull { it.id == remoteId } ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
when (val result = virtualRemoteRepository.updateRemote(
remote.copy(
displayName = trimmed,
layoutStyle = layoutStyle,
transmissionMode = transmissionMode,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器配置已保存"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun toggleLearnMode() {
if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return
isLearnModeFlow.update { !it }
}
fun onButtonClick(buttonId: String) {
if (isLearnModeFlow.value) learnButton(buttonId) else sendButton(buttonId)
}
private fun sendButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
if (sendingButtonIdFlow.value != null || learningButtonIdFlow.value != null) return
sendingButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val result = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.SUCCEEDED)
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态"))
}
is DomainResult.Failure -> {
recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.FAILED, result.error.externalCode)
handleDomainError(result.error)
}
}
} finally {
sendingButtonIdFlow.value = null
}
}
}
private fun learnButton(buttonId: String) {
if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
if (learningButtonIdFlow.value != null) return
learningButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val result = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.SUCCEEDED)
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键信号已学习并保存"))
}
is DomainResult.Failure -> {
recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.FAILED, result.error.externalCode)
handleDomainError(result.error)
}
}
} finally {
learningButtonIdFlow.value = null
}
}
}
fun addCustomButton(displayName: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
viewModelScope.launch {
when (val result = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已添加按键"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun deleteButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
viewModelScope.launch {
when (val result = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键已删除"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun toggleAcPower() = updateAc(state.value.acState.copy(power = !state.value.acState.power))
fun adjustAcTemperature(delta: Int) = updateAc(state.value.acState.copy(temperature = (state.value.acState.temperature + delta).coerceIn(16, 30)))
fun setAcMode(mode: AcMode) = updateAc(state.value.acState.copy(mode = mode))
fun setAcFanSpeed(speed: AcFanSpeed) = updateAc(state.value.acState.copy(fanSpeed = speed))
fun toggleAcVerticalSwing() = updateAc(state.value.acState.copy(verticalSwing = !state.value.acState.verticalSwing))
fun toggleAcHorizontalSwing() = updateAc(state.value.acState.copy(horizontalSwing = !state.value.acState.horizontalSwing))
fun toggleAcEco() = updateAc(state.value.acState.copy(eco = !state.value.acState.eco))
private fun updateAc(newState: AcStateSnapshot) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
localAcStateFlow.value = newState
acDebounceJob?.cancel()
acDebounceJob = viewModelScope.launch {
delay(300L)
when (val result = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun saveDeviceName(newName: String) { fun saveDeviceName(newName: String) {
val currentProfile = profileId ?: return val currentProfile = profileId ?: return
val trimmed = newName.trim() val trimmed = newName.trim()
@@ -181,6 +414,19 @@ class InfraredHubViewModel @Inject constructor(
messageFlow.value = null messageFlow.value = null
} }
private fun recordRemoteActivity(kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null) {
val currentProfile = profileId ?: return
val remote = state.value.selectedRemote ?: return
activityWriter.record(
profileId = currentProfile,
kind = kind,
source = ActivitySource.USER,
outcome = outcome,
target = ActivityTarget(ActivityTargetType.REMOTE, remote.id.value, remote.displayName),
errorCode = errorCode,
)
}
private fun handleDomainError(error: DomainError) { private fun handleDomainError(error: DomainError) {
when (error) { when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid
@@ -16,8 +16,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
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.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -41,6 +43,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.KeyboardArrowLeft
import androidx.compose.material.icons.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.KeyboardArrowUp
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material.icons.outlined.VolumeOff
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -51,7 +63,7 @@ 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.LocalContext import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag 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
@@ -60,6 +72,9 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
@@ -118,7 +133,7 @@ internal fun RemotePanelScreen(
onMessageShown: () -> Unit, onMessageShown: () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val resources = LocalResources.current
var showAddKeyDialog by remember { mutableStateOf(false) } var showAddKeyDialog by remember { mutableStateOf(false) }
@@ -128,7 +143,7 @@ internal fun RemotePanelScreen(
is PanelUiMessage.Info -> { is PanelUiMessage.Info -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
@@ -136,7 +151,7 @@ internal fun RemotePanelScreen(
is PanelUiMessage.Error -> { is PanelUiMessage.Error -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
@@ -175,17 +190,14 @@ internal fun RemotePanelScreen(
} }
}, },
navigationIcon = { navigationIcon = {
TextButton(onClick = onBack) { ABoxIconButton(ABoxIconKey.BACK, "返回", onBack)
Text(stringResource(R.string.infrared_back))
}
}, },
actions = { actions = {
// 非空调面板显示学习模式开关 if (state.remote?.type == com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) {
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
FilterChip( FilterChip(
selected = state.isLearnMode, selected = state.isLearnMode,
onClick = onToggleLearnMode, onClick = onToggleLearnMode,
label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, label = { Text(if (state.isLearnMode) "录制中" else "学习模式") },
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFFFF5252), selectedContainerColor = Color(0xFFFF5252),
selectedLabelColor = Color.White, selectedLabelColor = Color.White,
@@ -222,7 +234,7 @@ internal fun RemotePanelScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Text("🔴", fontSize = 16.sp) ABoxIcon(ABoxIconKey.PENDING, "录制中", modifier = Modifier.size(20.dp))
Text( Text(
text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码", text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码",
color = Color(0xFFC62828), color = Color(0xFFC62828),
@@ -233,56 +245,24 @@ internal fun RemotePanelScreen(
} }
} }
// 面板内容区 UnifiedRemoteControlSurface(
Box(modifier = Modifier.fillMaxSize().weight(1f)) { state = state,
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> {
AcControlPanel(
acState = state.acState,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVSwing = onToggleAcVerticalSwing,
onToggleHSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
)
}
PanelLayoutStyle.TV_NAV -> {
TvControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
}
PanelLayoutStyle.MEDIA_PLAYER -> {
MediaPlayerControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
}
PanelLayoutStyle.GRID -> {
GenericGridControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick, onButtonClick = onButtonClick,
onAddCustomButton = onAddCustomButton,
onDeleteButton = onDeleteButton, onDeleteButton = onDeleteButton,
onAddButton = { showAddKeyDialog = true }, onToggleAcPower = onToggleAcPower,
onAdjustAcTemp = onAdjustAcTemp,
onSetAcMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleAcVerticalSwing = onToggleAcVerticalSwing,
onToggleAcHorizontalSwing = onToggleAcHorizontalSwing,
onToggleAcEco = onToggleAcEco,
modifier = Modifier.fillMaxSize().weight(1f),
) )
} }
} }
} }
} }
}
}
}
if (showAddKeyDialog) { if (showAddKeyDialog) {
AddCustomKeyDialog( AddCustomKeyDialog(
@@ -309,12 +289,13 @@ private fun AcControlPanel(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().testTag("ac_temperature_stepper"),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
), ),
@@ -332,7 +313,7 @@ private fun AcControlPanel(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Text(
text = if (acState.power) "● 运行中" else "○ 已关机", text = if (acState.power) "运行中" else "已关机",
color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -344,25 +325,13 @@ private fun AcControlPanel(
.background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline) .background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline)
.testTag("ac_power_toggle"), .testTag("ac_power_toggle"),
) { ) {
Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) Icon(Icons.Outlined.PowerSettingsNew, contentDescription = "电源", tint = Color.White)
} }
} }
Text(
text = "${acState.temperature}°C",
fontSize = 64.sp,
fontWeight = FontWeight.Black,
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(32.dp), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Button( Button(
@@ -370,33 +339,48 @@ private fun AcControlPanel(
modifier = Modifier.size(56.dp).testTag("ac_temp_down"), modifier = Modifier.size(56.dp).testTag("ac_temp_down"),
shape = CircleShape, shape = CircleShape,
) { ) {
Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold) Icon(Icons.Outlined.Remove, contentDescription = "降低温度")
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${acState.temperature}°",
fontSize = 56.sp,
fontWeight = FontWeight.Black,
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("设定温度", style = MaterialTheme.typography.labelMedium)
} }
Button( Button(
onClick = { onAdjustTemp(1) }, onClick = { onAdjustTemp(1) },
modifier = Modifier.size(56.dp).testTag("ac_temp_up"), modifier = Modifier.size(56.dp).testTag("ac_temp_up"),
shape = CircleShape, shape = CircleShape,
) { ) {
Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold) Icon(Icons.Outlined.Add, contentDescription = "升高温度")
} }
} }
Text(
text = "本地选择:${acState.mode.name} · ${acState.fanSpeed.name}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) Card(modifier = Modifier.fillMaxWidth().testTag("ac_mode_selector")) {
Row( Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
modifier = Modifier.fillMaxWidth(), Text("运行模式", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
horizontalArrangement = Arrangement.spacedBy(8.dp), Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
) {
AcMode.entries.forEach { mode -> AcMode.entries.forEach { mode ->
FilterChip( FilterChip(
selected = acState.mode == mode, selected = acState.mode == mode,
onClick = { onSetMode(mode) }, onClick = { onSetMode(mode) },
label = { Text(mode.name) }, label = { Text(modeLabel(mode)) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
} }
}
}
Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
Row( Row(
@@ -407,7 +391,7 @@ private fun AcControlPanel(
FilterChip( FilterChip(
selected = acState.fanSpeed == speed, selected = acState.fanSpeed == speed,
onClick = { onSetFanSpeed(speed) }, onClick = { onSetFanSpeed(speed) },
label = { Text(speed.name) }, label = { Text(fanSpeedLabel(speed)) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -420,19 +404,19 @@ private fun AcControlPanel(
FilterChip( FilterChip(
selected = acState.verticalSwing, selected = acState.verticalSwing,
onClick = onToggleVSwing, onClick = onToggleVSwing,
label = { Text("↕ 上下扫风") }, label = { Text("上下扫风") },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
FilterChip( FilterChip(
selected = acState.horizontalSwing, selected = acState.horizontalSwing,
onClick = onToggleHSwing, onClick = onToggleHSwing,
label = { Text("↔ 左右扫风") }, label = { Text("左右扫风") },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
FilterChip( FilterChip(
selected = acState.eco, selected = acState.eco,
onClick = onToggleEco, onClick = onToggleEco,
label = { Text("🌱 节能 ECO") }, label = { Text("节能") },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -452,6 +436,7 @@ private fun TvControlPanel(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -462,9 +447,9 @@ private fun TvControlPanel(
horizontalArrangement = Arrangement.SpaceAround, horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("TV_POWER", "电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("TV_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("TV_SOURCE", "信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -472,7 +457,7 @@ private fun TvControlPanel(
// 中部五向方向导航盘 // 中部五向方向导航盘
Card( Card(
shape = CircleShape, shape = CircleShape,
modifier = Modifier.size(220.dp), modifier = Modifier.size(220.dp).testTag("tv_direction_pad"),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) { ) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -481,7 +466,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_UP") }, onClick = { onButtonClick("TV_NAV_UP") },
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"),
) { ) {
ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId) DirectionIndicator("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, "向上", learningButtonId, sendingButtonId)
} }
// 下 // 下
@@ -489,7 +474,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_DOWN") }, onClick = { onButtonClick("TV_NAV_DOWN") },
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
) { ) {
ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId) DirectionIndicator("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, "向下", learningButtonId, sendingButtonId)
} }
// 左 // 左
@@ -497,7 +482,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_LEFT") }, onClick = { onButtonClick("TV_NAV_LEFT") },
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
) { ) {
ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId) DirectionIndicator("TV_NAV_LEFT", Icons.Outlined.KeyboardArrowLeft, "向左", learningButtonId, sendingButtonId)
} }
// 右 // 右
@@ -505,7 +490,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_RIGHT") }, onClick = { onButtonClick("TV_NAV_RIGHT") },
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
) { ) {
ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId) DirectionIndicator("TV_NAV_RIGHT", Icons.Outlined.KeyboardArrowRight, "向右", learningButtonId, sendingButtonId)
} }
// OK 居中大按钮 // OK 居中大按钮
@@ -524,14 +509,14 @@ private fun TvControlPanel(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround, horizontalArrangement = Arrangement.SpaceAround,
) { ) {
InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("TV_BACK", "返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("TV_HOME", "主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("TV_MENU", "菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
} }
// 底部音量与频道调节柱 // 底部音量与频道调节柱
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth().testTag("tv_volume_channel_rockers"),
horizontalArrangement = Arrangement.SpaceEvenly, horizontalArrangement = Arrangement.SpaceEvenly,
) { ) {
Column( Column(
@@ -539,11 +524,11 @@ private fun TvControlPanel(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId) Icon(Icons.Outlined.Add, contentDescription = "提高音量")
} }
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId) Icon(Icons.Outlined.Remove, contentDescription = "降低音量")
} }
} }
@@ -552,17 +537,118 @@ private fun TvControlPanel(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId) Icon(Icons.Outlined.Add, contentDescription = "频道上调")
} }
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId) Icon(Icons.Outlined.Remove, contentDescription = "频道下调")
} }
} }
} }
} }
} }
@Composable
fun UnifiedRemoteControlSurface(
state: RemotePanelUiState,
onButtonClick: (String) -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleAcVerticalSwing: () -> Unit,
onToggleAcHorizontalSwing: () -> Unit,
onToggleAcEco: () -> Unit,
modifier: Modifier = Modifier,
) {
var showAddKeyDialog by remember { mutableStateOf(false) }
val remote = state.remote
Box(modifier = modifier) {
if (remote == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> AcControlPanel(
acState = state.acState,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVSwing = onToggleAcVerticalSwing,
onToggleHSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
)
PanelLayoutStyle.TV_NAV -> TvControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
PanelLayoutStyle.MEDIA_PLAYER -> MediaPlayerControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
PanelLayoutStyle.GRID -> GenericGridControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
onDeleteButton = onDeleteButton,
onAddButton = { showAddKeyDialog = true },
)
}
}
}
if (showAddKeyDialog) {
AddCustomKeyDialog(
onDismiss = { showAddKeyDialog = false },
onConfirm = {
showAddKeyDialog = false
onAddCustomButton(it)
},
)
}
}
@Composable
private fun DirectionIndicator(
buttonId: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
learningButtonId: String?,
sendingButtonId: String?,
) {
when (buttonId) {
learningButtonId -> Text("录制中", fontSize = 11.sp, fontWeight = FontWeight.Bold)
sendingButtonId -> CircularProgressIndicator(modifier = Modifier.size(18.dp))
else -> Icon(icon, contentDescription = description, modifier = Modifier.size(30.dp))
}
}
private fun modeLabel(mode: AcMode): String = when (mode) {
AcMode.COOL -> "制冷"
AcMode.HEAT -> "制热"
AcMode.AUTO -> "自动"
AcMode.DRY -> "除湿"
AcMode.FAN -> "送风"
}
private fun fanSpeedLabel(speed: AcFanSpeed): String = when (speed) {
AcFanSpeed.AUTO -> "自动"
AcFanSpeed.LOW -> "低速"
AcFanSpeed.MEDIUM -> "中速"
AcFanSpeed.HIGH -> "高速"
}
@Composable @Composable
private fun MediaPlayerControlPanel( private fun MediaPlayerControlPanel(
bindings: List<RemoteKeyBinding>, bindings: List<RemoteKeyBinding>,
@@ -582,8 +668,8 @@ private fun MediaPlayerControlPanel(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("MEDIA_POWER", "电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) InteractiveRemoteButton("MEDIA_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -641,13 +727,13 @@ private fun GenericGridControlPanel(
) { ) {
Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp) Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) { Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) {
Text("➕ 添加按键") Text("添加按键")
} }
} }
if (bindings.isEmpty()) { if (bindings.isEmpty()) {
Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant) Text("暂无自定义按键,请使用上方添加操作新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} else { } else {
LazyVerticalGrid( LazyVerticalGrid(
@@ -696,9 +782,10 @@ private fun GridButtonCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp) Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp)
val statusText = when { val statusText = when {
isLearning -> "🟡 正在录制信号..." isLearning -> "正在录制信号..."
binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})" binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})"
else -> "⚪ 待录制 (点击录码)" binding.status == InfraredKeyStatus.FAILED -> "学习失败(请重试)"
else -> "未学习(点击录码)"
} }
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
@@ -711,7 +798,7 @@ private fun GridButtonCard(
onClick = onDelete, onClick = onDelete,
modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"), modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"),
) { ) {
Text("🗑️", fontSize = 15.sp) Text("删除", fontSize = 12.sp)
} }
} }
} }
@@ -755,7 +842,7 @@ private fun ButtonIndicatorText(
sendingButtonId: String?, sendingButtonId: String?,
) { ) {
if (learningButtonId == buttonId) { if (learningButtonId == buttonId) {
Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold) Text("录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
} else if (sendingButtonId == buttonId) { } else if (sendingButtonId == buttonId) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
} else { } else {
@@ -24,6 +24,8 @@ import com.flagship.abox.manager.infrared.remote.UiText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -59,6 +61,7 @@ class RemotePanelViewModel @Inject constructor(
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null) private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null) private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
private var acDebounceJob: Job? = null private var acDebounceJob: Job? = null
private var learningJob: Job? = null
private val remoteFlow = if (profileId == null || remoteId == null) { private val remoteFlow = if (profileId == null || remoteId == null) {
flowOf(null) flowOf(null)
@@ -126,8 +129,10 @@ class RemotePanelViewModel @Inject constructor(
) )
fun toggleLearnMode() { fun toggleLearnMode() {
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
val newMode = !isLearnModeFlow.value val newMode = !isLearnModeFlow.value
isLearnModeFlow.value = newMode isLearnModeFlow.value = newMode
if (!newMode) cancelLearning()
if (newMode) { if (newMode) {
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled)) messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled))
} }
@@ -162,12 +167,13 @@ class RemotePanelViewModel @Inject constructor(
} }
fun learnButton(buttonId: String) { fun learnButton(buttonId: String) {
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
val currentProfile = profileId ?: return val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return val currentRemoteId = remoteId ?: return
if (state.value.learningButtonId != null) return if (state.value.learningButtonId != null) return
learningButtonIdFlow.value = buttonId learningButtonIdFlow.value = buttonId
viewModelScope.launch { learningJob = viewModelScope.launch {
try { try {
when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> { is DomainResult.Success -> {
@@ -175,12 +181,39 @@ class RemotePanelViewModel @Inject constructor(
} }
is DomainResult.Failure -> handleDomainError(res.error) is DomainResult.Failure -> handleDomainError(res.error)
} }
} catch (cancelled: kotlinx.coroutines.CancellationException) {
val binding = state.value.bindings.firstOrNull { it.buttonId == buttonId }
if (binding != null) {
withContext(NonCancellable) {
virtualRemoteRepository.updateKeyBinding(
binding.copy(
status = com.flagship.abox.manager.domain.infrared.InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "学习已取消,需要重新学习",
),
)
}
}
throw cancelled
} finally { } finally {
learningButtonIdFlow.value = null learningButtonIdFlow.value = null
} }
} }
} }
fun cancelLearning() {
learningJob?.cancel()
learningJob = null
learningButtonIdFlow.value = null
isLearnModeFlow.value = false
}
override fun onCleared() {
cancelLearning()
acDebounceJob?.cancel()
super.onCleared()
}
fun addCustomButton(displayName: String) { fun addCustomButton(displayName: String) {
val currentProfile = profileId ?: return val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return val currentRemoteId = remoteId ?: return
@@ -204,7 +204,8 @@ class InfraredRemoteViewModel @Inject constructor(
activeLearningSlotFlow.value = spec.defaultSlot activeLearningSlotFlow.value = spec.defaultSlot
val deferred = when (existingKey?.status) { val deferred = when (existingKey?.status) {
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING ->
learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
null -> learningCoordinator.learn( null -> learningCoordinator.learn(
profileId = currentProfile, profileId = currentProfile,
deviceName = currentDevice, deviceName = currentDevice,
@@ -26,24 +26,24 @@ data class RemoteButtonSpec(
object RemoteSceneConfig { object RemoteSceneConfig {
object AirConditioner { object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻") val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "电源")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲") val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "升温")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼") val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "降温")
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀") val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式")
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨") val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速")
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔") val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱") val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
} }
object Television { object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻") val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "电源")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇") val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "静音")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲") val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "上")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼") val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "下")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀") val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "左")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶") val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "右")
val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK") val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +") val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -") val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -")
@@ -35,7 +35,7 @@ import androidx.compose.runtime.remember
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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag 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
@@ -45,6 +45,8 @@ import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
@@ -87,7 +89,7 @@ internal fun InfraredRemoteScreen(
onMessageShown: () -> Unit, onMessageShown: () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val resources = LocalResources.current
LaunchedEffect(state.message) { LaunchedEffect(state.message) {
state.message?.let { msg -> state.message?.let { msg ->
@@ -95,7 +97,7 @@ internal fun InfraredRemoteScreen(
is RemoteUiMessage.Info -> { is RemoteUiMessage.Info -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
@@ -103,7 +105,7 @@ internal fun InfraredRemoteScreen(
is RemoteUiMessage.Error -> { is RemoteUiMessage.Error -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
@@ -158,12 +160,12 @@ internal fun InfraredRemoteScreen(
} }
// 按键管理 / 设置入口按钮 // 按键管理 / 设置入口按钮
IconButton( ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = "打开红外设置",
onClick = onOpenSettings, onClick = onOpenSettings,
modifier = Modifier.testTag("remote_settings_button"), modifier = Modifier.testTag("remote_settings_button"),
) { )
Text("⚙️", fontSize = 20.sp)
}
}, },
) )
}, },
@@ -33,7 +33,7 @@ import androidx.compose.runtime.remember
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.platform.LocalContext import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag 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
@@ -76,7 +76,7 @@ internal fun InfraredControlScreen(
onMessageShown: () -> Unit, onMessageShown: () -> Unit,
) { ) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val resources = LocalResources.current
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
LaunchedEffect(state.message) { LaunchedEffect(state.message) {
@@ -85,7 +85,7 @@ internal fun InfraredControlScreen(
is InfraredUiMessage.Info -> { is InfraredUiMessage.Info -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()
@@ -93,7 +93,7 @@ internal fun InfraredControlScreen(
is InfraredUiMessage.Error -> { is InfraredUiMessage.Error -> {
val text = when (val t = msg.text) { val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
} }
snackbarHostState.showSnackbar(text) snackbarHostState.showSnackbar(text)
onMessageShown() onMessageShown()

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