Compare commits
26
Commits
05d44578b9
...
54aa74126f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54aa74126f | ||
|
|
816606234b | ||
|
|
cd4a5ea04c | ||
|
|
d8d9eb4f28 | ||
|
|
7a107cc55a | ||
|
|
0e4a50b3dd | ||
|
|
7afcea296e | ||
|
|
8e3d7e604b | ||
|
|
1139606f9f | ||
|
|
5a25c31b39 | ||
|
|
fd67820c63 | ||
|
|
cce0441476 | ||
|
|
713106df14 | ||
|
|
ca4c34f21c | ||
|
|
867096d7ef | ||
|
|
25ed255ddf | ||
|
|
176a70cf7f | ||
|
|
26ab5095e5 | ||
|
|
4798ae32ba | ||
|
|
b537cd1824 | ||
|
|
171d1daa50 | ||
|
|
9ba17c7d72 | ||
|
|
a30ab9e376 | ||
|
|
876acd1e7b | ||
|
|
e14b4cee27 | ||
|
|
6e78b71d31 |
@@ -133,6 +133,25 @@ class AbsdkBridge internal constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun commandPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
powerState: SocketPowerState,
|
||||
): DomainResult<Unit> = serialized {
|
||||
withActiveSession(profileId) {
|
||||
when (val control = call {
|
||||
client.setSocketPower(deviceName.value, powerState.toSdkStatus())
|
||||
}) {
|
||||
is ClientCall.Failed -> DomainResult.Failure(control.error)
|
||||
is ClientCall.Completed -> if (control.response.isSuccess()) {
|
||||
DomainResult.Success(Unit)
|
||||
} else {
|
||||
DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getReading(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
|
||||
@@ -67,8 +67,10 @@ dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":android:core:designsystem"))
|
||||
implementation(project(":android:core:preferences"))
|
||||
implementation(project(":android:core:devicecatalog"))
|
||||
implementation(project(":android:core:session"))
|
||||
implementation(project(":android:feature:infrared"))
|
||||
implementation(project(":android:feature:automation"))
|
||||
implementation(project(":absdk-bridge"))
|
||||
implementation(project(":runtime:simulation"))
|
||||
debugImplementation(project(":testing:fakes"))
|
||||
|
||||
+55
@@ -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()
|
||||
}
|
||||
}
|
||||
+80
-42
@@ -1,26 +1,24 @@
|
||||
package com.flagship.abox.manager.ui.preferences
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.test.assertIsSelected
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertWidthIsAtLeast
|
||||
import androidx.compose.ui.test.assertHeightIsAtLeast
|
||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
import com.flagship.abox.manager.preferences.UiPreferences
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -31,60 +29,100 @@ class SettingsScreenTest {
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() {
|
||||
fun interfaceStyleOpensMenuBeforeChangingRenderer() {
|
||||
val actions = mutableListOf<SettingsUiAction>()
|
||||
composeRule.setContent {
|
||||
ABoxTheme {
|
||||
SettingsScreen(
|
||||
state = SettingsUiState(
|
||||
preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3),
|
||||
loaded = true,
|
||||
),
|
||||
onAction = {},
|
||||
state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true),
|
||||
onAction = actions::add,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("界面设置").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("渲染器").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Material 3").assertIsSelected()
|
||||
composeRule.onNodeWithText("跟随系统").assertIsSelected()
|
||||
composeRule.onNodeWithText("动态取色").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("系统辅助功能").assertIsDisplayed()
|
||||
composeRule.onNodeWithTag("settings-dynamic-color")
|
||||
.assertWidthIsAtLeast(48.dp)
|
||||
.assertHeightIsAtLeast(48.dp)
|
||||
composeRule.onNodeWithTag("settings-blur")
|
||||
.assertWidthIsAtLeast(48.dp)
|
||||
.assertHeightIsAtLeast(48.dp)
|
||||
composeRule.onNodeWithTag("settings-haptics")
|
||||
.assertWidthIsAtLeast(48.dp)
|
||||
.assertHeightIsAtLeast(48.dp)
|
||||
composeRule.onNodeWithTag("settings-style-entry").performClick()
|
||||
|
||||
assertTrue(actions.isEmpty())
|
||||
composeRule.onNodeWithTag("settings-style-menu").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Miuix").performClick()
|
||||
assertEquals(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX), actions.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchingRendererKeepsCurrentlyRenderedTestScreenPresent() {
|
||||
fun materialThemeShowsEveryMaterialOptionInReferenceOrder() {
|
||||
showTheme(DesignRenderer.MATERIAL3)
|
||||
|
||||
assertVerticalOrder(
|
||||
"settings-material-colors",
|
||||
"settings-theme-mode",
|
||||
"settings-color-style",
|
||||
"settings-color-standard",
|
||||
"settings-predictive-back",
|
||||
"settings-interface-scale",
|
||||
)
|
||||
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag("settings-liquid-glass").assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun miuixThemeShowsEveryMiuixOptionInReferenceOrder() {
|
||||
showTheme(DesignRenderer.MIUIX)
|
||||
|
||||
assertVerticalOrder(
|
||||
"settings-theme-mode",
|
||||
"settings-miuix-accent",
|
||||
"settings-blur",
|
||||
"settings-floating-navigation",
|
||||
"settings-liquid-glass",
|
||||
"settings-predictive-back",
|
||||
"settings-interface-scale",
|
||||
)
|
||||
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag("settings-color-style").assertCountEquals(0)
|
||||
composeRule.onAllNodesWithTag("settings-color-standard").assertCountEquals(0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun themeDetailRemainsOpenWhenRendererChangesFromHomeMenu() {
|
||||
var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3))
|
||||
val experienceState = SettingsExperienceState()
|
||||
composeRule.setContent {
|
||||
ABoxTheme(preferences = preferences) {
|
||||
Column {
|
||||
Text("当前测试内容")
|
||||
SettingsScreen(
|
||||
SettingsExperience(
|
||||
state = SettingsUiState(preferences = preferences, loaded = true),
|
||||
experienceState = experienceState,
|
||||
onAction = { action ->
|
||||
if (action is SettingsUiAction.SetRenderer) {
|
||||
preferences = preferences.copy(renderer = action.value)
|
||||
}
|
||||
if (action is SettingsUiAction.SetRenderer) 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()
|
||||
composeRule.waitForIdle()
|
||||
|
||||
composeRule.onNodeWithText("当前测试内容").assertIsDisplayed()
|
||||
composeRule.onNodeWithText("Miuix").assertIsSelected()
|
||||
private fun assertVerticalOrder(vararg tags: String) {
|
||||
val tops = tags.map { tag -> composeRule.onNodeWithTag(tag).getUnclippedBoundsInRoot().top }
|
||||
assertTrue("Expected controls in reference-image order: ${tags.toList()}", tops.zipWithNext().all { it.first < it.second })
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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()
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.flagship.abox.manager.ui.temperaturehumidity
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onAllNodesWithTag
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class TemperatureHumidityDetailScreenTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun partialReadingRendersOnlyAvailableMetric() {
|
||||
composeRule.setContent {
|
||||
ABoxTheme {
|
||||
TemperatureHumidityScreen(
|
||||
state = TemperatureHumidityUiState(
|
||||
profileId = ProviderProfileId("p1"),
|
||||
deviceName = DeviceName("sensor-1"),
|
||||
temperature = "24.2",
|
||||
humidity = null,
|
||||
),
|
||||
onEvent = {},
|
||||
onBackClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithTag("sensor_metric_temperature").assertIsDisplayed()
|
||||
composeRule.onAllNodesWithTag("sensor_metric_humidity").assertCountEquals(0)
|
||||
composeRule.onNodeWithTag("sensor_monitoring_notice").assertIsDisplayed()
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.flagship.abox.manager.di
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object FoundationModule {
|
||||
// TEMPORARY boundary: ABSDK has no device catalog, and the provider-scoped production
|
||||
// ABox catalog is owned by the device-catalog module. Until that exists, debug builds
|
||||
// keep the fake ABox catalog here. Do NOT route ABox catalog requests to
|
||||
// SimulationDeviceCatalog; release builds must not request a DeviceCatalog binding
|
||||
// until a real release consumer appears.
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeviceCatalog(): DeviceCatalog = FakeDeviceCatalog()
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<application
|
||||
android:name=".ABoxManagerApplication"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name"
|
||||
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.domain.InfraredGateway
|
||||
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import com.flagship.abox.manager.domain.SocketGateway
|
||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||
import com.flagship.abox.manager.simulation.SimulationInfraredGateway
|
||||
import com.flagship.abox.manager.simulation.SimulationSocketGateway
|
||||
import com.flagship.abox.manager.simulation.SimulationTemperatureHumidityGateway
|
||||
import com.flagship.abox.manager.simulation.SimulationDeviceCatalog
|
||||
import com.flagship.abox.manager.devicecatalog.RoutingDeviceCatalog
|
||||
import com.flagship.abox.manager.devicecatalog.data.RoomConfiguredDeviceRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -22,6 +26,18 @@ import javax.inject.Singleton
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object DeviceGatewayModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSimulationDeviceCatalog(): SimulationDeviceCatalog = SimulationDeviceCatalog()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeviceCatalog(
|
||||
profileStore: ProviderProfileStore,
|
||||
configured: RoomConfiguredDeviceRepository,
|
||||
simulation: SimulationDeviceCatalog,
|
||||
): DeviceCatalog = RoutingDeviceCatalog(profileStore, configured, simulation)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSimulationSocketGateway(): SimulationSocketGateway = SimulationSocketGateway()
|
||||
|
||||
@@ -62,6 +62,15 @@ internal class RoutingSocketGateway(
|
||||
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
|
||||
}
|
||||
|
||||
override suspend fun commandPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
powerState: SocketPowerState,
|
||||
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
|
||||
is DomainResult.Failure -> provider
|
||||
is DomainResult.Success -> delegate(provider.value).commandPower(profileId, deviceName, powerState)
|
||||
}
|
||||
|
||||
override suspend fun setPower(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
|
||||
@@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.SceneExecutor
|
||||
import com.flagship.abox.manager.domain.SceneRepository
|
||||
import com.flagship.abox.manager.domain.SocketGateway
|
||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||
import com.flagship.abox.manager.ui.scene.RecordingSceneExecutor
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -29,9 +31,13 @@ object SceneIntegrationModule {
|
||||
socketGateway: SocketGateway,
|
||||
infraredGateway: InfraredGateway,
|
||||
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||
): SceneExecutor = DefaultSceneExecutor(
|
||||
activityWriter: ActivityEventWriter,
|
||||
): SceneExecutor = RecordingSceneExecutor(
|
||||
delegate = DefaultSceneExecutor(
|
||||
socketGateway = socketGateway,
|
||||
infraredGateway = infraredGateway,
|
||||
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.safeDrawing
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -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.LoginViewModel
|
||||
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.provider.ProviderSelectionRoute
|
||||
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||
import com.flagship.abox.manager.ui.shell.AccountProviderBottomSheet
|
||||
import com.flagship.abox.manager.ui.shell.AccountProviderDialog
|
||||
import com.flagship.abox.manager.ui.session.SessionUiAction
|
||||
|
||||
@Composable
|
||||
fun ABoxManagerApp(
|
||||
@@ -43,6 +47,11 @@ fun ABoxManagerApp(
|
||||
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
|
||||
val actionError = actionState.errorMessage?.asString()
|
||||
val screenKey = sessionState.screenKey()
|
||||
var showAccountDialog by rememberSaveable(screenKey) { mutableStateOf(false) }
|
||||
var showSettings by rememberSaveable(screenKey) { mutableStateOf(false) }
|
||||
val settingsExperienceState = rememberSaveable(screenKey, saver = SettingsExperienceState.Saver) {
|
||||
SettingsExperienceState()
|
||||
}
|
||||
|
||||
LaunchedEffect(screenKey) {
|
||||
sessionActionsViewModel.dismissError()
|
||||
@@ -74,42 +83,68 @@ fun ABoxManagerApp(
|
||||
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
|
||||
username = state.username,
|
||||
profile = state.profile,
|
||||
actionInProgress = actionState.inProgress,
|
||||
errorMessage = actionError,
|
||||
onLogout = sessionActionsViewModel::logout,
|
||||
onSessionAction = sessionActionsViewModel::dispatch,
|
||||
showAccountDialog = showAccountDialog,
|
||||
showSettings = showSettings,
|
||||
settingsExperienceState = settingsExperienceState,
|
||||
onShowAccountDialogChange = { showAccountDialog = it },
|
||||
onShowSettingsChange = { showSettings = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AuthenticatedNavHost(
|
||||
username: Username,
|
||||
profile: ProviderProfile,
|
||||
actionInProgress: Boolean,
|
||||
errorMessage: String?,
|
||||
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(
|
||||
username = username,
|
||||
profile = profile,
|
||||
onLogout = onLogout,
|
||||
onProviderClick = { showAccountSheet = true },
|
||||
onAvatarClick = { showAccountSheet = true },
|
||||
onProviderClick = { onShowAccountDialogChange(true) },
|
||||
onAvatarClick = { onShowAccountDialogChange(true) },
|
||||
)
|
||||
}
|
||||
|
||||
if (showAccountSheet) {
|
||||
AccountProviderBottomSheet(
|
||||
if (showAccountDialog) {
|
||||
AccountProviderDialog(
|
||||
userName = username.value,
|
||||
providerName = profile.displayName,
|
||||
onDismissRequest = { showAccountSheet = false },
|
||||
onLogout = onLogout,
|
||||
actionInProgress = actionInProgress,
|
||||
errorMessage = errorMessage,
|
||||
onDismissRequest = { onShowAccountDialogChange(false) },
|
||||
onOpenSettings = {
|
||||
onShowAccountDialogChange(false)
|
||||
onShowSettingsChange(true)
|
||||
},
|
||||
onSwitchProvider = {
|
||||
onShowAccountDialogChange(false)
|
||||
onSessionAction(SessionUiAction.SwitchProvider)
|
||||
},
|
||||
onEndSession = {
|
||||
onShowAccountDialogChange(false)
|
||||
onSessionAction(SessionUiAction.EndSession)
|
||||
},
|
||||
onForgetCredentials = {
|
||||
onShowAccountDialogChange(false)
|
||||
onSessionAction(SessionUiAction.ForgetSavedCredentials)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+122
@@ -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
|
||||
}
|
||||
}
|
||||
+55
@@ -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,
|
||||
)
|
||||
+31
@@ -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,
|
||||
)
|
||||
}
|
||||
+350
@@ -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)
|
||||
}
|
||||
}
|
||||
+216
@@ -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
|
||||
}
|
||||
+149
-79
@@ -2,11 +2,14 @@ package com.flagship.abox.manager.ui.navigation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.PredictiveBackHandler
|
||||
import android.os.Build
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -14,11 +17,14 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
@@ -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.panel.RemotePanelRoute
|
||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
||||
import com.flagship.abox.manager.ui.devicecenter.DeviceCenterRoute
|
||||
import com.flagship.abox.manager.automation.ui.activity.ActivityRoute
|
||||
import com.flagship.abox.manager.ui.scene.SceneListScreen
|
||||
import com.flagship.abox.manager.ui.preferences.SettingsRoute
|
||||
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
||||
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
|
||||
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
|
||||
@@ -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.detailPresentation
|
||||
import com.flagship.abox.manager.ui.shell.widthClass
|
||||
import com.flagship.abox.manager.designsystem.LocalABoxPredictiveBackEnabled
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import com.flagship.abox.manager.ui.socket.SocketScreen
|
||||
import com.flagship.abox.manager.ui.socket.SocketUiEvent
|
||||
import com.flagship.abox.manager.ui.socket.SocketViewModel
|
||||
@@ -183,7 +194,21 @@ fun AuthenticatedNavigationStateHost(
|
||||
selectedDevice = selectedDevice,
|
||||
detailPresentation = selectedPresentation,
|
||||
)
|
||||
val canNavigateBack = selectedDevice != null || currentRoute != MainTab.DEVICES.route
|
||||
val predictiveBackEnabled = LocalABoxPredictiveBackEnabled.current &&
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
var backProgress by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
.clip(RoundedCornerShape((28f * backProgress).dp))
|
||||
.graphicsLayer {
|
||||
val scale = 1f - 0.04f * backProgress
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationX = 28.dp.toPx() * backProgress
|
||||
},
|
||||
) {
|
||||
content(
|
||||
snapshot,
|
||||
{ tab ->
|
||||
@@ -205,12 +230,23 @@ fun AuthenticatedNavigationStateHost(
|
||||
},
|
||||
{ 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,
|
||||
onProviderClick: () -> Unit,
|
||||
onAvatarClick: () -> Unit,
|
||||
onOpenSettings: () -> Unit = {},
|
||||
windowSizeClass: ABoxWindowSizeClass? = null,
|
||||
) {
|
||||
val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp)
|
||||
@@ -237,33 +274,28 @@ fun AuthenticatedNavigation(
|
||||
profile.id,
|
||||
selection.deviceName,
|
||||
)
|
||||
DeviceType.INFRARED -> "${Routes.INFRARED_HUB}/${Uri.encode(profile.id.value)}"
|
||||
DeviceType.INFRARED -> Routes.infraredHub(profile.id.value, selection.deviceName.value)
|
||||
}
|
||||
},
|
||||
selectionForRoute = { route -> profile.selectionForRoute(route) },
|
||||
) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection ->
|
||||
val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES
|
||||
MainAppShell(
|
||||
currentTab = currentTab,
|
||||
windowSizeClass = resolvedWindowSizeClass,
|
||||
userName = username.value,
|
||||
providerName = profile.displayName,
|
||||
onTabClick = onTabClick,
|
||||
onProviderClick = onProviderClick,
|
||||
onAvatarClick = onAvatarClick,
|
||||
content = { paddingValues ->
|
||||
AuthenticatedGraphContent(
|
||||
navController = navController,
|
||||
profile = profile,
|
||||
paddingValues = paddingValues,
|
||||
username = username,
|
||||
currentTab = currentTab,
|
||||
windowSizeClass = resolvedWindowSizeClass,
|
||||
isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
|
||||
snapshot.currentRoute == MainTab.DEVICES.route,
|
||||
selectedDevice = snapshot.selectedDevice,
|
||||
onTabClick = onTabClick,
|
||||
onSelectDevice = onSelectDevice,
|
||||
onClearDeviceSelection = onClearDeviceSelection,
|
||||
onProviderClick = onProviderClick,
|
||||
onAvatarClick = onAvatarClick,
|
||||
onLogout = onLogout,
|
||||
)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -273,45 +305,36 @@ fun AuthenticatedNavigation(
|
||||
private fun AuthenticatedGraphContent(
|
||||
navController: NavHostController,
|
||||
profile: ProviderProfile,
|
||||
paddingValues: PaddingValues,
|
||||
username: Username,
|
||||
currentTab: MainTab,
|
||||
windowSizeClass: ABoxWindowSizeClass,
|
||||
isExpandedDevicePane: Boolean,
|
||||
selectedDevice: DeviceSelection?,
|
||||
onTabClick: (MainTab) -> Unit,
|
||||
onSelectDevice: (DeviceName, DeviceType) -> Unit,
|
||||
onClearDeviceSelection: () -> Unit,
|
||||
onProviderClick: () -> Unit,
|
||||
onAvatarClick: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
) {
|
||||
if (isExpandedDevicePane && selectedDevice != null) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
AuthenticatedNavHost(
|
||||
modifier = Modifier.weight(1f),
|
||||
navController = navController,
|
||||
profile = profile,
|
||||
onSelectDevice = onSelectDevice,
|
||||
onClearDeviceSelection = onClearDeviceSelection,
|
||||
onLogout = onLogout,
|
||||
)
|
||||
DeviceDetailPane(
|
||||
modifier = Modifier.weight(1f),
|
||||
selection = selectedDevice,
|
||||
onBack = onClearDeviceSelection,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
AuthenticatedNavHost(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
navController = navController,
|
||||
profile = profile,
|
||||
username = username,
|
||||
currentTab = currentTab,
|
||||
windowSizeClass = windowSizeClass,
|
||||
isExpandedDevicePane = isExpandedDevicePane,
|
||||
selectedDevice = selectedDevice,
|
||||
onTabClick = onTabClick,
|
||||
onSelectDevice = onSelectDevice,
|
||||
onClearDeviceSelection = onClearDeviceSelection,
|
||||
onProviderClick = onProviderClick,
|
||||
onAvatarClick = onAvatarClick,
|
||||
onLogout = onLogout,
|
||||
onOpenSettings = onOpenSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -319,9 +342,18 @@ private fun AuthenticatedNavHost(
|
||||
modifier: Modifier,
|
||||
navController: NavHostController,
|
||||
profile: ProviderProfile,
|
||||
username: Username,
|
||||
currentTab: MainTab,
|
||||
windowSizeClass: ABoxWindowSizeClass,
|
||||
isExpandedDevicePane: Boolean,
|
||||
selectedDevice: DeviceSelection?,
|
||||
onTabClick: (MainTab) -> Unit,
|
||||
onSelectDevice: (DeviceName, DeviceType) -> Unit,
|
||||
onClearDeviceSelection: () -> Unit,
|
||||
onProviderClick: () -> Unit,
|
||||
onAvatarClick: () -> Unit,
|
||||
onLogout: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
@@ -329,9 +361,42 @@ private fun AuthenticatedNavHost(
|
||||
modifier = modifier,
|
||||
) {
|
||||
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) {
|
||||
MainAppShell(
|
||||
currentTab = currentTab,
|
||||
windowSizeClass = windowSizeClass,
|
||||
userName = username.value,
|
||||
providerName = profile.displayName,
|
||||
onTabClick = onTabClick,
|
||||
onProviderClick = onProviderClick,
|
||||
onAvatarClick = onAvatarClick,
|
||||
) { paddingValues ->
|
||||
Box(Modifier.fillMaxSize().padding(paddingValues)) {
|
||||
SceneListScreen(
|
||||
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
|
||||
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
|
||||
@@ -339,8 +404,25 @@ private fun AuthenticatedNavHost(
|
||||
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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(
|
||||
onBackClick = {
|
||||
@@ -354,9 +436,12 @@ private fun AuthenticatedNavHost(
|
||||
)
|
||||
sceneExecutionDestination(onBack = navController::popBackStack)
|
||||
composable(
|
||||
route = "${Routes.INFRARED_HUB}/{profileId}",
|
||||
arguments = listOf(navArgument("profileId") { type = NavType.StringType }),
|
||||
) {
|
||||
route = Routes.INFRARED_HUB,
|
||||
arguments = listOf(
|
||||
navArgument("profileId") { type = NavType.StringType },
|
||||
navArgument("deviceName") { type = NavType.StringType },
|
||||
),
|
||||
) { entry ->
|
||||
InfraredHubRoute(
|
||||
onBack = {
|
||||
onClearDeviceSelection()
|
||||
@@ -368,7 +453,8 @@ private fun AuthenticatedNavHost(
|
||||
)
|
||||
},
|
||||
onOpenSettings = {
|
||||
navController.navigate("${Routes.INFRARED_SETTINGS}/${Uri.encode(profile.id.value)}")
|
||||
val deviceName = entry.arguments?.getString("deviceName").orEmpty()
|
||||
navController.navigate(Routes.infraredSettings(profile.id.value, deviceName))
|
||||
},
|
||||
onSessionInvalid = onLogout,
|
||||
)
|
||||
@@ -386,8 +472,11 @@ private fun AuthenticatedNavHost(
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = "${Routes.INFRARED_SETTINGS}/{profileId}",
|
||||
arguments = listOf(navArgument("profileId") { type = NavType.StringType }),
|
||||
route = Routes.INFRARED_SETTINGS,
|
||||
arguments = listOf(
|
||||
navArgument("profileId") { type = NavType.StringType },
|
||||
navArgument("deviceName") { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
InfraredControlRoute(
|
||||
onBack = navController::popBackStack,
|
||||
@@ -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
|
||||
private fun DeviceDetailPane(
|
||||
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? {
|
||||
val parts = route?.split('/') ?: return null
|
||||
return when {
|
||||
@@ -475,9 +539,9 @@ private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection?
|
||||
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
|
||||
type = DeviceType.TEMPERATURE_HUMIDITY,
|
||||
)
|
||||
parts.size >= 2 && parts[0] == Routes.INFRARED_HUB -> DeviceSelection(
|
||||
parts.size >= 3 && parts[0] == "infrared_hub" -> DeviceSelection(
|
||||
profileId = id,
|
||||
deviceName = DeviceName("红外遥控器"),
|
||||
deviceName = DeviceName(parts[2].decodeRoutePart()),
|
||||
type = DeviceType.INFRARED,
|
||||
)
|
||||
else -> null
|
||||
@@ -497,3 +561,9 @@ private fun NavBackStackEntry.actualRoute(): String? {
|
||||
}
|
||||
return route
|
||||
}
|
||||
|
||||
private fun NavHostController.popCurrentDestinationUnlessRoot() {
|
||||
if (currentBackStackEntry?.actualRoute() != MainTab.DEVICES.route) {
|
||||
popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
package com.flagship.abox.manager.ui.navigation
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
object Routes {
|
||||
const val STARTUP = "startup"
|
||||
const val PROVIDER_SELECTION = "provider_selection"
|
||||
const val LOGIN = "login"
|
||||
const val AUTHENTICATED = "authenticated"
|
||||
const val SETTINGS = "settings"
|
||||
|
||||
const val INFRARED_HUB = "infrared_hub"
|
||||
const val INFRARED_HUB = "infrared_hub/{profileId}/{deviceName}"
|
||||
const val INFRARED_PANEL = "infrared_panel"
|
||||
const val INFRARED_SETTINGS = "infrared_settings"
|
||||
const val INFRARED_SETTINGS = "infrared_settings/{profileId}/{deviceName}"
|
||||
|
||||
fun infraredHub(profileId: String, deviceName: String): String =
|
||||
"infrared_hub/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
|
||||
|
||||
fun infraredSettings(profileId: String, deviceName: String): String =
|
||||
"infrared_settings/${Uri.encode(profileId)}/${Uri.encode(deviceName)}"
|
||||
}
|
||||
|
||||
+490
-144
@@ -1,227 +1,573 @@
|
||||
package com.flagship.abox.manager.ui.preferences
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
|
||||
import androidx.compose.material.icons.outlined.Check
|
||||
import androidx.compose.material.icons.outlined.ChevronRight
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxCard
|
||||
import com.flagship.abox.manager.designsystem.ABoxErrorState
|
||||
import com.flagship.abox.manager.designsystem.ABoxFilterChip
|
||||
import com.flagship.abox.manager.designsystem.ABoxPreferenceGroup
|
||||
import com.flagship.abox.manager.designsystem.ABoxSwitch
|
||||
import com.flagship.abox.manager.designsystem.LocalABoxShapes
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||
import com.flagship.abox.manager.preferences.AccentPreference
|
||||
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
import com.flagship.abox.manager.preferences.InterfaceScale
|
||||
import com.flagship.abox.manager.preferences.ColorStandard
|
||||
import com.flagship.abox.manager.preferences.ColorStyle
|
||||
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||
|
||||
private enum class SettingsDestination { HOME, THEME }
|
||||
|
||||
@Stable
|
||||
class SettingsExperienceState(initialDestination: String = SettingsDestination.HOME.name) {
|
||||
var destination: String by mutableStateOf(initialDestination)
|
||||
|
||||
companion object {
|
||||
val Saver: Saver<SettingsExperienceState, String> = Saver(
|
||||
save = { it.destination },
|
||||
restore = { SettingsExperienceState(it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
experienceState: SettingsExperienceState = SettingsExperienceState(),
|
||||
onExit: () -> Unit = {},
|
||||
viewModel: SettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state = viewModel.uiState.collectAsStateWithLifecycle().value
|
||||
SettingsScreen(state = state, onAction = viewModel::onAction)
|
||||
SettingsExperience(state, experienceState, viewModel::onAction, onExit)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsExperience(
|
||||
state: SettingsUiState,
|
||||
experienceState: SettingsExperienceState,
|
||||
onAction: (SettingsUiAction) -> Unit,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) }
|
||||
.getOrDefault(SettingsDestination.HOME)
|
||||
BackHandler {
|
||||
if (destination == SettingsDestination.THEME) experienceState.destination = SettingsDestination.HOME.name
|
||||
else onExit()
|
||||
}
|
||||
when (destination) {
|
||||
SettingsDestination.HOME -> SettingsHomeScreen(
|
||||
state = state,
|
||||
onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name },
|
||||
onBack = onExit,
|
||||
onAction = onAction,
|
||||
)
|
||||
SettingsDestination.THEME -> ThemeSettingsScreen(
|
||||
state = state,
|
||||
onBack = { experienceState.destination = SettingsDestination.HOME.name },
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SettingsScreen(
|
||||
state: SettingsUiState,
|
||||
onAction: (SettingsUiAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) = 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(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) },
|
||||
) { contentPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(contentPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ABoxPreferenceGroup(title = stringResource(R.string.settings_style_section)) {
|
||||
PreferenceRow(
|
||||
title = stringResource(R.string.settings_renderer_label),
|
||||
var styleMenuExpanded by remember { mutableStateOf(false) }
|
||||
SettingsPage(modifier = modifier, title = stringResource(R.string.settings_title), onBack = onBack) {
|
||||
SettingsGroup {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
SettingsLinkRow(
|
||||
icon = ABoxIconKey.SETTINGS,
|
||||
title = stringResource(R.string.settings_style_entry),
|
||||
description = stringResource(R.string.settings_renderer_description),
|
||||
value = rendererLabel(state.preferences.renderer),
|
||||
modifier = Modifier.testTag("settings-style-entry"),
|
||||
) { styleMenuExpanded = true }
|
||||
Box(modifier = Modifier.align(Alignment.TopEnd)) {
|
||||
DropdownMenu(
|
||||
expanded = styleMenuExpanded,
|
||||
onDismissRequest = { styleMenuExpanded = false },
|
||||
modifier = Modifier.testTag("settings-style-menu"),
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ABoxFilterChip(
|
||||
selected = state.preferences.renderer == DesignRenderer.MATERIAL3,
|
||||
label = stringResource(R.string.settings_renderer_material3),
|
||||
onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MATERIAL3)) },
|
||||
)
|
||||
ABoxFilterChip(
|
||||
selected = state.preferences.renderer == DesignRenderer.MIUIX,
|
||||
label = stringResource(R.string.settings_renderer_miuix),
|
||||
onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) },
|
||||
DesignRenderer.entries.forEach { renderer ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(rendererLabel(renderer)) },
|
||||
trailingIcon = {
|
||||
if (renderer == state.preferences.renderer) Icon(Icons.Outlined.Check, null)
|
||||
},
|
||||
onClick = {
|
||||
styleMenuExpanded = false
|
||||
onAction(SettingsUiAction.SetRenderer(renderer))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ABoxPreferenceGroup(title = stringResource(R.string.settings_theme_section)) {
|
||||
PreferenceRow(
|
||||
title = stringResource(R.string.settings_theme_label),
|
||||
description = null,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ThemeChip(AppThemeMode.SYSTEM, state, onAction, R.string.settings_theme_system)
|
||||
ThemeChip(AppThemeMode.LIGHT, state, onAction, R.string.settings_theme_light)
|
||||
ThemeChip(AppThemeMode.DARK, state, onAction, R.string.settings_theme_dark)
|
||||
}
|
||||
}
|
||||
SwitchPreference(
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
description = stringResource(R.string.settings_dynamic_color_description),
|
||||
checked = state.preferences.dynamicColorEnabled,
|
||||
modifier = Modifier.testTag("settings-dynamic-color"),
|
||||
onCheckedChange = { onAction(SettingsUiAction.SetDynamicColor(it)) },
|
||||
GroupDivider()
|
||||
SettingsLinkRow(
|
||||
icon = ABoxIconKey.THEME,
|
||||
title = stringResource(R.string.settings_theme_entry),
|
||||
description = stringResource(R.string.settings_theme_entry_description),
|
||||
value = null,
|
||||
modifier = Modifier.testTag("settings-theme-entry"),
|
||||
onClick = onOpenTheme,
|
||||
)
|
||||
}
|
||||
|
||||
ABoxPreferenceGroup(title = stringResource(R.string.settings_surface_section)) {
|
||||
SwitchPreference(
|
||||
title = stringResource(R.string.settings_blur),
|
||||
description = stringResource(R.string.settings_blur_description),
|
||||
checked = state.preferences.blurEnabled,
|
||||
modifier = Modifier.testTag("settings-blur"),
|
||||
onCheckedChange = { onAction(SettingsUiAction.SetBlur(it)) },
|
||||
)
|
||||
PreferenceRow(
|
||||
title = stringResource(R.string.settings_navigation),
|
||||
description = null,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
NavigationChip(
|
||||
presentation = NavigationPresentation.STANDARD,
|
||||
selected = state.preferences.navigationPresentation == NavigationPresentation.STANDARD,
|
||||
label = stringResource(R.string.settings_navigation_standard),
|
||||
onAction = onAction,
|
||||
)
|
||||
NavigationChip(
|
||||
presentation = NavigationPresentation.FLOATING,
|
||||
selected = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
|
||||
label = stringResource(R.string.settings_navigation_floating),
|
||||
onAction = onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ABoxPreferenceGroup(title = stringResource(R.string.settings_feedback_section)) {
|
||||
SwitchPreference(
|
||||
SettingsGroup {
|
||||
SettingsSwitchRow(
|
||||
icon = ABoxIconKey.FEEDBACK,
|
||||
title = stringResource(R.string.settings_haptics),
|
||||
description = stringResource(R.string.settings_haptics_description),
|
||||
checked = state.preferences.hapticsEnabled,
|
||||
modifier = Modifier.testTag("settings-haptics"),
|
||||
onCheckedChange = { onAction(SettingsUiAction.SetHaptics(it)) },
|
||||
)
|
||||
tag = "settings-haptics",
|
||||
) { onAction(SettingsUiAction.SetHaptics(it)) }
|
||||
}
|
||||
|
||||
ABoxPreferenceGroup(title = stringResource(R.string.settings_accessibility_section)) {
|
||||
ABoxCard(
|
||||
SettingsGroup {
|
||||
SettingsInfoRow(
|
||||
icon = ABoxIconKey.SETTINGS,
|
||||
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 ->
|
||||
ABoxErrorState(
|
||||
title = stringResource(R.string.settings_error_save),
|
||||
description = error.asString(),
|
||||
@Composable
|
||||
private fun ThemeSettingsScreen(
|
||||
state: SettingsUiState,
|
||||
onBack: () -> Unit,
|
||||
onAction: (SettingsUiAction) -> Unit,
|
||||
) {
|
||||
SettingsPage(
|
||||
title = stringResource(R.string.settings_theme_detail_title),
|
||||
onBack = onBack,
|
||||
modifier = Modifier.testTag("settings-theme-detail"),
|
||||
) {
|
||||
ThemePreview(state)
|
||||
when (state.preferences.renderer) {
|
||||
DesignRenderer.MATERIAL3 -> MaterialThemeOptions(state, onAction)
|
||||
DesignRenderer.MIUIX -> MiuixThemeOptions(state, onAction)
|
||||
}
|
||||
SettingsError(state)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-material-colors")) {
|
||||
SettingsSwitchRow(
|
||||
icon = ABoxIconKey.THEME,
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
description = stringResource(R.string.settings_dynamic_color_description),
|
||||
checked = state.preferences.dynamicColorEnabled,
|
||||
tag = "settings-dynamic-color",
|
||||
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
|
||||
GroupDivider()
|
||||
AccentSelector(state, onAction)
|
||||
}
|
||||
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
|
||||
EnumSettingRow(
|
||||
modifier = Modifier.testTag("settings-color-style"),
|
||||
icon = ABoxIconKey.THEME,
|
||||
title = stringResource(R.string.settings_color_style),
|
||||
value = colorStyleLabel(state.preferences.colorStyle),
|
||||
values = ColorStyle.entries,
|
||||
label = { colorStyleLabel(it) },
|
||||
onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) },
|
||||
)
|
||||
EnumSettingRow(
|
||||
modifier = Modifier.testTag("settings-color-standard"),
|
||||
icon = ABoxIconKey.SETTINGS,
|
||||
title = stringResource(R.string.settings_color_standard),
|
||||
value = colorStandardLabel(state.preferences.colorStandard),
|
||||
values = ColorStandard.entries,
|
||||
label = { colorStandardLabel(it) },
|
||||
onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) },
|
||||
)
|
||||
PredictiveBackSetting(state, onAction)
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||
val renderEffectsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
val floatingEnabled = state.preferences.navigationPresentation == NavigationPresentation.FLOATING
|
||||
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-miuix-accent")) {
|
||||
SettingsSwitchRow(
|
||||
icon = ABoxIconKey.THEME,
|
||||
title = stringResource(R.string.settings_dynamic_color),
|
||||
description = stringResource(R.string.settings_dynamic_color_description),
|
||||
checked = state.preferences.dynamicColorEnabled,
|
||||
tag = "settings-dynamic-color",
|
||||
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
|
||||
GroupDivider()
|
||||
AccentSelector(state, onAction)
|
||||
}
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-blur")) {
|
||||
SettingsSwitchRow(
|
||||
icon = ABoxIconKey.EFFECTS,
|
||||
title = stringResource(R.string.settings_blur),
|
||||
description = stringResource(R.string.settings_blur_description),
|
||||
checked = state.preferences.blurEnabled,
|
||||
tag = "settings-blur-switch",
|
||||
enabled = renderEffectsSupported,
|
||||
unavailableDescription = if (renderEffectsSupported) null else stringResource(R.string.settings_requires_android_13),
|
||||
) { onAction(SettingsUiAction.SetBlur(it)) }
|
||||
}
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-floating-navigation")) {
|
||||
SettingsSwitchRow(
|
||||
icon = ABoxIconKey.EFFECTS,
|
||||
title = stringResource(R.string.settings_navigation_floating),
|
||||
description = stringResource(R.string.settings_navigation_floating_description),
|
||||
checked = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
|
||||
tag = "settings-floating-navigation-switch",
|
||||
) { onAction(SettingsUiAction.SetNavigation(if (it) NavigationPresentation.FLOATING else NavigationPresentation.STANDARD)) }
|
||||
}
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-liquid-glass")) {
|
||||
SettingsSwitchRow(
|
||||
icon = ABoxIconKey.EFFECTS,
|
||||
title = stringResource(R.string.settings_liquid_glass),
|
||||
description = stringResource(R.string.settings_liquid_glass_description),
|
||||
checked = state.preferences.liquidGlassEnabled,
|
||||
tag = "settings-liquid-glass-switch",
|
||||
enabled = renderEffectsSupported && floatingEnabled,
|
||||
unavailableDescription = when {
|
||||
!floatingEnabled -> stringResource(R.string.settings_requires_floating_navigation)
|
||||
!renderEffectsSupported -> stringResource(R.string.settings_requires_android_13)
|
||||
else -> null
|
||||
},
|
||||
) { onAction(SettingsUiAction.SetLiquidGlass(it)) }
|
||||
}
|
||||
PredictiveBackSetting(state, onAction)
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PredictiveBackSetting(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||
val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||
SettingsGroup(modifier = Modifier.testTag("settings-predictive-back")) {
|
||||
SettingsSwitchRow(
|
||||
icon = ABoxIconKey.BACK,
|
||||
title = stringResource(R.string.settings_predictive_back),
|
||||
description = stringResource(R.string.settings_predictive_back_description),
|
||||
checked = state.preferences.predictiveBackEnabled,
|
||||
tag = "settings-predictive-back-switch",
|
||||
enabled = supported,
|
||||
unavailableDescription = if (supported) null else stringResource(R.string.settings_requires_android_13),
|
||||
) { onAction(SettingsUiAction.SetPredictiveBack(it)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsPage(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.action_back)) }
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsGroup(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
content = { Column(content = content) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemePreview(state: SettingsUiState) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().height(190.dp),
|
||||
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
|
||||
) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Surface(
|
||||
modifier = Modifier.width(184.dp).height(142.dp),
|
||||
shape = RoundedCornerShape(24.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 3.dp,
|
||||
) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(22.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Box(Modifier.height(10.dp).weight(1f).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape))
|
||||
}
|
||||
Box(Modifier.fillMaxWidth().height(58.dp).background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp)))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
repeat(3) { Box(Modifier.weight(1f).height(18.dp).background(if (it == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, CircleShape)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "${rendererLabel(state.preferences.renderer)} · ${state.preferences.interfaceScale.percent}%",
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeChip(
|
||||
mode: AppThemeMode,
|
||||
private fun RendererSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
RendererOption(DesignRenderer.MATERIAL3, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-primary"), onAction)
|
||||
RendererOption(DesignRenderer.MIUIX, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-secondary"), onAction)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RendererOption(value: DesignRenderer, selected: DesignRenderer, modifier: Modifier, onAction: (SettingsUiAction) -> Unit) {
|
||||
val active = value == selected
|
||||
Surface(
|
||||
modifier = modifier.clickable { onAction(SettingsUiAction.SetRenderer(value)) },
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
border = if (active) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
|
||||
) {
|
||||
Row(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(rendererLabel(value), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
|
||||
if (active) Icon(Icons.Outlined.Check, null, tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ThemeModeSelector(
|
||||
state: SettingsUiState,
|
||||
onAction: (SettingsUiAction) -> Unit,
|
||||
labelRes: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ABoxFilterChip(
|
||||
selected = state.preferences.themeMode == mode,
|
||||
label = stringResource(labelRes),
|
||||
onClick = { onAction(SettingsUiAction.SetThemeMode(mode)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavigationChip(
|
||||
presentation: NavigationPresentation,
|
||||
selected: Boolean,
|
||||
label: String,
|
||||
onAction: (SettingsUiAction) -> Unit,
|
||||
) {
|
||||
ABoxFilterChip(
|
||||
selected = selected,
|
||||
label = label,
|
||||
onClick = { onAction(SettingsUiAction.SetNavigation(presentation)) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PreferenceRow(
|
||||
title: String,
|
||||
description: String?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
AppThemeMode.entries.forEach { mode ->
|
||||
val selected = state.preferences.themeMode == mode
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetThemeMode(mode)) },
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
|
||||
) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
description?.let {
|
||||
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(themeLabel(mode), Modifier.padding(vertical = 14.dp), textAlign = TextAlign.Center, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
|
||||
}
|
||||
}
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
description: String,
|
||||
checked: Boolean,
|
||||
modifier: Modifier,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
tag: String,
|
||||
enabled: Boolean = true,
|
||||
unavailableDescription: String? = null,
|
||||
onChange: (Boolean) -> Unit,
|
||||
) {
|
||||
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,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(text = title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
ABoxSwitch(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
contentDescription = title,
|
||||
modifier = modifier,
|
||||
)
|
||||
SettingsRowHeader(icon, title, unavailableDescription ?: description, Modifier.weight(1f))
|
||||
Switch(checked, onCheckedChange = onChange, enabled = enabled, modifier = Modifier.testTag(tag))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsInfoRow(icon: ABoxIconKey, title: String, description: String) {
|
||||
SettingsRowHeader(icon, title, description, Modifier.padding(16.dp))
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: String, modifier: Modifier = Modifier) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ABoxIcon(icon, title, Modifier.size(28.dp))
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable private fun GroupDivider() = Box(Modifier.fillMaxWidth().padding(start = 58.dp).height(1.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)))
|
||||
|
||||
@Composable private fun SettingsError(state: SettingsUiState) { state.error?.let { ABoxErrorState(stringResource(R.string.settings_error_save), it.asString()) } }
|
||||
|
||||
@Composable private fun rendererLabel(renderer: DesignRenderer) = stringResource(if (renderer == DesignRenderer.MATERIAL3) R.string.settings_renderer_primary else R.string.settings_renderer_secondary)
|
||||
@Composable private fun themeLabel(mode: AppThemeMode) = stringResource(when (mode) { AppThemeMode.SYSTEM -> R.string.settings_theme_system; AppThemeMode.LIGHT -> R.string.settings_theme_light; AppThemeMode.DARK -> R.string.settings_theme_dark })
|
||||
@Composable private fun colorStyleLabel(value: ColorStyle) = stringResource(when (value) { ColorStyle.TONAL_SPOT -> R.string.settings_color_style_tonal; ColorStyle.EXPRESSIVE -> R.string.settings_color_style_expressive; ColorStyle.FIDELITY -> R.string.settings_color_style_fidelity })
|
||||
@Composable private fun colorStandardLabel(value: ColorStandard) = stringResource(when (value) { ColorStandard.SPEC_2021 -> R.string.settings_color_standard_2021; ColorStandard.SPEC_2025 -> R.string.settings_color_standard_2025 })
|
||||
private fun accentColor(accent: AccentPreference) = when (accent) { AccentPreference.BLUE -> Color(0xFF4F6FA9); AccentPreference.GREEN -> Color(0xFF2F7D57); AccentPreference.AMBER -> Color(0xFFA66700); AccentPreference.ROSE -> Color(0xFFA94C68); AccentPreference.VIOLET -> Color(0xFF7659A8) }
|
||||
|
||||
+34
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||
import com.flagship.abox.manager.preferences.AccentPreference
|
||||
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
import com.flagship.abox.manager.preferences.InterfaceScale
|
||||
import com.flagship.abox.manager.preferences.ColorStandard
|
||||
import com.flagship.abox.manager.preferences.ColorStyle
|
||||
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||
import com.flagship.abox.manager.preferences.PreferenceField
|
||||
import com.flagship.abox.manager.preferences.UiPreferences
|
||||
@@ -62,6 +66,30 @@ class SettingsViewModel @Inject constructor(
|
||||
field = PreferenceField.HAPTICS,
|
||||
transform = { it.copy(hapticsEnabled = action.value) },
|
||||
)
|
||||
is SettingsUiAction.SetInterfaceScale -> update(
|
||||
field = PreferenceField.INTERFACE_SCALE,
|
||||
transform = { it.copy(interfaceScale = action.value) },
|
||||
)
|
||||
is SettingsUiAction.SetAccent -> update(
|
||||
field = PreferenceField.ACCENT,
|
||||
transform = { it.copy(accentPreference = action.value) },
|
||||
)
|
||||
is SettingsUiAction.SetColorStyle -> update(
|
||||
field = PreferenceField.COLOR_STYLE,
|
||||
transform = { it.copy(colorStyle = action.value) },
|
||||
)
|
||||
is SettingsUiAction.SetColorStandard -> update(
|
||||
field = PreferenceField.COLOR_STANDARD,
|
||||
transform = { it.copy(colorStandard = action.value) },
|
||||
)
|
||||
is SettingsUiAction.SetLiquidGlass -> update(
|
||||
field = PreferenceField.LIQUID_GLASS,
|
||||
transform = { it.copy(liquidGlassEnabled = action.value) },
|
||||
)
|
||||
is SettingsUiAction.SetPredictiveBack -> update(
|
||||
field = PreferenceField.PREDICTIVE_BACK,
|
||||
transform = { it.copy(predictiveBackEnabled = action.value) },
|
||||
)
|
||||
SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null)
|
||||
}
|
||||
}
|
||||
@@ -118,5 +146,11 @@ sealed interface SettingsUiAction {
|
||||
data class SetBlur(val value: Boolean) : SettingsUiAction
|
||||
data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction
|
||||
data class SetHaptics(val value: Boolean) : SettingsUiAction
|
||||
data class SetInterfaceScale(val value: InterfaceScale) : SettingsUiAction
|
||||
data class SetAccent(val value: AccentPreference) : SettingsUiAction
|
||||
data class SetColorStyle(val value: ColorStyle) : SettingsUiAction
|
||||
data class SetColorStandard(val value: ColorStandard) : SettingsUiAction
|
||||
data class SetLiquidGlass(val value: Boolean) : SettingsUiAction
|
||||
data class SetPredictiveBack(val value: Boolean) : SettingsUiAction
|
||||
data object DismissError : SettingsUiAction
|
||||
}
|
||||
|
||||
+94
@@ -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)
|
||||
}
|
||||
+92
-63
@@ -6,6 +6,9 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.FlowRowScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
@@ -81,12 +84,11 @@ import com.flagship.abox.manager.domain.SceneIcon
|
||||
import com.flagship.abox.manager.domain.SceneId
|
||||
import com.flagship.abox.manager.domain.SceneTrigger
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.domain.DeviceType
|
||||
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
|
||||
private const val DEFAULT_SOCKET_DEVICE_NAME = "Zigbee插座控制器"
|
||||
private const val DEFAULT_TH_DEVICE_NAME = "温湿度传感器"
|
||||
private const val DEFAULT_IR_DEVICE_NAME = "红外控制器"
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -505,7 +507,7 @@ private fun SceneEditorContent(
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SceneChoiceFlow {
|
||||
FilterChip(
|
||||
selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL,
|
||||
onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) },
|
||||
@@ -521,7 +523,7 @@ private fun SceneEditorContent(
|
||||
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SceneChoiceFlow {
|
||||
FilterChip(
|
||||
selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR,
|
||||
onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) },
|
||||
@@ -572,6 +574,7 @@ private fun SceneEditorContent(
|
||||
|
||||
if (showTriggerDialog) {
|
||||
AddTriggerDialog(
|
||||
availableDevices = state.availableDevices,
|
||||
onDismiss = { showTriggerDialog = false },
|
||||
onAdd = { triggerType, devName, time, temp, hum, op ->
|
||||
when (triggerType) {
|
||||
@@ -586,6 +589,7 @@ private fun SceneEditorContent(
|
||||
|
||||
if (showConditionDialog) {
|
||||
AddConditionDialog(
|
||||
availableDevices = state.availableDevices,
|
||||
onDismiss = { showConditionDialog = false },
|
||||
onAddTemp = { devName, op, temp ->
|
||||
viewModel.addTemperatureCondition(devName, op, temp)
|
||||
@@ -609,6 +613,7 @@ private fun SceneEditorContent(
|
||||
if (showActionDialog) {
|
||||
AddActionDialog(
|
||||
availableRemotes = state.availableRemotes,
|
||||
availableDevices = state.availableDevices,
|
||||
onDismiss = { showActionDialog = false },
|
||||
viewModel = viewModel,
|
||||
onAddSocket = { name, power ->
|
||||
@@ -745,7 +750,7 @@ private fun ActionItemRow(
|
||||
|
||||
@Composable
|
||||
private fun SceneTrigger.describe(): String = when (this) {
|
||||
is SceneTrigger.Time -> "⏰ $timeOfDay"
|
||||
is SceneTrigger.Time -> "定时 $timeOfDay"
|
||||
is SceneTrigger.Temperature -> stringResource(R.string.scene_trigger_desc_temp, operator.symbol, thresholdCelsius.toInt().toString())
|
||||
is SceneTrigger.Humidity -> stringResource(R.string.scene_trigger_desc_humidity, operator.symbol, thresholdPercent.toInt().toString())
|
||||
is SceneTrigger.DeviceState -> stringResource(R.string.scene_trigger_desc_device, deviceName.value, expectedState)
|
||||
@@ -768,13 +773,28 @@ private fun SceneAction.describe(): String = when (this) {
|
||||
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
internal fun SceneChoiceFlow(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable FlowRowScope.() -> Unit,
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddTriggerDialog(
|
||||
availableDevices: List<DeviceDescriptor>,
|
||||
onDismiss: () -> Unit,
|
||||
onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit,
|
||||
) {
|
||||
var selectedType by remember { mutableIntStateOf(0) }
|
||||
var deviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
||||
var deviceName by remember { mutableStateOf("") }
|
||||
var timeInput by remember { mutableStateOf("07:30") }
|
||||
var tempThreshold by remember { mutableDoubleStateOf(28.0) }
|
||||
var humThreshold by remember { mutableDoubleStateOf(40.0) }
|
||||
@@ -782,14 +802,18 @@ private fun AddTriggerDialog(
|
||||
|
||||
val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") }
|
||||
val isTimeValid = timeRegex.matches(timeInput.trim())
|
||||
val isDeviceValid = selectedType == 0 || deviceName.isNotBlank()
|
||||
val matchingDevices = availableDevices.filter {
|
||||
it.type == if (selectedType == 1 || selectedType == 2) DeviceType.TEMPERATURE_HUMIDITY else DeviceType.SOCKET
|
||||
}
|
||||
val isDeviceValid = selectedType == 0 && isTimeValid || selectedType != 0 &&
|
||||
matchingDevices.any { it.name.value == deviceName }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) })
|
||||
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
||||
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
||||
@@ -809,14 +833,17 @@ private fun AddTriggerDialog(
|
||||
)
|
||||
}
|
||||
1, 2 -> {
|
||||
OutlinedTextField(
|
||||
value = deviceName,
|
||||
onValueChange = { deviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
SceneChoiceFlow {
|
||||
matchingDevices.forEach { device ->
|
||||
FilterChip(
|
||||
selected = deviceName == device.name.value,
|
||||
onClick = { deviceName = device.name.value },
|
||||
label = { Text(device.name.value) },
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
}
|
||||
}
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||
}
|
||||
@@ -848,6 +875,7 @@ private fun AddTriggerDialog(
|
||||
|
||||
@Composable
|
||||
private fun AddConditionDialog(
|
||||
availableDevices: List<DeviceDescriptor>,
|
||||
onDismiss: () -> Unit,
|
||||
onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||
onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit,
|
||||
@@ -855,9 +883,9 @@ private fun AddConditionDialog(
|
||||
onAddTimeRange: (String, String) -> Unit,
|
||||
) {
|
||||
var selectedType by remember { mutableIntStateOf(0) }
|
||||
var tempDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
||||
var humDeviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) }
|
||||
var socketDeviceName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) }
|
||||
var tempDeviceName by remember { mutableStateOf("") }
|
||||
var humDeviceName by remember { mutableStateOf("") }
|
||||
var socketDeviceName by remember { mutableStateOf("") }
|
||||
var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) }
|
||||
var tempVal by remember { mutableDoubleStateOf(26.0) }
|
||||
var humVal by remember { mutableDoubleStateOf(50.0) }
|
||||
@@ -870,10 +898,13 @@ private fun AddConditionDialog(
|
||||
val isEndTimeValid = timeRegex.matches(endTime.trim())
|
||||
val isTimeRangeValid = isStartTimeValid && isEndTimeValid
|
||||
|
||||
val sensors = availableDevices.filter { it.type == DeviceType.TEMPERATURE_HUMIDITY }
|
||||
val sockets = availableDevices.filter { it.type == DeviceType.SOCKET }
|
||||
|
||||
val isDeviceValid = when (selectedType) {
|
||||
0 -> tempDeviceName.isNotBlank()
|
||||
1 -> humDeviceName.isNotBlank()
|
||||
2 -> socketDeviceName.isNotBlank()
|
||||
0 -> sensors.any { it.name.value == tempDeviceName }
|
||||
1 -> sensors.any { it.name.value == humDeviceName }
|
||||
2 -> sockets.any { it.name.value == socketDeviceName }
|
||||
else -> true
|
||||
}
|
||||
|
||||
@@ -882,7 +913,7 @@ private fun AddConditionDialog(
|
||||
title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
|
||||
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
|
||||
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
||||
@@ -890,14 +921,13 @@ private fun AddConditionDialog(
|
||||
}
|
||||
when (selectedType) {
|
||||
0 -> {
|
||||
OutlinedTextField(
|
||||
value = tempDeviceName,
|
||||
onValueChange = { tempDeviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
SceneChoiceFlow {
|
||||
sensors.forEach { device ->
|
||||
FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||
}
|
||||
}
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||
}
|
||||
@@ -905,14 +935,13 @@ private fun AddConditionDialog(
|
||||
Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f)
|
||||
}
|
||||
1 -> {
|
||||
OutlinedTextField(
|
||||
value = humDeviceName,
|
||||
onValueChange = { humDeviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
SceneChoiceFlow {
|
||||
sensors.forEach { device ->
|
||||
FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||
}
|
||||
}
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
|
||||
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
|
||||
}
|
||||
@@ -920,15 +949,14 @@ private fun AddConditionDialog(
|
||||
Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f)
|
||||
}
|
||||
2 -> {
|
||||
OutlinedTextField(
|
||||
value = socketDeviceName,
|
||||
onValueChange = { socketDeviceName = it },
|
||||
label = { Text(stringResource(R.string.scene_device_name_label)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text(stringResource(R.string.scene_device_name_label))
|
||||
SceneChoiceFlow {
|
||||
sockets.forEach { device ->
|
||||
FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) })
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.scene_target_socket_state))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
||||
}
|
||||
@@ -985,6 +1013,7 @@ private fun AddConditionDialog(
|
||||
@Composable
|
||||
private fun AddActionDialog(
|
||||
availableRemotes: List<VirtualRemote>,
|
||||
availableDevices: List<com.flagship.abox.manager.domain.DeviceDescriptor>,
|
||||
onDismiss: () -> Unit,
|
||||
viewModel: SceneEditorViewModel,
|
||||
onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit,
|
||||
@@ -997,7 +1026,7 @@ private fun AddActionDialog(
|
||||
val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title)
|
||||
val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg)
|
||||
|
||||
var socketName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) }
|
||||
var socketName by remember { mutableStateOf(availableDevices.firstOrNull { it.type == DeviceType.SOCKET }?.name?.value.orEmpty()) }
|
||||
var socketPower by remember { mutableStateOf(SocketPowerState.ON) }
|
||||
|
||||
var selectedRemoteIndex by remember { mutableIntStateOf(0) }
|
||||
@@ -1032,7 +1061,7 @@ private fun AddActionDialog(
|
||||
title = { Text(stringResource(R.string.scene_dialog_add_action_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
|
||||
FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) })
|
||||
FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) })
|
||||
@@ -1040,15 +1069,18 @@ private fun AddActionDialog(
|
||||
}
|
||||
when (actionType) {
|
||||
0 -> {
|
||||
OutlinedTextField(
|
||||
value = socketName,
|
||||
onValueChange = { socketName = it },
|
||||
label = { Text(stringResource(R.string.scene_select_socket)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
Text(stringResource(R.string.scene_select_socket))
|
||||
SceneChoiceFlow {
|
||||
availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device ->
|
||||
FilterChip(
|
||||
selected = socketName == device.name.value,
|
||||
onClick = { socketName = device.name.value },
|
||||
label = { Text(device.name.value) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.scene_socket_power_state))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
SceneChoiceFlow {
|
||||
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
|
||||
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
|
||||
}
|
||||
@@ -1056,10 +1088,9 @@ private fun AddActionDialog(
|
||||
1 -> {
|
||||
if (availableRemotes.isEmpty()) {
|
||||
Text(stringResource(R.string.scene_no_virtual_remotes))
|
||||
OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_slot_label)) }, singleLine = true)
|
||||
} else {
|
||||
Text(stringResource(R.string.scene_select_virtual_remote))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SceneChoiceFlow {
|
||||
availableRemotes.forEachIndexed { idx, remote ->
|
||||
FilterChip(
|
||||
selected = selectedRemoteIndex == idx,
|
||||
@@ -1070,7 +1101,7 @@ private fun AddActionDialog(
|
||||
}
|
||||
Text(stringResource(R.string.scene_select_remote_button))
|
||||
if (keyBindings.isNotEmpty()) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SceneChoiceFlow {
|
||||
keyBindings.take(4).forEach { binding ->
|
||||
val label = binding.displayName.ifBlank { binding.buttonId }
|
||||
FilterChip(
|
||||
@@ -1106,8 +1137,8 @@ private fun AddActionDialog(
|
||||
},
|
||||
confirmButton = {
|
||||
val canConfirm = when (actionType) {
|
||||
0 -> socketName.isNotBlank()
|
||||
1 -> selectedButtonId.isNotBlank()
|
||||
0 -> availableDevices.any { it.type == DeviceType.SOCKET && it.name.value == socketName }
|
||||
1 -> availableRemotes.isNotEmpty() && selectedButtonId.isNotBlank()
|
||||
3 -> notifyTitle.isNotBlank()
|
||||
else -> true
|
||||
}
|
||||
@@ -1119,8 +1150,6 @@ private fun AddActionDialog(
|
||||
val remote = availableRemotes.getOrNull(selectedRemoteIndex)
|
||||
if (remote != null) {
|
||||
onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName)
|
||||
} else {
|
||||
onAddRemote("default-ir", DEFAULT_IR_DEVICE_NAME, selectedButtonId, selectedButtonName)
|
||||
}
|
||||
}
|
||||
2 -> onAddDelay(delaySeconds)
|
||||
|
||||
+26
-2
@@ -6,6 +6,9 @@ import com.flagship.abox.manager.domain.AutomationType
|
||||
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DeviceCatalog
|
||||
import com.flagship.abox.manager.domain.DeviceDescriptor
|
||||
import com.flagship.abox.manager.domain.DeviceType
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
@@ -50,6 +53,7 @@ data class SceneEditorState(
|
||||
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
||||
val enableLogging: Boolean = true,
|
||||
val availableRemotes: List<VirtualRemote> = emptyList(),
|
||||
val availableDevices: List<DeviceDescriptor> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val isSaving: Boolean = false,
|
||||
val isSaved: Boolean = false,
|
||||
@@ -64,6 +68,7 @@ class SceneEditorViewModel @Inject constructor(
|
||||
private val sceneRepository: SceneRepository,
|
||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||
private val profileStore: ProviderProfileStore,
|
||||
private val deviceCatalog: DeviceCatalog,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SceneEditorState())
|
||||
@@ -74,6 +79,23 @@ class SceneEditorViewModel @Inject constructor(
|
||||
|
||||
init {
|
||||
observeAvailableRemotes()
|
||||
observeAvailableDevices()
|
||||
}
|
||||
|
||||
private fun observeAvailableDevices() {
|
||||
viewModelScope.launch {
|
||||
profileStore.observeActiveProfile().collectLatest { profile ->
|
||||
if (profile == null) {
|
||||
_state.update { it.copy(availableDevices = emptyList()) }
|
||||
} else {
|
||||
deviceCatalog.observeDevices(profile.id).collectLatest { result ->
|
||||
if (result is DomainResult.Success) {
|
||||
_state.update { it.copy(availableDevices = result.value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeAvailableRemotes() {
|
||||
@@ -191,11 +213,13 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
|
||||
val parsedState = runCatching { SocketPowerState.valueOf(expectedState.trim().uppercase()) }.getOrNull()
|
||||
?: return
|
||||
val trigger = SceneTrigger.DeviceState(
|
||||
id = SceneTriggerId(UUID.randomUUID().toString()),
|
||||
deviceName = DeviceName(deviceName),
|
||||
expectedState = expectedState,
|
||||
label = "$deviceName 状态变为 $expectedState",
|
||||
expectedState = parsedState,
|
||||
label = "$deviceName 状态变为 ${parsedState.name}",
|
||||
)
|
||||
_state.update { it.copy(triggers = it.triggers + trigger) }
|
||||
}
|
||||
|
||||
+16
-4
@@ -8,6 +8,9 @@ import com.flagship.abox.manager.domain.Scene
|
||||
import com.flagship.abox.manager.domain.SceneExecutionResult
|
||||
import com.flagship.abox.manager.domain.SceneExecutor
|
||||
import com.flagship.abox.manager.domain.SceneId
|
||||
import com.flagship.abox.manager.domain.SceneExecutionId
|
||||
import com.flagship.abox.manager.domain.SceneExecutionSource
|
||||
import com.flagship.abox.manager.domain.SceneExecutionUpdate
|
||||
import com.flagship.abox.manager.domain.SceneRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
@@ -15,6 +18,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
sealed interface SceneExecutionUiState {
|
||||
data object Loading : SceneExecutionUiState
|
||||
@@ -71,11 +75,19 @@ class SceneExecutionViewModel @Inject constructor(
|
||||
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
|
||||
return@launch
|
||||
}
|
||||
when (val result = sceneExecutor.execute(scene)) {
|
||||
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
|
||||
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
|
||||
result.error.detail ?: "Execution failed",
|
||||
sceneExecutor.execute(
|
||||
scene = scene,
|
||||
executionId = SceneExecutionId("manual-${scene.id.value}"),
|
||||
source = SceneExecutionSource.USER,
|
||||
).collect { update ->
|
||||
when (update) {
|
||||
is SceneExecutionUpdate.Completed -> _uiState.value = SceneExecutionUiState.Completed(update.result)
|
||||
is SceneExecutionUpdate.Rejected -> _uiState.value = SceneExecutionUiState.Failed(
|
||||
update.error.detail ?: "Execution failed",
|
||||
)
|
||||
is SceneExecutionUpdate.Started -> _uiState.value = SceneExecutionUiState.Running(update.scene)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
-45
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
@@ -28,10 +29,11 @@ import androidx.compose.material.icons.outlined.Bolt
|
||||
import androidx.compose.material.icons.outlined.Schedule
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.FilledTonalButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
@@ -49,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -68,7 +71,10 @@ import com.flagship.abox.manager.domain.Scene
|
||||
import com.flagship.abox.manager.domain.SceneColor
|
||||
import com.flagship.abox.manager.domain.SceneIcon
|
||||
import com.flagship.abox.manager.domain.SceneId
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
@@ -84,6 +90,23 @@ fun SceneListScreen(
|
||||
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
|
||||
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||
var showFabMenu by remember { mutableStateOf(false) }
|
||||
var fabExpanded by remember { mutableStateOf(true) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(listState) {
|
||||
var previousIndex = 0
|
||||
var previousOffset = 0
|
||||
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
|
||||
.distinctUntilChanged()
|
||||
.collect { (index, offset) ->
|
||||
val movingUp = index > previousIndex || (index == previousIndex && offset > previousOffset)
|
||||
val movingDown = index < previousIndex || (index == previousIndex && offset < previousOffset)
|
||||
if (movingUp) fabExpanded = false
|
||||
if (movingDown || (index == 0 && offset == 0)) fabExpanded = true
|
||||
previousIndex = index
|
||||
previousOffset = offset
|
||||
}
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -97,37 +120,30 @@ fun SceneListScreen(
|
||||
Scaffold(
|
||||
containerColor = Color.Transparent,
|
||||
floatingActionButton = {
|
||||
Box {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = { showFabMenu = true },
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.scene_create_action)) },
|
||||
expanded = fabExpanded,
|
||||
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 ->
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
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
|
||||
@@ -250,9 +328,10 @@ private fun SceneCard(
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = scene.icon.displayEmoji(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
ABoxIcon(
|
||||
key = scene.icon.toABoxIconKey(),
|
||||
contentDescription = scene.name,
|
||||
modifier = Modifier.size(28.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
@@ -375,10 +454,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "⚡",
|
||||
style = MaterialTheme.typography.displayMedium,
|
||||
)
|
||||
ABoxIcon(ABoxIconKey.SCENES, contentDescription = "暂无情景", modifier = Modifier.size(48.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.scene_empty_title),
|
||||
@@ -396,19 +472,19 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
fun SceneIcon.displayEmoji(): String = when (this) {
|
||||
SceneIcon.HOME -> "🏠"
|
||||
SceneIcon.OFFICE -> "💼"
|
||||
SceneIcon.NIGHT -> "🌙"
|
||||
SceneIcon.MOVIE -> "🎬"
|
||||
SceneIcon.AWAY -> "🚶"
|
||||
SceneIcon.READING -> "📖"
|
||||
SceneIcon.DINING -> "🍽️"
|
||||
SceneIcon.GAMING -> "🎮"
|
||||
SceneIcon.THERMOSTAT -> "🌡️"
|
||||
SceneIcon.LIGHT -> "💡"
|
||||
SceneIcon.CLEAN -> "🧹"
|
||||
SceneIcon.CUSTOM -> "✨"
|
||||
fun SceneIcon.toABoxIconKey(): ABoxIconKey = when (this) {
|
||||
SceneIcon.HOME -> ABoxIconKey.DEVICES
|
||||
SceneIcon.OFFICE -> ABoxIconKey.ACTIVITY
|
||||
SceneIcon.NIGHT -> ABoxIconKey.PENDING
|
||||
SceneIcon.MOVIE -> ABoxIconKey.SCENES
|
||||
SceneIcon.AWAY -> ABoxIconKey.OFFLINE
|
||||
SceneIcon.READING -> ABoxIconKey.DEVICES
|
||||
SceneIcon.DINING -> ABoxIconKey.DEVICES
|
||||
SceneIcon.GAMING -> ABoxIconKey.SCENES
|
||||
SceneIcon.THERMOSTAT -> ABoxIconKey.TEMPERATURE
|
||||
SceneIcon.LIGHT -> ABoxIconKey.POWER
|
||||
SceneIcon.CLEAN -> ABoxIconKey.REFRESH
|
||||
SceneIcon.CUSTOM -> ABoxIconKey.UNKNOWN_DEVICE
|
||||
}
|
||||
|
||||
internal fun SceneColor.toColorOrNull(): Color? {
|
||||
|
||||
-183
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+176
@@ -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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -35,6 +36,10 @@ import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
|
||||
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
|
||||
import com.flagship.abox.manager.designsystem.LocalABoxNavigationPresentation
|
||||
import com.flagship.abox.manager.designsystem.LocalABoxRenderer
|
||||
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||
|
||||
/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
|
||||
@Composable
|
||||
@@ -46,13 +51,18 @@ fun MainAppShell(
|
||||
onTabClick: (MainTab) -> Unit,
|
||||
onProviderClick: () -> Unit,
|
||||
onAvatarClick: () -> Unit,
|
||||
showNavigationChrome: Boolean = true,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
|
||||
val renderer = LocalABoxRenderer.current
|
||||
val floating = renderer == DesignRenderer.MIUIX &&
|
||||
LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
if (showNavigationChrome) {
|
||||
ProviderTopBar(
|
||||
currentTab = currentTab,
|
||||
providerName = providerName,
|
||||
@@ -60,14 +70,55 @@ fun MainAppShell(
|
||||
onProviderClick = onProviderClick,
|
||||
onAvatarClick = onAvatarClick,
|
||||
)
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
if (!useRail) {
|
||||
ABoxNavigationBar {
|
||||
if (showNavigationChrome && !useRail) {
|
||||
MainBottomBar(currentTab, onTabClick, renderer, floating)
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (showNavigationChrome && useRail) {
|
||||
MainNavigationRail(
|
||||
currentTab = currentTab,
|
||||
onTabClick = onTabClick,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.weight(1f).fillMaxHeight(),
|
||||
) {
|
||||
content(innerPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MainBottomBar(
|
||||
currentTab: MainTab,
|
||||
onTabClick: (MainTab) -> Unit,
|
||||
renderer: DesignRenderer,
|
||||
floating: Boolean,
|
||||
) {
|
||||
if (renderer == DesignRenderer.MIUIX) {
|
||||
Surface(
|
||||
modifier = if (floating) {
|
||||
Modifier.padding(horizontal = 20.dp, vertical = 8.dp).clip(RoundedCornerShape(36.dp))
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
shape = if (floating) RoundedCornerShape(36.dp) else RoundedCornerShape(0.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
tonalElevation = if (floating) 3.dp else 0.dp,
|
||||
) {
|
||||
ABoxNavigationBar(containerColor = Color.Transparent) {
|
||||
MainTab.entries.forEach { tab ->
|
||||
val selected = tab == currentTab
|
||||
ABoxNavigationBarItem(
|
||||
selected = selected,
|
||||
selected = tab == currentTab,
|
||||
onClick = { onTabClick(tab) },
|
||||
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
|
||||
icon = {
|
||||
@@ -81,20 +132,18 @@ fun MainAppShell(
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { innerPadding ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
if (useRail) {
|
||||
MainNavigationRail(
|
||||
currentTab = currentTab,
|
||||
onTabClick = onTabClick,
|
||||
} else {
|
||||
ABoxNavigationBar {
|
||||
MainTab.entries.forEach { tab ->
|
||||
val selected = tab == currentTab
|
||||
ABoxNavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = { onTabClick(tab) },
|
||||
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
|
||||
icon = { Icon(if (selected) tab.selectedIcon else tab.unselectedIcon, stringResource(tab.labelRes)) },
|
||||
label = { Text(stringResource(tab.labelRes)) },
|
||||
)
|
||||
}
|
||||
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.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -38,6 +39,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -47,12 +50,17 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
|
||||
import com.flagship.abox.manager.designsystem.ABoxIconButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||
import com.flagship.abox.manager.designsystem.ABoxStatus
|
||||
import com.flagship.abox.manager.designsystem.ABoxStatusBanner
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import java.text.SimpleDateFormat
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun SocketRoute(
|
||||
@@ -79,38 +87,29 @@ fun SocketScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
val configuration = LocalConfiguration.current
|
||||
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||
ABoxDeviceDetailScaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.deviceName?.value ?: stringResource(R.string.socket_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
title = state.deviceName?.value ?: stringResource(R.string.socket_title),
|
||||
onBack = onBackClick,
|
||||
subtitle = stringResource(R.string.socket_detail_subtitle),
|
||||
icon = ABoxIconKey.SOCKET,
|
||||
action = {
|
||||
Row {
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.EDIT,
|
||||
contentDescription = stringResource(R.string.socket_name_settings),
|
||||
onClick = { showDeviceNameDialog = true },
|
||||
enabled = !state.isOperating,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { showEditNameDialog = true },
|
||||
enabled = !state.isLoading && !state.isOperating,
|
||||
) {
|
||||
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
IconButton(
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.REFRESH,
|
||||
contentDescription = stringResource(R.string.action_refresh),
|
||||
onClick = { onEvent(SocketUiEvent.Refresh) },
|
||||
enabled = !state.isLoading && !state.isOperating,
|
||||
) {
|
||||
Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
@@ -136,20 +135,28 @@ fun SocketScreen(
|
||||
)
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxWidth().height(64.dp)) {
|
||||
SocketOperationBanner(operation = state.operation)
|
||||
}
|
||||
|
||||
// Central Status Card
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
.padding(top = 8.dp, bottom = 18.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
containerColor = if (state.isPowerOn) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
.padding(horizontal = 24.dp, vertical = 32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
@@ -157,13 +164,16 @@ fun SocketScreen(
|
||||
powerState = state.powerState,
|
||||
isOffline = state.isOffline,
|
||||
isLoading = state.isLoading,
|
||||
isOperating = state.isOperating,
|
||||
enabled = state.isActionEnabled,
|
||||
onClick = { onEvent(SocketUiEvent.TogglePower) },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.socket_status_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
@@ -174,7 +184,7 @@ fun SocketScreen(
|
||||
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
||||
else -> stringResource(R.string.socket_status_unknown)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = when {
|
||||
state.isOffline -> MaterialTheme.colorScheme.error
|
||||
@@ -185,7 +195,9 @@ fun SocketScreen(
|
||||
)
|
||||
|
||||
state.lastConfirmedTimeMillis?.let { timeMillis ->
|
||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
||||
val formattedTime = remember(timeMillis, configuration) {
|
||||
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.socket_confirmed_at, formattedTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -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 = if (state.isPowerOn) {
|
||||
stringResource(R.string.socket_action_turn_off)
|
||||
} else {
|
||||
stringResource(R.string.socket_action_turn_on)
|
||||
},
|
||||
text = stringResource(R.string.socket_direct_control),
|
||||
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(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -232,57 +221,76 @@ fun SocketScreen(
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) },
|
||||
enabled = state.isActionEnabled && state.powerState != SocketPowerState.ON,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) {
|
||||
ABoxIcon(ABoxIconKey.POWER, "开启插座", modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.socket_action_turn_on))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) },
|
||||
enabled = state.isActionEnabled && state.powerState != SocketPowerState.OFF,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
modifier = Modifier.weight(1f).height(56.dp),
|
||||
) {
|
||||
ABoxIcon(ABoxIconKey.POWER, "关闭插座", modifier = Modifier.size(20.dp))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(stringResource(R.string.socket_action_turn_off))
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.socket_confirmation_hint),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 14.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditNameDialog) {
|
||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEditNameDialog = false },
|
||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = inputName,
|
||||
onValueChange = { inputName = it },
|
||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
if (showDeviceNameDialog) {
|
||||
DeviceNameDialog(
|
||||
currentName = state.deviceName?.value.orEmpty(),
|
||||
title = stringResource(R.string.socket_name_settings),
|
||||
description = stringResource(R.string.gateway_device_name_socket_desc),
|
||||
onDismiss = { showDeviceNameDialog = false },
|
||||
onConfirm = { name ->
|
||||
showDeviceNameDialog = false
|
||||
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(name)))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceNameDialog(
|
||||
currentName: String,
|
||||
title: String,
|
||||
description: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var value by remember(currentName) { mutableStateOf(currentName) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(description, style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(stringResource(R.string.gateway_device_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
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))
|
||||
}
|
||||
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -290,6 +298,9 @@ private fun PowerIndicatorBadge(
|
||||
powerState: SocketPowerState?,
|
||||
isOffline: Boolean,
|
||||
isLoading: Boolean,
|
||||
isOperating: Boolean,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val indicatorColor = when {
|
||||
@@ -301,21 +312,71 @@ private fun PowerIndicatorBadge(
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(96.dp)
|
||||
.size(160.dp)
|
||||
.clip(CircleShape)
|
||||
.background(indicatorColor.copy(alpha = 0.15f))
|
||||
.border(3.dp, indicatorColor, CircleShape),
|
||||
.border(4.dp, indicatorColor, CircleShape)
|
||||
.clickable(enabled = enabled, onClickLabel = "切换插座电源", onClick = onClick)
|
||||
.testTag("socket_power_dial"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isLoading) {
|
||||
ABoxLoadingIndicator(modifier = Modifier.size(36.dp))
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
if (isLoading || isOperating) {
|
||||
ABoxLoadingIndicator(modifier = Modifier.size(44.dp))
|
||||
} else {
|
||||
Text(
|
||||
text = if (powerState == SocketPowerState.ON) "⚡" else "○",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = indicatorColor,
|
||||
ABoxIcon(
|
||||
key = ABoxIconKey.POWER,
|
||||
contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭",
|
||||
modifier = Modifier.size(52.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = when {
|
||||
isOperating -> stringResource(R.string.device_status_switching)
|
||||
powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on)
|
||||
powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
||||
else -> stringResource(R.string.device_status_unavailable)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SocketOperationBanner(
|
||||
operation: SocketOperationState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (operation) {
|
||||
is SocketOperationState.PendingCommand -> ABoxStatusBanner(
|
||||
state = ABoxStatus.PENDING,
|
||||
contentDescription = "正在发送电源指令",
|
||||
modifier = modifier,
|
||||
)
|
||||
is SocketOperationState.RefreshingConfirmation -> ABoxStatusBanner(
|
||||
state = ABoxStatus.PENDING,
|
||||
contentDescription = "正在确认插座状态",
|
||||
modifier = modifier,
|
||||
)
|
||||
is SocketOperationState.AwaitingConfirmation -> ABoxStatusBanner(
|
||||
state = ABoxStatus.WARNING,
|
||||
contentDescription = "指令已发送,等待状态确认",
|
||||
modifier = modifier.testTag("socket_awaiting_confirmation"),
|
||||
)
|
||||
is SocketOperationState.Failed -> ABoxStatusBanner(
|
||||
state = ABoxStatus.ERROR,
|
||||
contentDescription = "插座操作失败",
|
||||
modifier = modifier,
|
||||
)
|
||||
SocketOperationState.Unavailable -> ABoxStatusBanner(
|
||||
state = ABoxStatus.OFFLINE,
|
||||
contentDescription = "插座当前不可用",
|
||||
modifier = modifier,
|
||||
)
|
||||
SocketOperationState.IdleConfirmed -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,9 +438,11 @@ private fun ErrorBanner(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("✕")
|
||||
}
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.CLOSE,
|
||||
contentDescription = "关闭错误提示",
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,14 +14,27 @@ data class SocketUiState(
|
||||
val isOffline: Boolean = false,
|
||||
val errorMessage: UiText? = null,
|
||||
val lastConfirmedTimeMillis: Long? = null,
|
||||
val operation: SocketOperationState = SocketOperationState.IdleConfirmed,
|
||||
) {
|
||||
val isActionEnabled: Boolean
|
||||
get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null
|
||||
get() = !isLoading && operation !is SocketOperationState.PendingCommand &&
|
||||
operation !is SocketOperationState.RefreshingConfirmation &&
|
||||
operation !is SocketOperationState.AwaitingConfirmation &&
|
||||
!isOffline && profileId != null && deviceName != null
|
||||
|
||||
val isPowerOn: Boolean
|
||||
get() = powerState == SocketPowerState.ON
|
||||
}
|
||||
|
||||
sealed interface SocketOperationState {
|
||||
data object IdleConfirmed : SocketOperationState
|
||||
data class PendingCommand(val target: SocketPowerState) : SocketOperationState
|
||||
data class RefreshingConfirmation(val target: SocketPowerState) : SocketOperationState
|
||||
data class AwaitingConfirmation(val target: SocketPowerState) : SocketOperationState
|
||||
data class Failed(val target: SocketPowerState) : SocketOperationState
|
||||
data object Unavailable : SocketOperationState
|
||||
}
|
||||
|
||||
sealed interface SocketUiEvent {
|
||||
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
|
||||
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
||||
|
||||
+74
-14
@@ -9,13 +9,18 @@ import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SocketGateway
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
|
||||
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
||||
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.inject.Inject
|
||||
@@ -23,6 +28,8 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class SocketViewModel @Inject constructor(
|
||||
private val socketGateway: SocketGateway,
|
||||
private val configuredDeviceRepository: ConfiguredDeviceRepository,
|
||||
private val activityWriter: ActivityEventWriter,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -56,6 +63,7 @@ class SocketViewModel @Inject constructor(
|
||||
it.copy(
|
||||
profileId = event.profileId,
|
||||
deviceName = event.deviceName,
|
||||
operation = SocketOperationState.Unavailable,
|
||||
)
|
||||
}
|
||||
loadStatus()
|
||||
@@ -63,17 +71,20 @@ class SocketViewModel @Inject constructor(
|
||||
|
||||
is SocketUiEvent.ChangeDeviceName -> {
|
||||
val trimmed = event.deviceName.value.trim()
|
||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceName = DeviceName(trimmed),
|
||||
powerState = null,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = null,
|
||||
)
|
||||
}
|
||||
val current = _state.value
|
||||
val profileId = current.profileId
|
||||
val oldName = current.deviceName
|
||||
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
|
||||
viewModelScope.launch {
|
||||
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
|
||||
is DomainResult.Success -> {
|
||||
_state.update { it.copy(deviceName = DeviceName(trimmed), powerState = null, errorMessage = null, lastConfirmedTimeMillis = null) }
|
||||
loadStatus()
|
||||
}
|
||||
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SocketUiEvent.Refresh -> {
|
||||
@@ -115,6 +126,7 @@ class SocketViewModel @Inject constructor(
|
||||
isOffline = false,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||
operation = SocketOperationState.IdleConfirmed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -126,6 +138,7 @@ class SocketViewModel @Inject constructor(
|
||||
isLoading = false,
|
||||
isOffline = isOffline,
|
||||
errorMessage = DomainErrorMapper.map(result.error),
|
||||
operation = SocketOperationState.Unavailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,36 +152,83 @@ class SocketViewModel @Inject constructor(
|
||||
val deviceName = current.deviceName ?: return
|
||||
|
||||
// Prevent duplicate action if currently operating, loading or offline
|
||||
if (current.isOperating || current.isLoading || current.isOffline) {
|
||||
if (current.isOperating || current.isLoading || current.isOffline || !current.isActionEnabled) {
|
||||
return
|
||||
}
|
||||
|
||||
_state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isOperating = true, errorMessage = null) }
|
||||
when (val result = socketGateway.setPower(profileId, deviceName, targetState)) {
|
||||
when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) {
|
||||
is DomainResult.Success -> {
|
||||
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED)
|
||||
_state.update {
|
||||
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,
|
||||
isOffline = false,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||
operation = SocketOperationState.IdleConfirmed,
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
is DomainResult.Failure -> confirmationError = confirmation.error
|
||||
}
|
||||
}
|
||||
activityWriter.recordDevice(
|
||||
profileId,
|
||||
deviceName,
|
||||
ActivityEventKind.SOCKET_CONFIRMATION,
|
||||
ActivityOutcome.AWAITING_CONFIRMATION,
|
||||
confirmationError?.externalCode,
|
||||
)
|
||||
_state.update {
|
||||
it.copy(
|
||||
isOperating = false,
|
||||
isOffline = confirmationError is DomainError.DeviceOffline,
|
||||
errorMessage = confirmationError?.let(DomainErrorMapper::map),
|
||||
operation = SocketOperationState.AwaitingConfirmation(targetState),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DomainResult.Failure -> {
|
||||
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.FAILED, result.error.externalCode)
|
||||
val isOffline = result.error is DomainError.DeviceOffline
|
||||
_state.update {
|
||||
it.copy(
|
||||
isOperating = false,
|
||||
isOffline = isOffline,
|
||||
errorMessage = DomainErrorMapper.map(result.error),
|
||||
operation = SocketOperationState.Failed(targetState),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val CONFIRMATION_ATTEMPTS = 4
|
||||
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
|
||||
}
|
||||
}
|
||||
|
||||
+90
-85
@@ -31,6 +31,8 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
@@ -39,11 +41,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
|
||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||
import com.flagship.abox.manager.designsystem.ABoxIconButton
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import java.text.SimpleDateFormat
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TemperatureHumidityRoute(
|
||||
@@ -70,37 +75,20 @@ fun TemperatureHumidityScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
val configuration = LocalConfiguration.current
|
||||
var showDeviceNameDialog by remember { mutableStateOf(false) }
|
||||
ABoxDeviceDetailScaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.deviceName?.value ?: stringResource(R.string.th_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { showEditNameDialog = true },
|
||||
title = state.deviceName?.value ?: stringResource(R.string.th_title),
|
||||
onBack = onBackClick,
|
||||
subtitle = stringResource(R.string.device_sensor_subtitle),
|
||||
icon = ABoxIconKey.SENSOR,
|
||||
action = {
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.EDIT,
|
||||
contentDescription = stringResource(R.string.sensor_name_settings),
|
||||
onClick = { showDeviceNameDialog = true },
|
||||
enabled = !state.isLoading && !state.isRefreshing,
|
||||
) {
|
||||
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 ->
|
||||
@@ -151,31 +139,41 @@ fun TemperatureHumidityScreen(
|
||||
)
|
||||
} else {
|
||||
// Telemetry Cards Row / Column
|
||||
Text(
|
||||
text = stringResource(R.string.sensor_current_readings),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
.padding(bottom = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TelemetryMetricCard(
|
||||
title = stringResource(R.string.th_temperature_label),
|
||||
value = state.temperature ?: "--",
|
||||
value = state.temperature,
|
||||
unit = stringResource(R.string.th_unit_celsius),
|
||||
icon = "🌡️",
|
||||
icon = ABoxIconKey.TEMPERATURE,
|
||||
testTag = "sensor_metric_temperature",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
TelemetryMetricCard(
|
||||
title = stringResource(R.string.th_humidity_label),
|
||||
value = state.humidity ?: "--",
|
||||
value = state.humidity,
|
||||
unit = stringResource(R.string.th_unit_percent),
|
||||
icon = "💧",
|
||||
icon = ABoxIconKey.HUMIDITY,
|
||||
testTag = "sensor_metric_humidity",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
state.lastUpdatedTimeMillis?.let { timeMillis ->
|
||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
||||
val formattedTime = remember(timeMillis, configuration) {
|
||||
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.th_last_updated, formattedTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
@@ -189,7 +187,7 @@ fun TemperatureHumidityScreen(
|
||||
|
||||
// Read-Only Informational Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("sensor_monitoring_notice"),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
),
|
||||
@@ -201,13 +199,13 @@ fun TemperatureHumidityScreen(
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.th_read_only_notice),
|
||||
text = stringResource(R.string.sensor_monitoring_title),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.th_read_only_description),
|
||||
text = stringResource(R.string.sensor_monitoring_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
@@ -215,7 +213,7 @@ fun TemperatureHumidityScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Refresh Button
|
||||
ABoxButton(
|
||||
@@ -223,7 +221,7 @@ fun TemperatureHumidityScreen(
|
||||
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
.height(56.dp),
|
||||
) {
|
||||
if (state.isRefreshing || state.isLoading) {
|
||||
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
|
||||
@@ -239,53 +237,59 @@ fun TemperatureHumidityScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditNameDialog) {
|
||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEditNameDialog = false },
|
||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = inputName,
|
||||
onValueChange = { inputName = it },
|
||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
if (showDeviceNameDialog) {
|
||||
SensorDeviceNameDialog(
|
||||
currentName = state.deviceName?.value.orEmpty(),
|
||||
onDismiss = { showDeviceNameDialog = false },
|
||||
onConfirm = { name ->
|
||||
showDeviceNameDialog = false
|
||||
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(name)))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SensorDeviceNameDialog(
|
||||
currentName: String,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (String) -> Unit,
|
||||
) {
|
||||
var value by remember(currentName) { mutableStateOf(currentName) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.sensor_name_settings)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.gateway_device_name_sensor_desc), style = MaterialTheme.typography.bodySmall)
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { value = it },
|
||||
label = { Text(stringResource(R.string.gateway_device_name)) },
|
||||
singleLine = true,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
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))
|
||||
}
|
||||
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TelemetryMetricCard(
|
||||
title: String,
|
||||
value: String,
|
||||
value: String?,
|
||||
unit: String,
|
||||
icon: String,
|
||||
icon: ABoxIconKey,
|
||||
testTag: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (value == null) return
|
||||
Card(
|
||||
modifier = modifier,
|
||||
modifier = modifier.testTag(testTag),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
@@ -298,10 +302,7 @@ private fun TelemetryMetricCard(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
ABoxIcon(icon, contentDescription = title, modifier = Modifier.size(28.dp))
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
@@ -323,7 +324,6 @@ private fun TelemetryMetricCard(
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
if (value != "--") {
|
||||
Text(
|
||||
text = " $unit",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
@@ -333,7 +333,6 @@ private fun TelemetryMetricCard(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -355,7 +354,11 @@ private fun NoDataCard(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("📭", style = MaterialTheme.typography.displaySmall)
|
||||
ABoxIcon(
|
||||
key = ABoxIconKey.SENSOR,
|
||||
contentDescription = "暂无传感器数据",
|
||||
modifier = Modifier.size(40.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.th_no_data_title),
|
||||
@@ -434,9 +437,11 @@ private fun ErrorBanner(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("✕")
|
||||
}
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.CLOSE,
|
||||
contentDescription = "关闭错误提示",
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -16,11 +16,11 @@ data class TemperatureHumidityUiState(
|
||||
val errorMessage: UiText? = null,
|
||||
val lastUpdatedTimeMillis: Long? = null,
|
||||
) {
|
||||
val formattedTemperature: String
|
||||
get() = if (temperature.isNullOrBlank()) "--" else "$temperature °C"
|
||||
val formattedTemperature: String?
|
||||
get() = temperature?.takeIf { it.isNotBlank() }?.let { "$it °C" }
|
||||
|
||||
val formattedHumidity: String
|
||||
get() = if (humidity.isNullOrBlank()) "--" else "$humidity %"
|
||||
val formattedHumidity: String?
|
||||
get() = humidity?.takeIf { it.isNotBlank() }?.let { "$it %" }
|
||||
|
||||
val hasValidData: Boolean
|
||||
get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank()
|
||||
|
||||
+23
-14
@@ -8,7 +8,11 @@ import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
|
||||
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
||||
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -22,6 +26,8 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class TemperatureHumidityViewModel @Inject constructor(
|
||||
private val thGateway: TemperatureHumidityGateway,
|
||||
private val configuredDeviceRepository: ConfiguredDeviceRepository,
|
||||
private val activityWriter: ActivityEventWriter,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
@@ -62,19 +68,20 @@ class TemperatureHumidityViewModel @Inject constructor(
|
||||
|
||||
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
|
||||
val trimmed = event.deviceName.value.trim()
|
||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceName = DeviceName(trimmed),
|
||||
temperature = null,
|
||||
humidity = null,
|
||||
hasNoData = false,
|
||||
errorMessage = null,
|
||||
lastUpdatedTimeMillis = null,
|
||||
)
|
||||
}
|
||||
val current = _state.value
|
||||
val profileId = current.profileId
|
||||
val oldName = current.deviceName
|
||||
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
|
||||
viewModelScope.launch {
|
||||
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
|
||||
is DomainResult.Success -> {
|
||||
_state.update { it.copy(deviceName = DeviceName(trimmed), temperature = null, humidity = null, hasNoData = false, errorMessage = null, lastUpdatedTimeMillis = null) }
|
||||
loadReading(isRefresh = false)
|
||||
}
|
||||
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TemperatureHumidityUiEvent.Refresh -> {
|
||||
@@ -100,12 +107,13 @@ class TemperatureHumidityViewModel @Inject constructor(
|
||||
|
||||
when (val result = thGateway.getReading(profileId, deviceName)) {
|
||||
is DomainResult.Success -> {
|
||||
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.SUCCEEDED)
|
||||
val reading = result.value
|
||||
val isBlankData = reading.temperature.isBlank() && reading.humidity.isBlank()
|
||||
val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank()
|
||||
_state.update {
|
||||
it.copy(
|
||||
temperature = reading.temperature.takeIf { t -> t.isNotBlank() },
|
||||
humidity = reading.humidity.takeIf { h -> h.isNotBlank() },
|
||||
temperature = reading.temperature?.takeIf { t -> t.isNotBlank() },
|
||||
humidity = reading.humidity?.takeIf { h -> h.isNotBlank() },
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
isOffline = false,
|
||||
@@ -117,6 +125,7 @@ class TemperatureHumidityViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
is DomainResult.Failure -> {
|
||||
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.FAILED, result.error.externalCode)
|
||||
val isOffline = result.error is DomainError.DeviceOffline
|
||||
val hasNoData = result.error is DomainError.NoData
|
||||
_state.update {
|
||||
|
||||
@@ -82,9 +82,9 @@
|
||||
<string name="scene_section_other">其他选项与外观</string>
|
||||
|
||||
<!-- Add Buttons -->
|
||||
<string name="scene_add_trigger">+ 新增触发器</string>
|
||||
<string name="scene_add_condition">+ 新增条件</string>
|
||||
<string name="scene_add_action_btn">+ 新增动作</string>
|
||||
<string name="scene_add_trigger">新增触发器</string>
|
||||
<string name="scene_add_condition">新增条件</string>
|
||||
<string name="scene_add_action_btn">新增动作</string>
|
||||
<string name="scene_add_action">添加</string>
|
||||
<string name="scene_save_action">保存指令</string>
|
||||
|
||||
@@ -104,10 +104,10 @@
|
||||
|
||||
<!-- Trigger Types & Dialog -->
|
||||
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
|
||||
<string name="scene_trigger_type_time">⏰ 定时时间触发</string>
|
||||
<string name="scene_trigger_type_temp">🌡️ 环境温度触发</string>
|
||||
<string name="scene_trigger_type_humidity">💧 环境湿度触发</string>
|
||||
<string name="scene_trigger_type_device">🔌 插座状态变更触发</string>
|
||||
<string name="scene_trigger_type_time">定时时间触发</string>
|
||||
<string name="scene_trigger_type_temp">环境温度触发</string>
|
||||
<string name="scene_trigger_type_humidity">环境湿度触发</string>
|
||||
<string name="scene_trigger_type_device">插座状态变更触发</string>
|
||||
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
|
||||
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
|
||||
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
|
||||
@@ -118,19 +118,19 @@
|
||||
|
||||
<!-- Condition Types & Dialog -->
|
||||
<string name="scene_dialog_add_condition_title">选择限制条件</string>
|
||||
<string name="scene_condition_type_temp">🌡️ 环境温度范围</string>
|
||||
<string name="scene_condition_type_humidity">💧 环境湿度范围</string>
|
||||
<string name="scene_condition_type_socket">🔌 插座当前电源状态</string>
|
||||
<string name="scene_condition_type_time_range">🕒 生效时间区间</string>
|
||||
<string name="scene_condition_type_temp">环境温度范围</string>
|
||||
<string name="scene_condition_type_humidity">环境湿度范围</string>
|
||||
<string name="scene_condition_type_socket">插座当前电源状态</string>
|
||||
<string name="scene_condition_type_time_range">生效时间区间</string>
|
||||
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
|
||||
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
|
||||
|
||||
<!-- Action Types & Dialog -->
|
||||
<string name="scene_dialog_add_action_title">选择执行动作</string>
|
||||
<string name="scene_action_type_socket">🔌 智能插座控制</string>
|
||||
<string name="scene_action_type_virtual_remote">📺 红外遥控按键</string>
|
||||
<string name="scene_action_type_delay">⏱️ 延时等待 (缓冲)</string>
|
||||
<string name="scene_action_type_notification">🔔 发送系统提示</string>
|
||||
<string name="scene_action_type_socket">智能插座控制</string>
|
||||
<string name="scene_action_type_virtual_remote">红外遥控按键</string>
|
||||
<string name="scene_action_type_delay">延时等待(缓冲)</string>
|
||||
<string name="scene_action_type_notification">发送系统提示</string>
|
||||
<string name="scene_select_socket">选择目标插座</string>
|
||||
<string name="scene_socket_power_state">设定电源状态</string>
|
||||
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
|
||||
@@ -144,8 +144,8 @@
|
||||
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
|
||||
<string name="scene_action_remote">遥控【%1$s】: 发送 %2$s</string>
|
||||
<string name="scene_action_infrared">红外【%1$s】: 槽位 %2$s</string>
|
||||
<string name="scene_action_delay">⏱️ 等待 %1$d 秒</string>
|
||||
<string name="scene_action_notification">🔔 提示: %1$s</string>
|
||||
<string name="scene_action_delay">等待 %1$d 秒</string>
|
||||
<string name="scene_action_notification">提示:%1$s</string>
|
||||
|
||||
<!-- Scene: execution -->
|
||||
<string name="scene_execution_title">执行指令</string>
|
||||
@@ -164,13 +164,13 @@
|
||||
<!-- 校验与弹窗选项/占位文案 -->
|
||||
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</string>
|
||||
<string name="scene_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
|
||||
<string name="scene_chip_time">⏰ 定时</string>
|
||||
<string name="scene_chip_temp">🌡️ 温度</string>
|
||||
<string name="scene_chip_humidity">💧 湿度</string>
|
||||
<string name="scene_chip_socket">🔌 插座</string>
|
||||
<string name="scene_chip_remote">📺 遥控</string>
|
||||
<string name="scene_chip_delay">⏱️ 延时</string>
|
||||
<string name="scene_chip_notify">🔔 提示</string>
|
||||
<string name="scene_chip_time">定时</string>
|
||||
<string name="scene_chip_temp">温度</string>
|
||||
<string name="scene_chip_humidity">湿度</string>
|
||||
<string name="scene_chip_socket">插座</string>
|
||||
<string name="scene_chip_remote">遥控</string>
|
||||
<string name="scene_chip_delay">延时</string>
|
||||
<string name="scene_chip_notify">提示</string>
|
||||
<string name="scene_chip_above">> 高于</string>
|
||||
<string name="scene_chip_below">< 低于</string>
|
||||
<string name="scene_chip_power_on">开启</string>
|
||||
@@ -184,7 +184,7 @@
|
||||
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
|
||||
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
|
||||
<string name="scene_empty_actions_hint">尚未添加执行动作,请点击下方按钮添加</string>
|
||||
<string name="scene_summary_triggers_and_actions">⚡ %1$d 个触发器 • %2$d 个动作</string>
|
||||
<string name="scene_summary_triggers_and_actions">%1$d 个触发器 · %2$d 个动作</string>
|
||||
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
|
||||
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
|
||||
<string name="scene_exec_rerun">重新执行</string>
|
||||
@@ -259,4 +259,127 @@
|
||||
<string name="error_operation_failed">操作执行失败,请重试。</string>
|
||||
<string name="error_no_data">暂无可用数据。</string>
|
||||
<string name="error_unknown">发生未知错误。</string>
|
||||
|
||||
<!-- Interface settings -->
|
||||
<string name="settings_title">界面设置</string>
|
||||
<string name="settings_style_section">样式</string>
|
||||
<string name="settings_renderer_label">渲染器</string>
|
||||
<string name="settings_renderer_description">选择应用使用的界面渲染方式</string>
|
||||
<string name="settings_renderer_material3">Material 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>
|
||||
|
||||
@@ -82,9 +82,9 @@
|
||||
<string name="scene_section_other">Other Options & Appearance</string>
|
||||
|
||||
<!-- Add Buttons -->
|
||||
<string name="scene_add_trigger">+ Add Trigger</string>
|
||||
<string name="scene_add_condition">+ Add Condition</string>
|
||||
<string name="scene_add_action_btn">+ Add Action</string>
|
||||
<string name="scene_add_trigger">Add Trigger</string>
|
||||
<string name="scene_add_condition">Add Condition</string>
|
||||
<string name="scene_add_action_btn">Add Action</string>
|
||||
<string name="scene_add_action">Add</string>
|
||||
<string name="scene_save_action">Save Command</string>
|
||||
|
||||
@@ -104,10 +104,10 @@
|
||||
|
||||
<!-- Trigger Types & Dialog -->
|
||||
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
|
||||
<string name="scene_trigger_type_time">⏰ Time / Schedule Trigger</string>
|
||||
<string name="scene_trigger_type_temp">🌡️ Temperature Threshold</string>
|
||||
<string name="scene_trigger_type_humidity">💧 Humidity Threshold</string>
|
||||
<string name="scene_trigger_type_device">🔌 Socket State Changed</string>
|
||||
<string name="scene_trigger_type_time">Time / Schedule Trigger</string>
|
||||
<string name="scene_trigger_type_temp">Temperature Threshold</string>
|
||||
<string name="scene_trigger_type_humidity">Humidity Threshold</string>
|
||||
<string name="scene_trigger_type_device">Socket State Changed</string>
|
||||
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</string>
|
||||
<string name="scene_trigger_temp_threshold">Temperature Threshold (°C)</string>
|
||||
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
|
||||
@@ -118,19 +118,19 @@
|
||||
|
||||
<!-- Condition Types & Dialog -->
|
||||
<string name="scene_dialog_add_condition_title">Select Condition</string>
|
||||
<string name="scene_condition_type_temp">🌡️ Temperature Range</string>
|
||||
<string name="scene_condition_type_humidity">💧 Humidity Range</string>
|
||||
<string name="scene_condition_type_socket">🔌 Current Socket Power</string>
|
||||
<string name="scene_condition_type_time_range">🕒 Active Time Range</string>
|
||||
<string name="scene_condition_type_temp">Temperature Range</string>
|
||||
<string name="scene_condition_type_humidity">Humidity Range</string>
|
||||
<string name="scene_condition_type_socket">Current Socket Power</string>
|
||||
<string name="scene_condition_type_time_range">Active Time Range</string>
|
||||
<string name="scene_condition_start_time">Start Time (e.g. 22:00)</string>
|
||||
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
|
||||
|
||||
<!-- Action Types & Dialog -->
|
||||
<string name="scene_dialog_add_action_title">Select Action</string>
|
||||
<string name="scene_action_type_socket">🔌 Smart Socket Control</string>
|
||||
<string name="scene_action_type_virtual_remote">📺 Infrared Remote Button</string>
|
||||
<string name="scene_action_type_delay">⏱️ Delay / Buffer Timer</string>
|
||||
<string name="scene_action_type_notification">🔔 System Notification</string>
|
||||
<string name="scene_action_type_socket">Smart Socket Control</string>
|
||||
<string name="scene_action_type_virtual_remote">Infrared Remote Button</string>
|
||||
<string name="scene_action_type_delay">Delay / Buffer Timer</string>
|
||||
<string name="scene_action_type_notification">System Notification</string>
|
||||
<string name="scene_select_socket">Select Target Socket</string>
|
||||
<string name="scene_socket_power_state">Target Power State</string>
|
||||
<string name="scene_select_virtual_remote">Select Virtual Remote</string>
|
||||
@@ -144,8 +144,8 @@
|
||||
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
|
||||
<string name="scene_action_remote">Remote [%1$s]: Send %2$s</string>
|
||||
<string name="scene_action_infrared">Infrared [%1$s]: Slot %2$s</string>
|
||||
<string name="scene_action_delay">⏱️ Delay %1$d seconds</string>
|
||||
<string name="scene_action_notification">🔔 Notify: %1$s</string>
|
||||
<string name="scene_action_delay">Delay %1$d seconds</string>
|
||||
<string name="scene_action_notification">Notify: %1$s</string>
|
||||
|
||||
<!-- Scene: execution -->
|
||||
<string name="scene_execution_title">Execute Command</string>
|
||||
@@ -164,13 +164,13 @@
|
||||
<!-- Validation & dialog chips / placeholders -->
|
||||
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
|
||||
<string name="scene_condition_time_error">Invalid time format (use HH:mm, e.g. 22:00)</string>
|
||||
<string name="scene_chip_time">⏰ Schedule</string>
|
||||
<string name="scene_chip_temp">🌡️ Temperature</string>
|
||||
<string name="scene_chip_humidity">💧 Humidity</string>
|
||||
<string name="scene_chip_socket">🔌 Socket</string>
|
||||
<string name="scene_chip_remote">📺 Remote</string>
|
||||
<string name="scene_chip_delay">⏱️ Delay</string>
|
||||
<string name="scene_chip_notify">🔔 Notify</string>
|
||||
<string name="scene_chip_time">Schedule</string>
|
||||
<string name="scene_chip_temp">Temperature</string>
|
||||
<string name="scene_chip_humidity">Humidity</string>
|
||||
<string name="scene_chip_socket">Socket</string>
|
||||
<string name="scene_chip_remote">Remote</string>
|
||||
<string name="scene_chip_delay">Delay</string>
|
||||
<string name="scene_chip_notify">Notify</string>
|
||||
<string name="scene_chip_above">> Above</string>
|
||||
<string name="scene_chip_below">< Below</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_conditions_hint">No conditions configured. Tap + below to add.</string>
|
||||
<string name="scene_empty_actions_hint">No actions configured. Tap + below to add.</string>
|
||||
<string name="scene_summary_triggers_and_actions">⚡ %1$d triggers • %2$d actions</string>
|
||||
<string name="scene_summary_triggers_and_actions">%1$d triggers · %2$d actions</string>
|
||||
<string name="scene_summary_actions_only">%1$d actions</string>
|
||||
<string name="scene_exec_ready_hint">Tap the button below to run immediately</string>
|
||||
<string name="scene_exec_rerun">Re-run</string>
|
||||
@@ -261,30 +261,125 @@
|
||||
<string name="error_unknown">An unexpected error occurred.</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_title">Interface settings</string>
|
||||
<string name="settings_style_section">Style</string>
|
||||
<string name="settings_renderer_label">Renderer</string>
|
||||
<string name="settings_renderer_description">Choose the interface renderer used by the app</string>
|
||||
<string name="settings_renderer_material3">Material 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_theme_section">Theme</string>
|
||||
<string name="settings_theme_label">Theme mode</string>
|
||||
<string name="settings_theme_system">Follow system</string>
|
||||
<string name="settings_theme_light">Light</string>
|
||||
<string name="settings_theme_dark">Dark</string>
|
||||
<string name="settings_dynamic_color">Dynamic color</string>
|
||||
<string name="settings_dynamic_color_description">Generate interface colors from the system wallpaper</string>
|
||||
<string name="settings_surface_section">Surface effects</string>
|
||||
<string name="settings_blur">Blur effects</string>
|
||||
<string name="settings_blur_description">Enable background blur on supported screens</string>
|
||||
<string name="settings_navigation">Navigation style</string>
|
||||
<string name="settings_navigation_standard">Standard navigation</string>
|
||||
<string name="settings_navigation_floating">Floating navigation</string>
|
||||
<string name="settings_feedback_section">Interaction feedback</string>
|
||||
<string name="settings_haptics">Haptic feedback</string>
|
||||
<string name="settings_haptics_description">Provide subtle vibration feedback for actions</string>
|
||||
<string name="settings_accessibility_section">System accessibility</string>
|
||||
<string name="settings_accessibility_card_title">Follow system accessibility settings</string>
|
||||
<string name="settings_accessibility_description">Animation, font size, contrast and other accessibility options are controlled by the system.</string>
|
||||
<string name="settings_error_save">Unable to save interface settings. Try again.</string>
|
||||
<string name="settings_style_entry">Interface style</string>
|
||||
<string name="settings_theme_entry">Theme</string>
|
||||
<string name="settings_theme_entry_description">Colors, brightness, effects and scale</string>
|
||||
<string name="settings_theme_detail_title">Theme</string>
|
||||
<string name="settings_renderer_primary">Material 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>
|
||||
|
||||
+86
@@ -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)
|
||||
}
|
||||
}
|
||||
+37
@@ -2,7 +2,11 @@ package com.flagship.abox.manager.ui.preferences
|
||||
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||
import com.flagship.abox.manager.preferences.AccentPreference
|
||||
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
import com.flagship.abox.manager.preferences.InterfaceScale
|
||||
import com.flagship.abox.manager.preferences.ColorStandard
|
||||
import com.flagship.abox.manager.preferences.ColorStyle
|
||||
import com.flagship.abox.manager.ui.common.UiText.StringResource
|
||||
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||
import com.flagship.abox.manager.preferences.UiPreferences
|
||||
@@ -83,6 +87,39 @@ class SettingsViewModelTest {
|
||||
assertEquals(repository.value, viewModel.uiState.value.preferences)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scaleAndAccentUpdatesPersistThroughRepository() = runTest {
|
||||
val repository = RecordingUiPreferencesRepository()
|
||||
val viewModel = SettingsViewModel(repository)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onAction(SettingsUiAction.SetInterfaceScale(InterfaceScale.COMPACT))
|
||||
advanceUntilIdle()
|
||||
viewModel.onAction(SettingsUiAction.SetAccent(AccentPreference.ROSE))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(InterfaceScale.COMPACT, repository.value.interfaceScale)
|
||||
assertEquals(AccentPreference.ROSE, repository.value.accentPreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun styleSpecificThemeOptionsPersistThroughRepository() = runTest {
|
||||
val repository = RecordingUiPreferencesRepository()
|
||||
val viewModel = SettingsViewModel(repository)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.onAction(SettingsUiAction.SetColorStyle(ColorStyle.FIDELITY))
|
||||
viewModel.onAction(SettingsUiAction.SetColorStandard(ColorStandard.SPEC_2025))
|
||||
viewModel.onAction(SettingsUiAction.SetLiquidGlass(true))
|
||||
viewModel.onAction(SettingsUiAction.SetPredictiveBack(false))
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(ColorStyle.FIDELITY, repository.value.colorStyle)
|
||||
assertEquals(ColorStandard.SPEC_2025, repository.value.colorStandard)
|
||||
assertTrue(repository.value.liquidGlassEnabled)
|
||||
assertEquals(false, repository.value.predictiveBackEnabled)
|
||||
}
|
||||
|
||||
|
||||
private class RecordingUiPreferencesRepository(
|
||||
initial: UiPreferences = UiPreferences(),
|
||||
|
||||
+9
-7
@@ -20,6 +20,7 @@ import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
||||
import com.flagship.abox.manager.testing.fakes.FakeDeviceCatalog
|
||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
@@ -39,6 +40,7 @@ class SceneEditorViewModelTest {
|
||||
private val profileStore = FakeProviderProfileStore()
|
||||
private val sceneRepository = FakeSceneRepository()
|
||||
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
||||
private val deviceCatalog = FakeDeviceCatalog()
|
||||
private val profileId = ProviderProfileId("local-experience")
|
||||
|
||||
private val existingScene = Scene(
|
||||
@@ -66,7 +68,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeWithNullPreparesNewScene() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
|
||||
val state = viewModel.state.value
|
||||
@@ -79,7 +81,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeWithExistingSceneLoadsState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(existingScene.id)
|
||||
|
||||
// Immediately binds sceneId and enters loading state
|
||||
@@ -101,7 +103,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun addingAndReorderingActionsUpdatesState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
viewModel.updateName("My New Scene")
|
||||
|
||||
@@ -129,7 +131,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||
viewModel.updateName("Auto Rule")
|
||||
|
||||
@@ -153,7 +155,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun saveNewScenePersistsToRepository() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
viewModel.updateName("Brand New Scene")
|
||||
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
||||
@@ -170,7 +172,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||
viewModel.updateName("Auto Without Trigger")
|
||||
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||
@@ -188,7 +190,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore, deviceCatalog)
|
||||
viewModel.initialize(existingScene.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
|
||||
+26
@@ -157,6 +157,32 @@ class SocketViewModelTest {
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commandSuccessReadsBackConfirmedState() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
assertEquals(SocketOperationState.IdleConfirmed, viewModel.state.value.operation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readbackFailureKeepsLastConfirmedStateAndMarksAwaitingConfirmation() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Readback failed"))
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
assertEquals(
|
||||
SocketOperationState.AwaitingConfirmation(SocketPowerState.ON),
|
||||
viewModel.state.value.operation,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
|
||||
+14
-2
@@ -129,8 +129,8 @@ class TemperatureHumidityViewModelTest {
|
||||
assertTrue(state.hasNoData)
|
||||
assertNull(state.temperature)
|
||||
assertNull(state.humidity)
|
||||
assertEquals("--", state.formattedTemperature)
|
||||
assertEquals("--", state.formattedHumidity)
|
||||
assertNull(state.formattedTemperature)
|
||||
assertNull(state.formattedHumidity)
|
||||
assertFalse(state.hasValidData)
|
||||
}
|
||||
|
||||
@@ -190,4 +190,16 @@ class TemperatureHumidityViewModelTest {
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
|
||||
assertNull(viewModel.state.value.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun partialReadingKeepsAvailableMetric() = runTest {
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(profileId, deviceName, temperature = "26.5", humidity = null),
|
||||
)
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
assertEquals("26.5", viewModel.state.value.temperature)
|
||||
assertNull(viewModel.state.value.humidity)
|
||||
}
|
||||
}
|
||||
|
||||
+36
-2
@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -476,13 +477,19 @@ fun ABoxDeviceDetailScaffold(
|
||||
title: String,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null,
|
||||
icon: ABoxIconKey = ABoxIconKey.UNKNOWN_DEVICE,
|
||||
action: (@Composable () -> Unit)? = null,
|
||||
content: @Composable (PaddingValues) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
ABoxIconButton(
|
||||
@@ -490,11 +497,38 @@ fun ABoxDeviceDetailScaffold(
|
||||
contentDescription = "返回",
|
||||
onClick = onBack,
|
||||
)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
modifier = Modifier.padding(start = 4.dp),
|
||||
) {
|
||||
ABoxIcon(
|
||||
key = icon,
|
||||
contentDescription = subtitle ?: title,
|
||||
modifier = Modifier.padding(9.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(start = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
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,
|
||||
|
||||
+15
@@ -12,6 +12,8 @@ import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import com.flagship.abox.manager.designsystem.renderer.ABoxRendererAdapters
|
||||
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens
|
||||
@@ -20,6 +22,7 @@ import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
|
||||
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
|
||||
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver
|
||||
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||
import com.flagship.abox.manager.preferences.NavigationPresentation
|
||||
import com.flagship.abox.manager.preferences.UiPreferences
|
||||
|
||||
val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
|
||||
@@ -29,6 +32,9 @@ val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shap
|
||||
val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
|
||||
val LocalABoxReducedMotion = staticCompositionLocalOf { false }
|
||||
val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
|
||||
val LocalABoxNavigationPresentation = staticCompositionLocalOf { NavigationPresentation.STANDARD }
|
||||
val LocalABoxLiquidGlassEnabled = staticCompositionLocalOf { false }
|
||||
val LocalABoxPredictiveBackEnabled = staticCompositionLocalOf { false }
|
||||
|
||||
@Composable
|
||||
fun ABoxTheme(
|
||||
@@ -78,8 +84,14 @@ internal fun ABoxThemeContent(
|
||||
dynamicColorTokens = dynamicColorTokens,
|
||||
)
|
||||
val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
|
||||
val density = LocalDensity.current
|
||||
val scaledDensity = Density(
|
||||
density = density.density * (preferences.interfaceScale.percent / 100f),
|
||||
fontScale = density.fontScale,
|
||||
)
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalDensity provides scaledDensity,
|
||||
LocalABoxColors provides resolution.colorTokens,
|
||||
LocalABoxSpacing provides ABoxSpacing(),
|
||||
LocalABoxTypography provides ABoxTypography,
|
||||
@@ -87,6 +99,9 @@ internal fun ABoxThemeContent(
|
||||
LocalABoxRenderer provides resolution.renderer,
|
||||
LocalABoxReducedMotion provides reducedMotion,
|
||||
LocalABoxBlurEnabled provides resolution.blurEnabled,
|
||||
LocalABoxNavigationPresentation provides preferences.navigationPresentation,
|
||||
LocalABoxLiquidGlassEnabled provides preferences.liquidGlassEnabled,
|
||||
LocalABoxPredictiveBackEnabled provides preferences.predictiveBackEnabled,
|
||||
) {
|
||||
renderer.Render(
|
||||
resolution = resolution,
|
||||
|
||||
+15
@@ -11,6 +11,11 @@ import androidx.compose.material.icons.outlined.Edit
|
||||
import androidx.compose.material.icons.outlined.ErrorOutline
|
||||
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
|
||||
import androidx.compose.material.icons.outlined.History
|
||||
import androidx.compose.material.icons.outlined.Palette
|
||||
import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material.icons.outlined.Tune
|
||||
import androidx.compose.material.icons.outlined.Vibration
|
||||
import androidx.compose.material.icons.outlined.ViewCarousel
|
||||
import androidx.compose.material.icons.outlined.Layers
|
||||
import androidx.compose.material.icons.outlined.Power
|
||||
import androidx.compose.material.icons.outlined.PowerSettingsNew
|
||||
@@ -51,6 +56,11 @@ enum class ABoxIconKey {
|
||||
WARNING,
|
||||
ERROR,
|
||||
UNKNOWN_DEVICE,
|
||||
SETTINGS,
|
||||
THEME,
|
||||
EFFECTS,
|
||||
FEEDBACK,
|
||||
SCALE,
|
||||
}
|
||||
|
||||
/** Design-system-owned device categories; this module does not depend on domain models. */
|
||||
@@ -94,6 +104,11 @@ object ABoxIconMapper {
|
||||
ABoxIconKey.WARNING -> Icons.Outlined.Warning
|
||||
ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline
|
||||
ABoxIconKey.UNKNOWN_DEVICE -> Icons.AutoMirrored.Outlined.HelpOutline
|
||||
ABoxIconKey.SETTINGS -> Icons.Outlined.Settings
|
||||
ABoxIconKey.THEME -> Icons.Outlined.Palette
|
||||
ABoxIconKey.EFFECTS -> Icons.Outlined.ViewCarousel
|
||||
ABoxIconKey.FEEDBACK -> Icons.Outlined.Vibration
|
||||
ABoxIconKey.SCALE -> Icons.Outlined.Tune
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -6,10 +6,7 @@ import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
|
||||
internal interface ABoxRendererAdapter {
|
||||
@Composable
|
||||
fun Render(
|
||||
resolution: ABoxThemeResolution,
|
||||
content: @Composable () -> Unit,
|
||||
)
|
||||
fun Render(resolution: ABoxThemeResolution, content: @Composable () -> Unit)
|
||||
}
|
||||
|
||||
internal object ABoxRendererAdapters {
|
||||
|
||||
+5
-5
@@ -13,11 +13,11 @@ internal object MiuixRendererAdapter : ABoxRendererAdapter {
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme()
|
||||
MiuixTheme(
|
||||
colors = colors,
|
||||
) {
|
||||
Material3RendererAdapter.Render(
|
||||
resolution = resolution,
|
||||
MiuixTheme(colors = colors) {
|
||||
androidx.compose.material3.MaterialTheme(
|
||||
colorScheme = com.flagship.abox.manager.designsystem.materialColorScheme(resolution),
|
||||
typography = com.flagship.abox.manager.designsystem.ABoxTypography,
|
||||
shapes = com.flagship.abox.manager.designsystem.ABoxShapes(resolution.shapeTokens),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
|
||||
+13
-1
@@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flagship.abox.manager.preferences.AppThemeMode
|
||||
import com.flagship.abox.manager.preferences.AccentPreference
|
||||
import com.flagship.abox.manager.preferences.DesignRenderer
|
||||
import com.flagship.abox.manager.preferences.UiPreferences
|
||||
|
||||
@@ -121,7 +122,7 @@ object ABoxThemeResolver {
|
||||
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
|
||||
dynamicColorTokens
|
||||
} else {
|
||||
brandTokens
|
||||
brandTokens.withAccent(preferences.accentPreference, isDarkTheme)
|
||||
}
|
||||
val shapeTokens = when (preferences.renderer) {
|
||||
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
|
||||
@@ -137,3 +138,14 @@ object ABoxThemeResolver {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ABoxColorTokens.withAccent(accent: AccentPreference, dark: Boolean): ABoxColorTokens {
|
||||
val selected = when (accent) {
|
||||
AccentPreference.BLUE -> if (dark) Color(0xFF9CCAFF) else Color(0xFF385F9D)
|
||||
AccentPreference.GREEN -> if (dark) Color(0xFF7EDFA7) else Color(0xFF247A4B)
|
||||
AccentPreference.AMBER -> if (dark) Color(0xFFFFC46B) else Color(0xFF8A5800)
|
||||
AccentPreference.ROSE -> if (dark) Color(0xFFFFB1C1) else Color(0xFFA43C59)
|
||||
AccentPreference.VIOLET -> if (dark) Color(0xFFD4BBFF) else Color(0xFF6D52A2)
|
||||
}
|
||||
return copy(primary = selected, deviceSocket = selected)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+57
@@ -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)
|
||||
}
|
||||
}
|
||||
+27
@@ -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))
|
||||
}
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+16
@@ -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,
|
||||
)
|
||||
+74
@@ -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,
|
||||
)
|
||||
+9
@@ -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
|
||||
}
|
||||
+31
@@ -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
|
||||
}
|
||||
+22
@@ -64,6 +64,16 @@ class DataStoreUiPreferencesRepository internal constructor(
|
||||
navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD)
|
||||
?: NavigationPresentation.STANDARD,
|
||||
hapticsEnabled = preferences[HAPTICS] ?: true,
|
||||
interfaceScale = preferences[INTERFACE_SCALE]?.enumOrDefault(InterfaceScale.STANDARD)
|
||||
?: InterfaceScale.STANDARD,
|
||||
accentPreference = preferences[ACCENT]?.enumOrDefault(AccentPreference.BLUE)
|
||||
?: AccentPreference.BLUE,
|
||||
colorStyle = preferences[COLOR_STYLE]?.enumOrDefault(ColorStyle.TONAL_SPOT)
|
||||
?: ColorStyle.TONAL_SPOT,
|
||||
colorStandard = preferences[COLOR_STANDARD]?.enumOrDefault(ColorStandard.SPEC_2021)
|
||||
?: ColorStandard.SPEC_2021,
|
||||
liquidGlassEnabled = preferences[LIQUID_GLASS] ?: false,
|
||||
predictiveBackEnabled = preferences[PREDICTIVE_BACK] ?: false,
|
||||
)
|
||||
|
||||
private fun MutablePreferences.putAll(preferences: UiPreferences) {
|
||||
@@ -73,6 +83,12 @@ class DataStoreUiPreferencesRepository internal constructor(
|
||||
this[BLUR] = preferences.blurEnabled
|
||||
this[NAVIGATION] = preferences.navigationPresentation.name
|
||||
this[HAPTICS] = preferences.hapticsEnabled
|
||||
this[INTERFACE_SCALE] = preferences.interfaceScale.name
|
||||
this[ACCENT] = preferences.accentPreference.name
|
||||
this[COLOR_STYLE] = preferences.colorStyle.name
|
||||
this[COLOR_STANDARD] = preferences.colorStandard.name
|
||||
this[LIQUID_GLASS] = preferences.liquidGlassEnabled
|
||||
this[PREDICTIVE_BACK] = preferences.predictiveBackEnabled
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> String.enumOrDefault(default: T): T =
|
||||
@@ -89,5 +105,11 @@ class DataStoreUiPreferencesRepository internal constructor(
|
||||
val BLUR = booleanPreferencesKey("blur")
|
||||
val NAVIGATION = stringPreferencesKey("navigation")
|
||||
val HAPTICS = booleanPreferencesKey("haptics")
|
||||
val INTERFACE_SCALE = stringPreferencesKey("interface_scale")
|
||||
val ACCENT = stringPreferencesKey("accent")
|
||||
val COLOR_STYLE = stringPreferencesKey("color_style")
|
||||
val COLOR_STANDARD = stringPreferencesKey("color_standard")
|
||||
val LIQUID_GLASS = booleanPreferencesKey("liquid_glass")
|
||||
val PREDICTIVE_BACK = booleanPreferencesKey("predictive_back")
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -16,6 +16,31 @@ enum class NavigationPresentation {
|
||||
FLOATING,
|
||||
}
|
||||
|
||||
enum class InterfaceScale(val percent: Int) {
|
||||
COMPACT(85),
|
||||
STANDARD(100),
|
||||
LARGE(115),
|
||||
}
|
||||
|
||||
enum class AccentPreference {
|
||||
BLUE,
|
||||
GREEN,
|
||||
AMBER,
|
||||
ROSE,
|
||||
VIOLET,
|
||||
}
|
||||
|
||||
enum class ColorStyle {
|
||||
TONAL_SPOT,
|
||||
EXPRESSIVE,
|
||||
FIDELITY,
|
||||
}
|
||||
|
||||
enum class ColorStandard {
|
||||
SPEC_2021,
|
||||
SPEC_2025,
|
||||
}
|
||||
|
||||
enum class PreferenceField {
|
||||
RENDERER,
|
||||
THEME_MODE,
|
||||
@@ -23,6 +48,12 @@ enum class PreferenceField {
|
||||
BLUR,
|
||||
NAVIGATION,
|
||||
HAPTICS,
|
||||
INTERFACE_SCALE,
|
||||
ACCENT,
|
||||
COLOR_STYLE,
|
||||
COLOR_STANDARD,
|
||||
LIQUID_GLASS,
|
||||
PREDICTIVE_BACK,
|
||||
}
|
||||
|
||||
data class UiPreferences(
|
||||
@@ -32,4 +63,10 @@ data class UiPreferences(
|
||||
val blurEnabled: Boolean = false,
|
||||
val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD,
|
||||
val hapticsEnabled: Boolean = true,
|
||||
val interfaceScale: InterfaceScale = InterfaceScale.STANDARD,
|
||||
val accentPreference: AccentPreference = AccentPreference.BLUE,
|
||||
val colorStyle: ColorStyle = ColorStyle.TONAL_SPOT,
|
||||
val colorStandard: ColorStandard = ColorStandard.SPEC_2021,
|
||||
val liquidGlassEnabled: Boolean = false,
|
||||
val predictiveBackEnabled: Boolean = false,
|
||||
)
|
||||
|
||||
+24
@@ -45,6 +45,24 @@ class DataStoreUiPreferencesRepositoryTest {
|
||||
assertEquals(UiPreferencesWriteResult.Success, repository.update {
|
||||
it.copy(hapticsEnabled = false)
|
||||
})
|
||||
assertEquals(UiPreferencesWriteResult.Success, repository.update {
|
||||
it.copy(interfaceScale = InterfaceScale.LARGE)
|
||||
})
|
||||
assertEquals(UiPreferencesWriteResult.Success, repository.update {
|
||||
it.copy(accentPreference = AccentPreference.GREEN)
|
||||
})
|
||||
assertEquals(UiPreferencesWriteResult.Success, repository.update {
|
||||
it.copy(colorStyle = ColorStyle.EXPRESSIVE)
|
||||
})
|
||||
assertEquals(UiPreferencesWriteResult.Success, repository.update {
|
||||
it.copy(colorStandard = ColorStandard.SPEC_2025)
|
||||
})
|
||||
assertEquals(UiPreferencesWriteResult.Success, repository.update {
|
||||
it.copy(liquidGlassEnabled = true)
|
||||
})
|
||||
assertEquals(UiPreferencesWriteResult.Success, repository.update {
|
||||
it.copy(predictiveBackEnabled = false)
|
||||
})
|
||||
|
||||
assertEquals(
|
||||
UiPreferences(
|
||||
@@ -54,6 +72,12 @@ class DataStoreUiPreferencesRepositoryTest {
|
||||
blurEnabled = true,
|
||||
navigationPresentation = NavigationPresentation.FLOATING,
|
||||
hapticsEnabled = false,
|
||||
interfaceScale = InterfaceScale.LARGE,
|
||||
accentPreference = AccentPreference.GREEN,
|
||||
colorStyle = ColorStyle.EXPRESSIVE,
|
||||
colorStandard = ColorStandard.SPEC_2025,
|
||||
liquidGlassEnabled = true,
|
||||
predictiveBackEnabled = false,
|
||||
),
|
||||
repository.observe().first(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+83
@@ -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, "设备"),
|
||||
)
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+33
@@ -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,
|
||||
)
|
||||
+70
@@ -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,
|
||||
)
|
||||
+29
@@ -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"))
|
||||
}
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+49
@@ -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" }
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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()
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
+32
@@ -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" }
|
||||
}
|
||||
+15
@@ -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)
|
||||
}
|
||||
+94
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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>
|
||||
+113
@@ -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.tooling.preview)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.material.icons.extended)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
|
||||
+57
@@ -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()
|
||||
}
|
||||
}
|
||||
+66
@@ -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,
|
||||
)
|
||||
}
|
||||
+1
@@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
+10
-35
@@ -3,17 +3,13 @@ package com.flagship.abox.manager.infrared.ui
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -25,53 +21,32 @@ class InfraredControlScreenTest {
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun rendersControlScreenAndTriggersKeyOperations() {
|
||||
val testKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = ProviderProfileId("profile1"),
|
||||
deviceName = DeviceName("ir-1"),
|
||||
displayName = "Power",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
fun rendersDeviceConfigurationAndAcceptsDeviceNameInput() {
|
||||
val state = InfraredControlUiState(
|
||||
profileId = ProviderProfileId("profile1"),
|
||||
providerType = ProviderType.LOCAL_EXPERIENCE,
|
||||
savedDeviceName = DeviceName("ir-1"),
|
||||
keys = listOf(testKey),
|
||||
loading = false,
|
||||
)
|
||||
|
||||
var sendClicked = false
|
||||
var deleteClicked = false
|
||||
var changedName: String? = null
|
||||
|
||||
composeTestRule.setContent {
|
||||
InfraredControlScreen(
|
||||
state = state,
|
||||
onBack = {},
|
||||
onSessionInvalid = {},
|
||||
onDeviceNameChanged = {},
|
||||
onDeviceNameChanged = { changedName = it },
|
||||
onSaveDeviceName = {},
|
||||
onLearn = { _, _ -> },
|
||||
onRetry = {},
|
||||
onSend = { sendClicked = true },
|
||||
onRename = { _, _ -> },
|
||||
onDelete = { deleteClicked = true },
|
||||
onMessageShown = {},
|
||||
nextSlot = 2,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick()
|
||||
assertTrue(sendClicked)
|
||||
|
||||
composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick()
|
||||
composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick()
|
||||
assertTrue(deleteClicked)
|
||||
composeTestRule.onNodeWithText("运行环境与连接信息").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("infrared_device_name_input")
|
||||
.assertIsDisplayed()
|
||||
.performTextInput("-updated")
|
||||
composeTestRule.waitForIdle()
|
||||
assertEquals("-updated", changedName)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
|
||||
VirtualRemoteEntity::class,
|
||||
RemoteKeyBindingEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class InfraredDatabase : RoomDatabase() {
|
||||
|
||||
+6
@@ -65,6 +65,11 @@ data class VirtualRemoteEntity(
|
||||
indices = [
|
||||
Index(value = ["remoteId"]),
|
||||
Index(value = ["profileId"]),
|
||||
Index(
|
||||
value = ["profileId", "hardwareDeviceName", "assignedSlot"],
|
||||
unique = true,
|
||||
name = "index_remote_key_bindings_hardware_slot",
|
||||
),
|
||||
],
|
||||
)
|
||||
data class RemoteKeyBindingEntity(
|
||||
@@ -72,6 +77,7 @@ data class RemoteKeyBindingEntity(
|
||||
val id: String,
|
||||
val remoteId: String,
|
||||
val profileId: String,
|
||||
val hardwareDeviceName: String,
|
||||
val buttonId: String,
|
||||
val displayName: String,
|
||||
val assignedSlot: String?,
|
||||
|
||||
+20
@@ -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)",
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -206,19 +206,36 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi
|
||||
id = id,
|
||||
remoteId = VirtualRemoteId(remoteId),
|
||||
profileId = ProviderProfileId(profileId),
|
||||
hardwareDeviceName = DeviceName(hardwareDeviceName),
|
||||
buttonId = buttonId,
|
||||
displayName = displayName,
|
||||
assignedSlot = assignedSlot,
|
||||
rawHexCode = rawHexCode,
|
||||
status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY),
|
||||
status = resolvedStatus(),
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
)
|
||||
|
||||
private fun RemoteKeyBindingEntity.resolvedStatus(): InfraredKeyStatus {
|
||||
val parsed = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.UNLEARNED)
|
||||
return if (
|
||||
parsed == InfraredKeyStatus.FAILED &&
|
||||
lastErrorCode == null &&
|
||||
lastErrorDetail == null &&
|
||||
assignedSlot == null &&
|
||||
rawHexCode == null
|
||||
) {
|
||||
InfraredKeyStatus.UNLEARNED
|
||||
} else {
|
||||
parsed
|
||||
}
|
||||
}
|
||||
|
||||
private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity(
|
||||
id = id,
|
||||
remoteId = remoteId.value,
|
||||
profileId = profileId.value,
|
||||
hardwareDeviceName = hardwareDeviceName.value,
|
||||
buttonId = buttonId,
|
||||
displayName = displayName,
|
||||
assignedSlot = assignedSlot,
|
||||
|
||||
+2
-1
@@ -20,6 +20,7 @@ import com.flagship.abox.manager.infrared.data.local.InfraredDao
|
||||
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
|
||||
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
|
||||
import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository
|
||||
import com.flagship.abox.manager.infrared.data.local.MIGRATION_2_3
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
@@ -49,7 +50,7 @@ object InfraredFeatureModule {
|
||||
context,
|
||||
InfraredDatabase::class.java,
|
||||
"abox_infrared.db",
|
||||
).fallbackToDestructiveMigration().build()
|
||||
).addMigrations(MIGRATION_2_3).build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
||||
+414
@@ -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 -> "高速" }
|
||||
+411
-105
@@ -2,6 +2,8 @@ package com.flagship.abox.manager.infrared.hub
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -16,6 +18,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
@@ -24,6 +30,8 @@ import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
@@ -40,6 +48,9 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.SwapVert
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -50,7 +61,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -59,6 +71,10 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.designsystem.ABoxIconButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||
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.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
||||
|
||||
@Composable
|
||||
fun InfraredHubRoute(
|
||||
@@ -87,6 +105,21 @@ fun InfraredHubRoute(
|
||||
onOpenRemote = onOpenRemote,
|
||||
onDeleteRemote = viewModel::deleteRemote,
|
||||
onCreateRemote = viewModel::createRemote,
|
||||
onSaveDeviceName = viewModel::saveDeviceName,
|
||||
onMoveRemote = viewModel::moveRemote,
|
||||
onUpdateRemote = viewModel::updateRemote,
|
||||
onSelectRemote = viewModel::selectRemote,
|
||||
onToggleLearnMode = viewModel::toggleLearnMode,
|
||||
onButtonClick = viewModel::onButtonClick,
|
||||
onAddCustomButton = viewModel::addCustomButton,
|
||||
onDeleteButton = viewModel::deleteButton,
|
||||
onToggleAcPower = viewModel::toggleAcPower,
|
||||
onAdjustAcTemp = viewModel::adjustAcTemperature,
|
||||
onSetAcMode = viewModel::setAcMode,
|
||||
onSetFanSpeed = viewModel::setAcFanSpeed,
|
||||
onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing,
|
||||
onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing,
|
||||
onToggleAcEco = viewModel::toggleAcEco,
|
||||
onSessionInvalid = onSessionInvalid,
|
||||
onMessageShown = viewModel::clearMessage,
|
||||
)
|
||||
@@ -101,12 +134,30 @@ internal fun InfraredHubScreen(
|
||||
onOpenRemote: (String) -> Unit,
|
||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
|
||||
onSaveDeviceName: (String) -> Unit = {},
|
||||
onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> },
|
||||
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> },
|
||||
onSessionInvalid: () -> Unit,
|
||||
onMessageShown: () -> Unit,
|
||||
onSelectRemote: (VirtualRemoteId) -> Unit = {},
|
||||
onToggleLearnMode: () -> Unit = {},
|
||||
onButtonClick: (String) -> Unit = {},
|
||||
onAddCustomButton: (String) -> Unit = {},
|
||||
onDeleteButton: (String) -> Unit = {},
|
||||
onToggleAcPower: () -> Unit = {},
|
||||
onAdjustAcTemp: (Int) -> Unit = {},
|
||||
onSetAcMode: (AcMode) -> Unit = {},
|
||||
onSetFanSpeed: (AcFanSpeed) -> Unit = {},
|
||||
onToggleAcVerticalSwing: () -> Unit = {},
|
||||
onToggleAcHorizontalSwing: () -> Unit = {},
|
||||
onToggleAcEco: () -> Unit = {},
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var showManagement by remember { mutableStateOf(false) }
|
||||
|
||||
BackHandler(enabled = showManagement) { showManagement = false }
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
@@ -114,7 +165,7 @@ internal fun InfraredHubScreen(
|
||||
is HubUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
@@ -122,7 +173,7 @@ internal fun InfraredHubScreen(
|
||||
is HubUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
@@ -135,102 +186,115 @@ internal fun InfraredHubScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_hub_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
state.providerDisplayName?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text(stringResource(R.string.infrared_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = onNavigateToSettings,
|
||||
ABoxDeviceDetailScaffold(
|
||||
title = state.savedDeviceName.value,
|
||||
subtitle = if (showManagement) stringResource(R.string.infrared_settings_subtitle) else stringResource(R.string.infrared_center_subtitle),
|
||||
icon = ABoxIconKey.INFRARED,
|
||||
onBack = if (showManagement) ({ showManagement = false }) else onBack,
|
||||
action = {
|
||||
if (!showManagement) {
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.EDIT,
|
||||
contentDescription = stringResource(R.string.infrared_manage_action),
|
||||
onClick = { showManagement = true },
|
||||
modifier = Modifier.testTag("hub_settings_button"),
|
||||
) {
|
||||
Text("⚙️", fontSize = 20.sp)
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
ABoxSurface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Column(
|
||||
if (showManagement) {
|
||||
InfraredManagementScreen(
|
||||
state = state,
|
||||
onAddRemote = { showCreateDialog = true },
|
||||
onDeleteRemote = onDeleteRemote,
|
||||
onSaveDeviceName = onSaveDeviceName,
|
||||
onMoveRemote = onMoveRemote,
|
||||
onUpdateRemote = onUpdateRemote,
|
||||
)
|
||||
} else Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// 1. 硬件槽位配额看板 (100 槽位预算)
|
||||
QuotaSummaryCard(quota = state.slotQuota)
|
||||
|
||||
// 2. 遥控器列表标题与新建按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_hub_my_remotes),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { showCreateDialog = true },
|
||||
modifier = Modifier.testTag("hub_add_remote_button"),
|
||||
) {
|
||||
Text("➕ " + stringResource(R.string.infrared_hub_add_remote))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 遥控器网格卡片
|
||||
if (state.remotes.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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 = stringResource(R.string.infrared_hub_no_remotes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
text = "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
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) },
|
||||
if (remote.type == RemoteDeviceType.CUSTOM) {
|
||||
FilterChip(
|
||||
selected = state.isLearnMode,
|
||||
onClick = onToggleLearnMode,
|
||||
label = { Text(if (state.isLearnMode) stringResource(R.string.infrared_recording) else stringResource(R.string.infrared_learn_mode)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
InfraredCenterControlSurface(
|
||||
state = state,
|
||||
onButtonClick = onButtonClick,
|
||||
onAddCustomButton = onAddCustomButton,
|
||||
onDeleteButton = onDeleteButton,
|
||||
onToggleAcPower = onToggleAcPower,
|
||||
onAdjustAcTemp = onAdjustAcTemp,
|
||||
onSetAcMode = onSetAcMode,
|
||||
onSetFanSpeed = onSetFanSpeed,
|
||||
onToggleAcVerticalSwing = onToggleAcVerticalSwing,
|
||||
onToggleAcHorizontalSwing = onToggleAcHorizontalSwing,
|
||||
onToggleAcEco = onToggleAcEco,
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,6 +308,226 @@ internal fun InfraredHubScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.fillMaxSize().padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StableSingleLineField(
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
BasicTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InfraredManagementScreen(
|
||||
state: InfraredHubUiState,
|
||||
onAddRemote: () -> Unit,
|
||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||
onSaveDeviceName: (String) -> Unit,
|
||||
onMoveRemote: (VirtualRemoteId, Int) -> Unit,
|
||||
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit,
|
||||
) {
|
||||
var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) }
|
||||
var reorderRemoteId by remember { mutableStateOf<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
|
||||
@@ -271,15 +555,18 @@ private fun VirtualRemoteCard(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val icon = when (remote.type) {
|
||||
RemoteDeviceType.TV -> "📺"
|
||||
RemoteDeviceType.AIR_CONDITIONER -> "❄️"
|
||||
RemoteDeviceType.FAN -> "🌀"
|
||||
RemoteDeviceType.CUSTOM -> "🎛️"
|
||||
}
|
||||
Text(icon, fontSize = 28.sp)
|
||||
IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) {
|
||||
Text("🗑️", fontSize = 16.sp)
|
||||
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
|
||||
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
|
||||
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
|
||||
}
|
||||
ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp))
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.CLOSE,
|
||||
contentDescription = "删除${remote.displayName}",
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.testTag("delete_remote_${remote.id.value}"),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -288,11 +575,11 @@ private fun VirtualRemoteCard(
|
||||
)
|
||||
|
||||
val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
"❄️ 状态机动态写码"
|
||||
"状态机动态写码"
|
||||
} else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
|
||||
"🚀 静态独占槽位"
|
||||
"静态独占槽位"
|
||||
} else {
|
||||
"📦 动态写码"
|
||||
"动态写码"
|
||||
}
|
||||
|
||||
Text(
|
||||
@@ -305,9 +592,13 @@ private fun VirtualRemoteCard(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
|
||||
private fun InfraredHardwareSummary(
|
||||
deviceName: String,
|
||||
remoteCount: Int,
|
||||
quota: SlotQuotaSummary,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("infrared_hardware_summary"),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
@@ -323,13 +614,28 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "硬件槽位配额 (总计 100 槽位)",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = MaterialTheme.colorScheme.primaryContainer,
|
||||
) {
|
||||
ABoxIcon(
|
||||
key = ABoxIconKey.INFRARED,
|
||||
contentDescription = "红外硬件",
|
||||
modifier = Modifier.padding(10.dp).size(26.dp),
|
||||
)
|
||||
}
|
||||
Column {
|
||||
Text(deviceName, fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Text(
|
||||
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用",
|
||||
text = "红外控制器 · $remoteCount 个虚拟遥控器",
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = "${quota.usedCount}/${quota.totalCapacity}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
@@ -345,7 +651,7 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个",
|
||||
text = "静态槽位 ${quota.assignedStaticSlots.size} 个 · 动态缓冲槽位 100 · 剩余 ${quota.remainingStaticCount} 个",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -430,7 +736,7 @@ private fun CreateVirtualRemoteDialog(
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("遥控器名称(如:客厅电视、我的遥控器)") },
|
||||
label = { Text("遥控器名称(如:电视、我的遥控器)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
||||
)
|
||||
@@ -443,10 +749,10 @@ private fun CreateVirtualRemoteDialog(
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = when (selectedCustomLayout) {
|
||||
PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)"
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)"
|
||||
PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)"
|
||||
PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局"
|
||||
PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)"
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)"
|
||||
PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)"
|
||||
PanelLayoutStyle.AC_COMPACT -> "空调布局"
|
||||
},
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
@@ -459,21 +765,21 @@ private fun CreateVirtualRemoteDialog(
|
||||
onDismissRequest = { layoutDropdownExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") },
|
||||
text = { Text("电视导航布局(内置方向盘、音量、频道)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.TV_NAV
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") },
|
||||
text = { Text("媒体播放器布局(播放、快进、切歌)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("🎛️ 自由网格布局 (可自由增删按键)") },
|
||||
text = { Text("自由网格布局(可自由增删按键)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.GRID
|
||||
layoutDropdownExpanded = false
|
||||
@@ -538,7 +844,7 @@ private fun CreateVirtualRemoteDialog(
|
||||
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
||||
)
|
||||
Column {
|
||||
Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium)
|
||||
Text("静态独占槽位模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
@@ -552,7 +858,7 @@ private fun CreateVirtualRemoteDialog(
|
||||
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
||||
)
|
||||
Column {
|
||||
Text("📦 动态写码模式", fontWeight = FontWeight.Medium)
|
||||
Text("动态写码模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
@@ -563,7 +869,7 @@ private fun CreateVirtualRemoteDialog(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。",
|
||||
text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。",
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
|
||||
+10
@@ -4,7 +4,10 @@ import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
sealed interface HubUiMessage {
|
||||
@@ -19,6 +22,13 @@ data class InfraredHubUiState(
|
||||
val providerDisplayName: String? = null,
|
||||
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
|
||||
val remotes: List<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 loading: Boolean = true,
|
||||
val message: HubUiMessage? = null,
|
||||
|
||||
+248
-2
@@ -9,9 +9,23 @@ import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import com.flagship.abox.manager.domain.ActivityEventKind
|
||||
import com.flagship.abox.manager.domain.ActivityOutcome
|
||||
import com.flagship.abox.manager.domain.ActivitySource
|
||||
import com.flagship.abox.manager.domain.ActivityTarget
|
||||
import com.flagship.abox.manager.domain.ActivityTargetType
|
||||
import com.flagship.abox.manager.domain.ActivityEventWriter
|
||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||
@@ -30,7 +44,10 @@ import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@@ -43,12 +60,26 @@ class InfraredHubViewModel @Inject constructor(
|
||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||
private val createVirtualRemote: CreateVirtualRemote,
|
||||
private val deleteVirtualRemote: DeleteVirtualRemote,
|
||||
private val sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
|
||||
private val addVirtualRemoteKey: AddVirtualRemoteKey,
|
||||
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
|
||||
private val activityWriter: ActivityEventWriter,
|
||||
) : ViewModel() {
|
||||
|
||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||
|
||||
private val messageFlow = MutableStateFlow<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(
|
||||
val providerProfile: ProviderProfile? = null,
|
||||
@@ -68,7 +99,12 @@ class InfraredHubViewModel @Inject constructor(
|
||||
when (devResult) {
|
||||
is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error))
|
||||
is DomainResult.Success -> {
|
||||
val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME
|
||||
val deviceName = routeDeviceName ?: devResult.value ?: return@flatMapLatest flowOf(
|
||||
HubSnapshot(
|
||||
providerProfile = active,
|
||||
configError = DomainError.InvalidRequest(detail = "No infrared device configured"),
|
||||
),
|
||||
)
|
||||
combine(
|
||||
virtualRemoteRepository.observeRemotes(profileId, deviceName),
|
||||
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
|
||||
@@ -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(
|
||||
hubSnapshotFlow,
|
||||
panelSnapshotFlow,
|
||||
centerActionsFlow,
|
||||
messageFlow,
|
||||
) { snapshot, msg ->
|
||||
) { snapshot, panel, actions, msg ->
|
||||
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid
|
||||
@@ -111,6 +188,13 @@ class InfraredHubViewModel @Inject constructor(
|
||||
providerDisplayName = snapshot.providerProfile?.displayName,
|
||||
savedDeviceName = snapshot.savedDevice,
|
||||
remotes = snapshot.remotes,
|
||||
selectedRemoteId = panel.selectedId,
|
||||
selectedRemote = panel.remote,
|
||||
bindings = panel.bindings,
|
||||
acState = actions.localAcState ?: panel.remote?.acState ?: AcStateSnapshot(),
|
||||
isLearnMode = actions.learnMode,
|
||||
learningButtonId = actions.learningButtonId,
|
||||
sendingButtonId = actions.sendingButtonId,
|
||||
slotQuota = snapshot.slotQuota,
|
||||
loading = false,
|
||||
message = resolvedMessage,
|
||||
@@ -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) {
|
||||
val currentProfile = profileId ?: return
|
||||
val trimmed = newName.trim()
|
||||
@@ -181,6 +414,19 @@ class InfraredHubViewModel @Inject constructor(
|
||||
messageFlow.value = null
|
||||
}
|
||||
|
||||
private fun recordRemoteActivity(kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null) {
|
||||
val currentProfile = profileId ?: return
|
||||
val remote = state.value.selectedRemote ?: return
|
||||
activityWriter.record(
|
||||
profileId = currentProfile,
|
||||
kind = kind,
|
||||
source = ActivitySource.USER,
|
||||
outcome = outcome,
|
||||
target = ActivityTarget(ActivityTargetType.REMOTE, remote.id.value, remote.displayName),
|
||||
errorCode = errorCode,
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDomainError(error: DomainError) {
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid
|
||||
|
||||
+199
-112
@@ -16,8 +16,10 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
@@ -41,6 +43,16 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material.icons.outlined.Add
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.outlined.KeyboardArrowUp
|
||||
import androidx.compose.material.icons.outlined.PowerSettingsNew
|
||||
import androidx.compose.material.icons.outlined.Remove
|
||||
import androidx.compose.material.icons.outlined.VolumeOff
|
||||
import androidx.compose.material.icons.outlined.Tv
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -51,7 +63,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -60,6 +72,9 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.designsystem.ABoxIconButton
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
@@ -118,7 +133,7 @@ internal fun RemotePanelScreen(
|
||||
onMessageShown: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
var showAddKeyDialog by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -128,7 +143,7 @@ internal fun RemotePanelScreen(
|
||||
is PanelUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
@@ -136,7 +151,7 @@ internal fun RemotePanelScreen(
|
||||
is PanelUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
@@ -175,17 +190,14 @@ internal fun RemotePanelScreen(
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text(stringResource(R.string.infrared_back))
|
||||
}
|
||||
ABoxIconButton(ABoxIconKey.BACK, "返回", onBack)
|
||||
},
|
||||
actions = {
|
||||
// 非空调面板显示学习模式开关
|
||||
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
|
||||
if (state.remote?.type == com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) {
|
||||
FilterChip(
|
||||
selected = state.isLearnMode,
|
||||
onClick = onToggleLearnMode,
|
||||
label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") },
|
||||
label = { Text(if (state.isLearnMode) "录制中" else "学习模式") },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = Color(0xFFFF5252),
|
||||
selectedLabelColor = Color.White,
|
||||
@@ -222,7 +234,7 @@ internal fun RemotePanelScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("🔴", fontSize = 16.sp)
|
||||
ABoxIcon(ABoxIconKey.PENDING, "录制中", modifier = Modifier.size(20.dp))
|
||||
Text(
|
||||
text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码",
|
||||
color = Color(0xFFC62828),
|
||||
@@ -233,56 +245,24 @@ internal fun RemotePanelScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 面板内容区
|
||||
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
|
||||
when (remote.layoutStyle) {
|
||||
PanelLayoutStyle.AC_COMPACT -> {
|
||||
AcControlPanel(
|
||||
acState = state.acState,
|
||||
onTogglePower = onToggleAcPower,
|
||||
onAdjustTemp = onAdjustAcTemp,
|
||||
onSetMode = onSetAcMode,
|
||||
onSetFanSpeed = onSetFanSpeed,
|
||||
onToggleVSwing = onToggleAcVerticalSwing,
|
||||
onToggleHSwing = onToggleAcHorizontalSwing,
|
||||
onToggleEco = onToggleAcEco,
|
||||
)
|
||||
}
|
||||
PanelLayoutStyle.TV_NAV -> {
|
||||
TvControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
}
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> {
|
||||
MediaPlayerControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
}
|
||||
PanelLayoutStyle.GRID -> {
|
||||
GenericGridControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
UnifiedRemoteControlSurface(
|
||||
state = state,
|
||||
onButtonClick = onButtonClick,
|
||||
onAddCustomButton = onAddCustomButton,
|
||||
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) {
|
||||
AddCustomKeyDialog(
|
||||
@@ -309,12 +289,13 @@ private fun AcControlPanel(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("ac_temperature_stepper"),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
@@ -332,7 +313,7 @@ private fun AcControlPanel(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (acState.power) "● 运行中" else "○ 已关机",
|
||||
text = if (acState.power) "运行中" else "已关机",
|
||||
color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
@@ -344,25 +325,13 @@ private fun AcControlPanel(
|
||||
.background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline)
|
||||
.testTag("ac_power_toggle"),
|
||||
) {
|
||||
Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Icon(Icons.Outlined.PowerSettingsNew, contentDescription = "电源", tint = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${acState.temperature}°C",
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
@@ -370,33 +339,48 @@ private fun AcControlPanel(
|
||||
modifier = Modifier.size(56.dp).testTag("ac_temp_down"),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
Icon(Icons.Outlined.Remove, contentDescription = "降低温度")
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = "${acState.temperature}°",
|
||||
fontSize = 56.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text("设定温度", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
Button(
|
||||
onClick = { onAdjustTemp(1) },
|
||||
modifier = Modifier.size(56.dp).testTag("ac_temp_up"),
|
||||
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))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth().testTag("ac_mode_selector")) {
|
||||
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("运行模式", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
AcMode.entries.forEach { mode ->
|
||||
FilterChip(
|
||||
selected = acState.mode == mode,
|
||||
onClick = { onSetMode(mode) },
|
||||
label = { Text(mode.name) },
|
||||
label = { Text(modeLabel(mode)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
|
||||
Row(
|
||||
@@ -407,7 +391,7 @@ private fun AcControlPanel(
|
||||
FilterChip(
|
||||
selected = acState.fanSpeed == speed,
|
||||
onClick = { onSetFanSpeed(speed) },
|
||||
label = { Text(speed.name) },
|
||||
label = { Text(fanSpeedLabel(speed)) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -420,19 +404,19 @@ private fun AcControlPanel(
|
||||
FilterChip(
|
||||
selected = acState.verticalSwing,
|
||||
onClick = onToggleVSwing,
|
||||
label = { Text("↕ 上下扫风") },
|
||||
label = { Text("上下扫风") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilterChip(
|
||||
selected = acState.horizontalSwing,
|
||||
onClick = onToggleHSwing,
|
||||
label = { Text("↔ 左右扫风") },
|
||||
label = { Text("左右扫风") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilterChip(
|
||||
selected = acState.eco,
|
||||
onClick = onToggleEco,
|
||||
label = { Text("🌱 节能 ECO") },
|
||||
label = { Text("节能") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
@@ -452,6 +436,7 @@ private fun TvControlPanel(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
@@ -462,9 +447,9 @@ private fun TvControlPanel(
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_POWER", "电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_SOURCE", "信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -472,7 +457,7 @@ private fun TvControlPanel(
|
||||
// 中部五向方向导航盘
|
||||
Card(
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(220.dp),
|
||||
modifier = Modifier.size(220.dp).testTag("tv_direction_pad"),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -481,7 +466,7 @@ private fun TvControlPanel(
|
||||
onClick = { onButtonClick("TV_NAV_UP") },
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId)
|
||||
DirectionIndicator("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, "向上", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// 下
|
||||
@@ -489,7 +474,7 @@ private fun TvControlPanel(
|
||||
onClick = { onButtonClick("TV_NAV_DOWN") },
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId)
|
||||
DirectionIndicator("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, "向下", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// 左
|
||||
@@ -497,7 +482,7 @@ private fun TvControlPanel(
|
||||
onClick = { onButtonClick("TV_NAV_LEFT") },
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId)
|
||||
DirectionIndicator("TV_NAV_LEFT", Icons.Outlined.KeyboardArrowLeft, "向左", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// 右
|
||||
@@ -505,7 +490,7 @@ private fun TvControlPanel(
|
||||
onClick = { onButtonClick("TV_NAV_RIGHT") },
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId)
|
||||
DirectionIndicator("TV_NAV_RIGHT", Icons.Outlined.KeyboardArrowRight, "向右", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// OK 居中大按钮
|
||||
@@ -524,14 +509,14 @@ private fun TvControlPanel(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_BACK", "返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_HOME", "主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_MENU", "菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
}
|
||||
|
||||
// 底部音量与频道调节柱
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth().testTag("tv_volume_channel_rockers"),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Column(
|
||||
@@ -539,11 +524,11 @@ private fun TvControlPanel(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId)
|
||||
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = "提高音量")
|
||||
}
|
||||
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId)
|
||||
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
Icon(Icons.Outlined.Remove, contentDescription = "降低音量")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,17 +537,118 @@ private fun TvControlPanel(
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId)
|
||||
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
Icon(Icons.Outlined.Add, contentDescription = "频道上调")
|
||||
}
|
||||
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId)
|
||||
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
Icon(Icons.Outlined.Remove, contentDescription = "频道下调")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UnifiedRemoteControlSurface(
|
||||
state: RemotePanelUiState,
|
||||
onButtonClick: (String) -> Unit,
|
||||
onAddCustomButton: (String) -> Unit,
|
||||
onDeleteButton: (String) -> Unit,
|
||||
onToggleAcPower: () -> Unit,
|
||||
onAdjustAcTemp: (Int) -> Unit,
|
||||
onSetAcMode: (AcMode) -> Unit,
|
||||
onSetFanSpeed: (AcFanSpeed) -> Unit,
|
||||
onToggleAcVerticalSwing: () -> Unit,
|
||||
onToggleAcHorizontalSwing: () -> Unit,
|
||||
onToggleAcEco: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var showAddKeyDialog by remember { mutableStateOf(false) }
|
||||
val remote = state.remote
|
||||
Box(modifier = modifier) {
|
||||
if (remote == null) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
when (remote.layoutStyle) {
|
||||
PanelLayoutStyle.AC_COMPACT -> AcControlPanel(
|
||||
acState = state.acState,
|
||||
onTogglePower = onToggleAcPower,
|
||||
onAdjustTemp = onAdjustAcTemp,
|
||||
onSetMode = onSetAcMode,
|
||||
onSetFanSpeed = onSetFanSpeed,
|
||||
onToggleVSwing = onToggleAcVerticalSwing,
|
||||
onToggleHSwing = onToggleAcHorizontalSwing,
|
||||
onToggleEco = onToggleAcEco,
|
||||
)
|
||||
PanelLayoutStyle.TV_NAV -> TvControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> MediaPlayerControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
PanelLayoutStyle.GRID -> GenericGridControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
onDeleteButton = onDeleteButton,
|
||||
onAddButton = { showAddKeyDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (showAddKeyDialog) {
|
||||
AddCustomKeyDialog(
|
||||
onDismiss = { showAddKeyDialog = false },
|
||||
onConfirm = {
|
||||
showAddKeyDialog = false
|
||||
onAddCustomButton(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DirectionIndicator(
|
||||
buttonId: String,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
description: String,
|
||||
learningButtonId: String?,
|
||||
sendingButtonId: String?,
|
||||
) {
|
||||
when (buttonId) {
|
||||
learningButtonId -> Text("录制中", fontSize = 11.sp, fontWeight = FontWeight.Bold)
|
||||
sendingButtonId -> CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
else -> Icon(icon, contentDescription = description, modifier = Modifier.size(30.dp))
|
||||
}
|
||||
}
|
||||
|
||||
private fun modeLabel(mode: AcMode): String = when (mode) {
|
||||
AcMode.COOL -> "制冷"
|
||||
AcMode.HEAT -> "制热"
|
||||
AcMode.AUTO -> "自动"
|
||||
AcMode.DRY -> "除湿"
|
||||
AcMode.FAN -> "送风"
|
||||
}
|
||||
|
||||
private fun fanSpeedLabel(speed: AcFanSpeed): String = when (speed) {
|
||||
AcFanSpeed.AUTO -> "自动"
|
||||
AcFanSpeed.LOW -> "低速"
|
||||
AcFanSpeed.MEDIUM -> "中速"
|
||||
AcFanSpeed.HIGH -> "高速"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaPlayerControlPanel(
|
||||
bindings: List<RemoteKeyBinding>,
|
||||
@@ -582,8 +668,8 @@ private fun MediaPlayerControlPanel(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("MEDIA_POWER", "电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("MEDIA_MUTE", "静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
@@ -641,13 +727,13 @@ private fun GenericGridControlPanel(
|
||||
) {
|
||||
Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) {
|
||||
Text("➕ 添加按键")
|
||||
Text("添加按键")
|
||||
}
|
||||
}
|
||||
|
||||
if (bindings.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
|
||||
Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("暂无自定义按键,请使用上方添加操作新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
@@ -696,9 +782,10 @@ private fun GridButtonCard(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
val statusText = when {
|
||||
isLearning -> "🟡 正在录制信号..."
|
||||
binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})"
|
||||
else -> "⚪ 待录制 (点击录码)"
|
||||
isLearning -> "正在录制信号..."
|
||||
binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})"
|
||||
binding.status == InfraredKeyStatus.FAILED -> "学习失败(请重试)"
|
||||
else -> "未学习(点击录码)"
|
||||
}
|
||||
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
@@ -711,7 +798,7 @@ private fun GridButtonCard(
|
||||
onClick = onDelete,
|
||||
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?,
|
||||
) {
|
||||
if (learningButtonId == buttonId) {
|
||||
Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
Text("录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
} else if (sendingButtonId == buttonId) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
|
||||
} else {
|
||||
|
||||
+34
-1
@@ -24,6 +24,8 @@ import com.flagship.abox.manager.infrared.remote.UiText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
@@ -59,6 +61,7 @@ class RemotePanelViewModel @Inject constructor(
|
||||
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
|
||||
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
|
||||
private var acDebounceJob: Job? = null
|
||||
private var learningJob: Job? = null
|
||||
|
||||
private val remoteFlow = if (profileId == null || remoteId == null) {
|
||||
flowOf(null)
|
||||
@@ -126,8 +129,10 @@ class RemotePanelViewModel @Inject constructor(
|
||||
)
|
||||
|
||||
fun toggleLearnMode() {
|
||||
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
|
||||
val newMode = !isLearnModeFlow.value
|
||||
isLearnModeFlow.value = newMode
|
||||
if (!newMode) cancelLearning()
|
||||
if (newMode) {
|
||||
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) {
|
||||
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
if (state.value.learningButtonId != null) return
|
||||
|
||||
learningButtonIdFlow.value = buttonId
|
||||
viewModelScope.launch {
|
||||
learningJob = viewModelScope.launch {
|
||||
try {
|
||||
when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
|
||||
is DomainResult.Success -> {
|
||||
@@ -175,12 +181,39 @@ class RemotePanelViewModel @Inject constructor(
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
} catch (cancelled: kotlinx.coroutines.CancellationException) {
|
||||
val binding = state.value.bindings.firstOrNull { it.buttonId == buttonId }
|
||||
if (binding != null) {
|
||||
withContext(NonCancellable) {
|
||||
virtualRemoteRepository.updateKeyBinding(
|
||||
binding.copy(
|
||||
status = com.flagship.abox.manager.domain.infrared.InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = "学习已取消,需要重新学习",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
throw cancelled
|
||||
} finally {
|
||||
learningButtonIdFlow.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelLearning() {
|
||||
learningJob?.cancel()
|
||||
learningJob = null
|
||||
learningButtonIdFlow.value = null
|
||||
isLearnModeFlow.value = false
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
cancelLearning()
|
||||
acDebounceJob?.cancel()
|
||||
super.onCleared()
|
||||
}
|
||||
|
||||
fun addCustomButton(displayName: String) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
|
||||
+2
-1
@@ -204,7 +204,8 @@ class InfraredRemoteViewModel @Inject constructor(
|
||||
activeLearningSlotFlow.value = spec.defaultSlot
|
||||
val deferred = when (existingKey?.status) {
|
||||
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING ->
|
||||
learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
null -> learningCoordinator.learn(
|
||||
profileId = currentProfile,
|
||||
deviceName = currentDevice,
|
||||
|
||||
+13
-13
@@ -26,24 +26,24 @@ data class RemoteButtonSpec(
|
||||
|
||||
object RemoteSceneConfig {
|
||||
object AirConditioner {
|
||||
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻")
|
||||
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲")
|
||||
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼")
|
||||
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀")
|
||||
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨")
|
||||
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔")
|
||||
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱")
|
||||
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "电源")
|
||||
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "升温")
|
||||
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "降温")
|
||||
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式")
|
||||
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速")
|
||||
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风")
|
||||
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时")
|
||||
|
||||
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
|
||||
}
|
||||
|
||||
object Television {
|
||||
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻")
|
||||
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇")
|
||||
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲")
|
||||
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼")
|
||||
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀")
|
||||
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶")
|
||||
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "电源")
|
||||
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "静音")
|
||||
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "上")
|
||||
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "下")
|
||||
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "左")
|
||||
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "右")
|
||||
val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
|
||||
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
|
||||
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -")
|
||||
|
||||
+10
-8
@@ -35,7 +35,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -45,6 +45,8 @@ import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.designsystem.ABoxIconButton
|
||||
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
|
||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
|
||||
@@ -87,7 +89,7 @@ internal fun InfraredRemoteScreen(
|
||||
onMessageShown: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
@@ -95,7 +97,7 @@ internal fun InfraredRemoteScreen(
|
||||
is RemoteUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
@@ -103,7 +105,7 @@ internal fun InfraredRemoteScreen(
|
||||
is RemoteUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
@@ -158,12 +160,12 @@ internal fun InfraredRemoteScreen(
|
||||
}
|
||||
|
||||
// 按键管理 / 设置入口按钮
|
||||
IconButton(
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.EDIT,
|
||||
contentDescription = "打开红外设置",
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.testTag("remote_settings_button"),
|
||||
) {
|
||||
Text("⚙️", fontSize = 20.sp)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
+4
-4
@@ -33,7 +33,7 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalResources
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -76,7 +76,7 @@ internal fun InfraredControlScreen(
|
||||
onMessageShown: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
val resources = LocalResources.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
@@ -85,7 +85,7 @@ internal fun InfraredControlScreen(
|
||||
is InfraredUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
@@ -93,7 +93,7 @@ internal fun InfraredControlScreen(
|
||||
is InfraredUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user