From 533ce46de69b66c9d45c89225a17c71b0f0f7398 Mon Sep 17 00:00:00 2001 From: tzh <3091898014@qq.com> Date: Mon, 24 Aug 2026 11:26:46 +0800 Subject: [PATCH 1/9] feat(scene): add scene editor, executor and fakes (phase 1) Introduce the scene module skeleton that satisfies phase 1 exit conditions for tzh's responsibility in project_plan.md section 6.5: scene list, editor and Fake-driven execution that can be tested independently. Domain layer (domain/): - Scene, SceneAction (SocketPower / InfraredKey), SceneIcon, SceneColor - SceneExecutionResult with per-action outcome aggregation - SceneRepository port (observe/get/create/update/delete, provider-isolated) - SceneExecutor port (sequential execution, per-action failure continues) Fakes (testing/fakes/): - FakeSceneRepository: in-memory, provider-isolated, seeds local-experience catalog - FakeSceneExecutor: drives existing SocketGateway/InfraredGateway fakes, rejects concurrent execution of the same scene Android app (android/app/): - SceneListScreen / SceneListViewModel: list, empty state, delete confirm - SceneEditorScreen / SceneEditorViewModel: name/icon/color, action add/remove/reorder - SceneExecutionScreen / SceneExecutionViewModel: run, progress, per-action summary - SceneRoutes + NavGraphBuilder extensions wired into ABoxManagerApp - SceneModule (debug) binds FakeSceneRepository, FakeSceneExecutor and a LocalProfileId qualifier that feat/provider-login will retire later Build (settings.gradle.kts): - Apply foojay-resolver-convention so the JVM 17 toolchain is auto-provisioned Resources: zh-rCN / en strings for all scene surfaces Tests: 20 unit tests covering repository isolation, CRUD, executor ordering, failure continuation and concurrent-execution rejection --- .../flagship/abox/manager/di/SceneModule.kt | 41 +++ .../abox/manager/ui/scene/LocalProfileId.kt | 14 + .../manager/ui/scene/SceneEditorScreen.kt | 283 ++++++++++++++++++ .../manager/ui/scene/SceneEditorViewModel.kt | 136 +++++++++ .../manager/ui/scene/SceneExecutionScreen.kt | 221 ++++++++++++++ .../ui/scene/SceneExecutionViewModel.kt | 64 ++++ .../abox/manager/ui/scene/SceneListScreen.kt | 195 ++++++++++++ .../manager/ui/scene/SceneListViewModel.kt | 59 ++++ .../abox/manager/ui/scene/SceneNavigation.kt | 72 +++++ .../src/main/res/values-zh-rCN/strings.xml | 39 +++ android/app/src/main/res/values/strings.xml | 39 +++ .../com/flagship/abox/manager/domain/Scene.kt | 78 +++++ .../abox/manager/domain/SceneExecution.kt | 32 ++ .../abox/manager/domain/SceneExecutor.kt | 12 + .../abox/manager/domain/SceneRepository.kt | 34 +++ settings.gradle.kts | 4 + .../testing/fakes/FakeSceneRepository.kt | 245 +++++++++++++++ .../testing/fakes/FakeSceneExecutorTest.kt | 167 +++++++++++ .../testing/fakes/FakeSceneRepositoryTest.kt | 149 +++++++++ 19 files changed, 1884 insertions(+) create mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneRepository.kt create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt create mode 100644 testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt create mode 100644 testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt new file mode 100644 index 0000000..fb2e90f --- /dev/null +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt @@ -0,0 +1,41 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +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.testing.fakes.FakeSceneExecutor +import com.flagship.abox.manager.testing.fakes.FakeSceneRepository +import com.flagship.abox.manager.ui.scene.LocalProfileId +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Phase 1 Hilt bindings for the scene module. Binds the in-memory [FakeSceneRepository] and the + * [FakeSceneExecutor] that drives the existing socket/infrared fakes. The bindings live in the + * debug source set so the release build does not ship the Fake implementation; the real provider + * bindings will land in `feat/scene-persistence` and `feat/provider-login` respectively. + */ +@Module +@InstallIn(SingletonComponent::class) +object SceneModule { + @Provides + @Singleton + fun provideSceneRepository(): SceneRepository = FakeSceneRepository() + + @Provides + @Singleton + fun provideSceneExecutor( + socketGateway: SocketGateway, + infraredGateway: InfraredGateway, + ): SceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway) + + @Provides + @Singleton + @LocalProfileId + fun provideLocalProfileId(): ProviderProfileId = FakeSceneRepository.localProfile +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt new file mode 100644 index 0000000..44d1730 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt @@ -0,0 +1,14 @@ +package com.flagship.abox.manager.ui.scene + +import javax.inject.Qualifier + +/** + * Qualifies the [com.flagship.abox.manager.domain.ProviderProfileId] used by the scene module + * while the real provider selector is still being built (see project_plan.md §7 dependency graph). + * + * Phase 2 will replace this binding with the active session's profile id observed from + * SessionGateway; the qualifier will be retired at that point. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class LocalProfileId diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt new file mode 100644 index 0000000..ee4e407 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -0,0 +1,283 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +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.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +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.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SocketPowerState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SceneEditorScreen( + sceneId: com.flagship.abox.manager.domain.SceneId?, + onBack: () -> Unit, + onSaved: () -> Unit, + viewModel: SceneEditorViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + androidx.compose.runtime.LaunchedEffect(state.isSaved) { + if (state.isSaved) onSaved() + } + androidx.compose.runtime.LaunchedEffect(sceneId) { + viewModel.initialize(sceneId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource( + if (state.isEditing) R.string.scene_editor_edit_title + else R.string.scene_editor_new_title, + ), + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + OutlinedTextField( + value = state.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.scene_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Text(stringResource(R.string.scene_icon_label), style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneIcon.entries.forEach { icon -> + FilterChip( + selected = state.icon == icon, + onClick = { viewModel.updateIcon(icon) }, + label = { Text(icon.displayLabel()) }, + ) + } + } + } + item { + OutlinedTextField( + value = state.colorHex, + onValueChange = viewModel::updateColor, + label = { Text(stringResource(R.string.scene_color_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Text( + stringResource(R.string.scene_actions_label, state.actions.size), + style = MaterialTheme.typography.titleSmall, + ) + } + items(state.actions, key = { it.id.value }) { action -> + ActionRow( + action = action, + index = state.actions.indexOf(action), + total = state.actions.size, + onMoveUp = { i -> viewModel.moveAction(i, i - 1) }, + onMoveDown = { i -> viewModel.moveAction(i, i + 1) }, + onRemove = { viewModel.removeAction(action.id) }, + ) + } + item { AddSocketActionSection(viewModel) } + item { AddInfraredActionSection(viewModel) } + item { + state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button( + onClick = viewModel::save, + enabled = state.isValid && !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.scene_save_action)) + } + } + } + } +} + +@Composable +private fun ActionRow( + action: SceneAction, + index: Int, + total: Int, + onMoveUp: (Int) -> Unit, + onMoveDown: (Int) -> Unit, + onRemove: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = when (action) { + is SceneAction.SocketPower -> stringResource( + R.string.scene_action_socket, + action.deviceName.value, + action.powerState.name, + ) + is SceneAction.InfraredKey -> stringResource( + R.string.scene_action_infrared, + action.deviceName.value, + action.key, + ) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { + Icon(Icons.Filled.ArrowUpward, contentDescription = null) + } + IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) { + Icon(Icons.Filled.ArrowDownward, contentDescription = null) + } + IconButton(onClick = onRemove) { + Icon(Icons.Filled.Delete, contentDescription = null) + } + } + } +} + +@Composable +private fun AddSocketActionSection(viewModel: SceneEditorViewModel) { + var deviceName by remember { mutableStateOf("") } + var powerOn by remember { mutableStateOf(true) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.scene_add_socket_action), + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = deviceName, + onValueChange = { deviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SocketPowerState.entries.forEach { state -> + FilterChip( + selected = powerOn == (state == SocketPowerState.ON), + onClick = { powerOn = state == SocketPowerState.ON }, + label = { Text(state.name) }, + ) + } + } + AssistChip( + onClick = { + viewModel.addSocketAction( + deviceName, + if (powerOn) SocketPowerState.ON else SocketPowerState.OFF, + ) + deviceName = "" + }, + label = { Text(stringResource(R.string.scene_add_action)) }, + ) + } +} + +@Composable +private fun AddInfraredActionSection(viewModel: SceneEditorViewModel) { + var deviceName by remember { mutableStateOf("") } + var key by remember { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.scene_add_infrared_action), + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = deviceName, + onValueChange = { deviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = key, + onValueChange = { key = it }, + label = { Text(stringResource(R.string.scene_infrared_key_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + AssistChip( + onClick = { + viewModel.addInfraredAction(deviceName, key) + deviceName = "" + key = "" + }, + label = { Text(stringResource(R.string.scene_add_action)) }, + ) + } +} + +private fun SceneIcon.displayLabel(): String = when (this) { + SceneIcon.HOME -> "Home" + SceneIcon.OFFICE -> "Office" + SceneIcon.NIGHT -> "Night" + SceneIcon.MOVIE -> "Movie" + SceneIcon.AWAY -> "Away" + SceneIcon.CUSTOM -> "Custom" +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt new file mode 100644 index 0000000..5bf3b6b --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -0,0 +1,136 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +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.domain.SceneRepository +import com.flagship.abox.manager.domain.SocketPowerState +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class SceneEditorState( + val sceneId: SceneId? = null, + val name: String = "", + val icon: SceneIcon = SceneIcon.HOME, + val colorHex: String = "#FF8A65", + val actions: List = emptyList(), + val isSaving: Boolean = false, + val isSaved: Boolean = false, + val errorMessage: String? = null, +) { + val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() + val isEditing: Boolean get() = sceneId != null +} + +@HiltViewModel +class SceneEditorViewModel @Inject constructor( + private val sceneRepository: SceneRepository, + @LocalProfileId private val localProfileId: ProviderProfileId, +) : ViewModel() { + + private val _state = MutableStateFlow(SceneEditorState()) + val state: StateFlow = _state.asStateFlow() + + fun initialize(sceneId: SceneId?) { + if (sceneId == null) { + _state.value = SceneEditorState() + return + } + viewModelScope.launch { + val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value + if (scene != null) { + _state.value = SceneEditorState( + sceneId = scene.id, + name = scene.name, + icon = scene.icon, + colorHex = scene.color.hex, + actions = scene.actions, + ) + } else { + _state.update { it.copy(errorMessage = "Scene not found") } + } + } + } + + fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) } + + fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) } + + fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) } + + fun addSocketAction(deviceName: String, powerState: SocketPowerState) { + if (deviceName.isBlank()) return + val action = SceneAction.SocketPower( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = localProfileId, + deviceName = DeviceName(deviceName), + powerState = powerState, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + + fun addInfraredAction(deviceName: String, key: String) { + if (deviceName.isBlank() || key.isBlank()) return + val action = SceneAction.InfraredKey( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = localProfileId, + deviceName = DeviceName(deviceName), + key = key, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + + fun removeAction(actionId: SceneActionId) = + _state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) } + + fun moveAction(from: Int, to: Int) { + val current = _state.value.actions + if (from !in current.indices || to !in current.indices || from == to) return + val updated = current.toMutableList().apply { add(to, removeAt(from)) } + _state.update { it.copy(actions = updated) } + } + + fun save() { + val state = _state.value + if (!state.isValid || state.isSaving) return + _state.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + val scene = Scene( + id = state.sceneId ?: SceneId("${localProfileId.value}:scene-${UUID.randomUUID()}"), + profileId = localProfileId, + name = state.name.trim(), + icon = state.icon, + color = SceneColor(state.colorHex), + actions = state.actions, + ) + val result = if (state.isEditing) { + sceneRepository.updateScene(localProfileId, scene) + } else { + sceneRepository.createScene(localProfileId, scene) + } + _state.update { current -> + when (result) { + is DomainResult.Success -> current.copy(isSaving = false, isSaved = true) + is DomainResult.Failure -> current.copy( + isSaving = false, + errorMessage = result.error.detail ?: "Save failed", + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt new file mode 100644 index 0000000..9630525 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -0,0 +1,221 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionExecutionResult +import com.flagship.abox.manager.domain.SceneActionOutcome + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SceneExecutionScreen( + sceneId: com.flagship.abox.manager.domain.SceneId, + onBack: () -> Unit, + viewModel: SceneExecutionViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(sceneId) { viewModel.initialize(sceneId) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.scene_execution_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + } + }, + ) + }, + ) { innerPadding -> + when (val state = uiState) { + SceneExecutionUiState.Loading -> CenteredLoading(innerPadding) + is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message) + is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute) + is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name) + is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack) + } + } +} + +@Composable +private fun CenteredLoading(paddingValues: PaddingValues) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun CenteredMessage(paddingValues: PaddingValues, message: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error) + } +} + +@Composable +private fun ReadyContent( + paddingValues: PaddingValues, + sceneName: String, + onExecute: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(sceneName, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.size(24.dp)) + Button(onClick = onExecute) { + Text(stringResource(R.string.scene_execution_run)) + } + } +} + +@Composable +private fun RunningContent(paddingValues: PaddingValues, sceneName: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.size(16.dp)) + Text( + stringResource(R.string.scene_execution_running, sceneName), + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun CompletedContent( + paddingValues: PaddingValues, + result: com.flagship.abox.manager.domain.SceneExecutionResult, + onBack: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + text = stringResource( + R.string.scene_execution_summary, + result.succeededCount, + result.failedCount, + result.totalActions, + ), + style = MaterialTheme.typography.titleMedium, + ) + } + items(result.results, key = { it.action.id.value }) { entry -> + ActionResultRow(entry) + } + item { + Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.scene_execution_done)) + } + } + } +} + +@Composable +private fun ActionResultRow(entry: SceneActionExecutionResult) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { + Icons.Filled.CheckCircle + } else { + Icons.Filled.Error + }, + contentDescription = null, + tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.action.describe(), + style = MaterialTheme.typography.bodyMedium, + ) + entry.error?.detail?.let { detail -> + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +private fun SceneAction.describe(): String = when (this) { + is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}" + is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key" +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt new file mode 100644 index 0000000..b577d6b --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -0,0 +1,64 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneExecutionResult +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed interface SceneExecutionUiState { + data object Loading : SceneExecutionUiState + data class Ready(val scene: Scene) : SceneExecutionUiState + data class Running(val scene: Scene) : SceneExecutionUiState + data class Completed(val result: SceneExecutionResult) : SceneExecutionUiState + data class Failed(val message: String) : SceneExecutionUiState +} + +@HiltViewModel +class SceneExecutionViewModel @Inject constructor( + private val sceneRepository: SceneRepository, + private val sceneExecutor: SceneExecutor, + @LocalProfileId private val localProfileId: ProviderProfileId, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SceneExecutionUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun initialize(sceneId: SceneId) { + _uiState.value = SceneExecutionUiState.Loading + viewModelScope.launch { + val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value + _uiState.value = if (scene != null) { + SceneExecutionUiState.Ready(scene) + } else { + SceneExecutionUiState.Failed("Scene not found") + } + } + } + + fun execute() { + val current = _uiState.value + val scene = (current as? SceneExecutionUiState.Ready)?.scene + ?: (current as? SceneExecutionUiState.Running)?.scene + ?: return + _uiState.value = SceneExecutionUiState.Running(scene) + viewModelScope.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", + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt new file mode 100644 index 0000000..5b17696 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -0,0 +1,195 @@ +package com.flagship.abox.manager.ui.scene + +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.Column +import androidx.compose.foundation.layout.PaddingValues +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.padding +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.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +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 com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneId + +@Composable +fun SceneListScreen( + onAddScene: () -> Unit, + onEditScene: (SceneId) -> Unit, + onExecuteScene: (SceneId) -> Unit, + viewModel: SceneListViewModel = hiltViewModel(), +) { + val scenes by viewModel.scenes.collectAsStateWithLifecycle() + val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.scene_list_title)) }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onAddScene, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.scene_create_action)) }, + ) + }, + ) { innerPadding -> + if (scenes.isEmpty()) { + EmptySceneList(modifier = Modifier.padding(innerPadding)) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding() + 80.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(scenes, key = { it.id.value }) { scene -> + SceneRow( + scene = scene, + onClick = { onExecuteScene(scene.id) }, + onEdit = { onEditScene(scene.id) }, + onDelete = { viewModel.markPendingDelete(scene.id) }, + ) + } + } + } + } + + pendingDelete?.let { sceneId -> + AlertDialog( + onDismissRequest = viewModel::cancelDelete, + title = { Text(stringResource(R.string.scene_delete_dialog_title)) }, + text = { Text(stringResource(R.string.scene_delete_dialog_message)) }, + confirmButton = { + TextButton(onClick = { viewModel.deleteScene(sceneId) }) { + Text(stringResource(R.string.scene_delete_confirm)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::cancelDelete) { + Text(stringResource(R.string.scene_delete_cancel)) + } + }, + ) + } +} + +@Composable +private fun SceneRow( + scene: Scene, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = scene.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource( + R.string.scene_action_count, + scene.actions.size, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onEdit) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = stringResource(R.string.scene_edit), + ) + } + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.scene_delete), + ) + } + } + } +} + +@Composable +private fun EmptySceneList(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.scene_empty_title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.size(12.dp)) + Text( + text = stringResource(R.string.scene_empty_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? { + val hex = value.removePrefix("#") + return runCatching { Color(hex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt new file mode 100644 index 0000000..1e47bc7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -0,0 +1,59 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Phase 1 scene list state. The repository is observed for the local-experience profile so the + * UI updates when scenes are added, edited or deleted from any screen. + * + * Phase 2 will replace the hard-coded [LocalProfileId] injection with the active session observed + * from SessionGateway (see project_plan.md §6.5 and §7). + */ +@HiltViewModel +class SceneListViewModel @Inject constructor( + private val sceneRepository: SceneRepository, + @LocalProfileId private val localProfileId: ProviderProfileId, +) : ViewModel() { + + val scenes: StateFlow> = sceneRepository + .observeScenes(localProfileId) + .map { result -> (result as? DomainResult.Success)?.value.orEmpty() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + private val _pendingDelete = MutableStateFlow(null) + val pendingDelete: StateFlow = _pendingDelete.asStateFlow() + + fun deleteScene(sceneId: SceneId) { + viewModelScope.launch { + sceneRepository.deleteScene(localProfileId, sceneId) + _pendingDelete.value = null + } + } + + fun cancelDelete() { + _pendingDelete.value = null + } + + fun markPendingDelete(sceneId: SceneId) { + _pendingDelete.value = sceneId + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt new file mode 100644 index 0000000..d335c4e --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -0,0 +1,72 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.flagship.abox.manager.domain.SceneId + +/** + * Scene navigation routes. [SceneListRoute] is the entry point; the editor and execution routes + * carry the scene id as a path segment so deep linking works without manual argument parsing. + */ +object SceneRoutes { + const val LIST = "scene/list" + const val EDITOR = "scene/editor" + const val EDITOR_WITH_ID = "scene/editor/{sceneId}" + const val EXECUTION = "scene/execution/{sceneId}" + + fun editorFor(sceneId: SceneId): String = "scene/editor/${sceneId.value}" + fun executionFor(sceneId: SceneId): String = "scene/execution/${sceneId.value}" +} + +fun NavGraphBuilder.sceneListDestination( + onAddScene: () -> Unit, + onEditScene: (SceneId) -> Unit, + onExecuteScene: (SceneId) -> Unit, +) { + composable(SceneRoutes.LIST) { + SceneListScreen( + onAddScene = onAddScene, + onEditScene = onEditScene, + onExecuteScene = onExecuteScene, + ) + } +} + +fun NavGraphBuilder.sceneEditorDestination( + onBack: () -> Unit, + onSaved: () -> Unit, +) { + composable( + route = SceneRoutes.EDITOR, + ) { + SceneEditorScreen(sceneId = null, onBack = onBack, onSaved = onSaved) + } + composable( + route = SceneRoutes.EDITOR_WITH_ID, + arguments = listOf(navArgument("sceneId") { type = NavType.StringType }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getString("sceneId") + SceneEditorScreen( + sceneId = id?.let { SceneId(it) }, + onBack = onBack, + onSaved = onSaved, + ) + } +} + +fun NavGraphBuilder.sceneExecutionDestination( + onBack: () -> Unit, +) { + composable( + route = SceneRoutes.EXECUTION, + arguments = listOf(navArgument("sceneId") { type = NavType.StringType }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getString("sceneId") + SceneExecutionScreen( + sceneId = SceneId(requireNotNull(id) { "sceneId argument is required" }), + onBack = onBack, + ) + } +} diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 696dc16..d387152 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -42,4 +42,43 @@ 情景动作与执行插槽(由 tzh 模块实现)。 活动记录 未记录任何事件。 + + + 情景模式 + 新建情景 + %1$d 个动作 + 编辑情景 + 删除情景 + 还没有情景 + 新建第一个情景,组合插座和红外动作。 + + + 删除情景 + 此操作无法撤销。 + 删除 + 取消 + + + 新建情景 + 编辑情景 + 返回 + 名称 + 图标 + 主题色(十六进制) + 动作(%1$d) + 添加插座动作 + 添加红外动作 + 设备名称 + 红外按键 + 添加 + 保存情景 + 插座 %1$s -> %2$s + 红外 %1$s -> %2$s + + + 执行情景 + 执行 + 正在执行 %1$s… + %3$d 个动作中成功 %1$d 个,失败 %2$d 个 + 完成 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c8818bb..1d5faa1 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -42,4 +42,43 @@ Scene actions and execution slot for tzh. Activity Log No recorded events yet. + + + Scenes + New scene + %1$d actions + Edit scene + Delete scene + No scenes yet + Create your first scene to combine socket and infrared actions. + + + Delete scene + This action cannot be undone. + Delete + Cancel + + + New scene + Edit scene + Back + Name + Icon + Accent color (hex) + Actions (%1$d) + Add socket action + Add infrared action + Device name + Infrared key + Add + Save scene + Socket %1$s -> %2$s + Infrared %1$s -> %2$s + + + Run scene + Run + Running %1$s… + %1$d succeeded, %2$d failed of %3$d actions + Done diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt new file mode 100644 index 0000000..c0ac4eb --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -0,0 +1,78 @@ +package com.flagship.abox.manager.domain + +@JvmInline +value class SceneId(val value: String) + +@JvmInline +value class SceneActionId(val value: String) + +enum class SceneActionType { + SOCKET_POWER, + INFRARED_KEY, +} + +/** + * A single executable step inside a [Scene]. + * + * Socket actions reference the [DeviceName] accepted by ABSDK 0630 and a target [SocketPowerState]. + * Infrared actions reference a [DeviceName] and the stable key string provided by the infrared + * module; the executor delegates the actual send to [InfraredGateway.sendKey]. + * + * `DeviceName` is used (rather than `DeviceId`) because ABSDK addresses devices by name, matching + * the existing [SocketGateway] and [InfraredGateway] contracts. The infrared module is responsible + * for keeping the key string stable across edits, so scenes can reference it without coupling to + * the infrared storage primary key. + */ +sealed interface SceneAction { + val id: SceneActionId + val profileId: ProviderProfileId + val deviceName: DeviceName + val type: SceneActionType + + data class SocketPower( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + override val deviceName: DeviceName, + val powerState: SocketPowerState, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.SOCKET_POWER + } + + data class InfraredKey( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + override val deviceName: DeviceName, + val key: String, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.INFRARED_KEY + } +} + +/** + * Visual identity for a scene. Kept as a small enum so the UI can resolve the icon without + * external resources and so the persistence layer stays stable across icon set changes. + */ +enum class SceneIcon { + HOME, + OFFICE, + NIGHT, + MOVIE, + AWAY, + CUSTOM, +} + +/** + * Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the + * Compose color space; persistence stores it verbatim. + */ +@JvmInline +value class SceneColor(val hex: String) + +data class Scene( + val id: SceneId, + val profileId: ProviderProfileId, + val name: String, + val icon: SceneIcon, + val color: SceneColor, + val actions: List, +) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt new file mode 100644 index 0000000..8637c78 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -0,0 +1,32 @@ +package com.flagship.abox.manager.domain + +enum class SceneActionOutcome { + SUCCESS, + FAILURE, +} + +/** + * Result of executing a single [SceneAction]. [error] is non-null when [outcome] is [FAILURE]. + */ +data class SceneActionExecutionResult( + val action: SceneAction, + val outcome: SceneActionOutcome, + val error: DomainError?, +) + +/** + * Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they + * appear in [scene.actions]; a per-action failure does not abort subsequent actions. + */ +data class SceneExecutionResult( + val scene: Scene, + val results: List, +) { + val totalActions: Int get() = results.size + val succeededCount: Int + get() = results.count { it.outcome == SceneActionOutcome.SUCCESS } + val failedCount: Int + get() = results.count { it.outcome == SceneActionOutcome.FAILURE } + val allSucceeded: Boolean + get() = results.isNotEmpty() && failedCount == 0 +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt new file mode 100644 index 0000000..71432ce --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt @@ -0,0 +1,12 @@ +package com.flagship.abox.manager.domain + +/** + * Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent + * actions; callers receive a [SceneExecutionResult] that summarizes every step. + * + * Implementations must be idempotent from the caller's perspective: triggering a scene that is + * already running returns [DomainError.OperationFailed] rather than starting a second pass. + */ +interface SceneExecutor { + suspend fun execute(scene: Scene): DomainResult +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneRepository.kt new file mode 100644 index 0000000..6f5fd93 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneRepository.kt @@ -0,0 +1,34 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.Flow + +/** + * Owns the scene catalog for every provider profile. Implementations must isolate data by + * [ProviderProfileId]: scenes, actions and identifiers must not leak across profiles. + * + * This port is intentionally persistence-agnostic. The first PR provides an in-memory Fake + * implementation; Room persistence will land in a follow-up `feat/scene-persistence` branch. + */ +interface SceneRepository { + fun observeScenes(profileId: ProviderProfileId): Flow>> + + suspend fun getScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult + + suspend fun createScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult + + suspend fun updateScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult + + suspend fun deleteScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b9cad8..da2d3d6 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,10 @@ pluginManagement { } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt new file mode 100644 index 0000000..7d47222 --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -0,0 +1,245 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +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.SceneActionId +import com.flagship.abox.manager.domain.SceneActionOutcome +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneExecutionResult +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory [SceneRepository] used by the scene UI and ViewModel tests. Scenes are isolated by + * [ProviderProfileId]; mutating scenes for one profile never touches another profile's flow. + * + * The repository is intentionally deterministic: [defaultScenes] seeds the same catalog every + * run so UI snapshots and tests stay stable. Mutations emit a new list through the profile's + * StateFlow. + */ +class FakeSceneRepository : SceneRepository { + private val flows = ConcurrentHashMap< + ProviderProfileId, + MutableStateFlow>>, + >() + + override fun observeScenes( + profileId: ProviderProfileId, + ): Flow>> = flowFor(profileId) + + override suspend fun getScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult { + val scenes = currentScenes(profileId) ?: return notFound(sceneId) + return DomainResult.Success(scenes.firstOrNull { it.id == sceneId }) + } + + override suspend fun createScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult { + require(scene.profileId == profileId) { + "Scene profileId must match the supplied profileId" + } + val scenes = currentScenes(profileId).orEmpty() + if (scenes.any { it.id == scene.id }) { + return DomainResult.Failure( + DomainError.InvalidRequest( + detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}", + ), + ) + } + val updated = scenes + scene + publish(profileId, updated) + return DomainResult.Success(scene) + } + + override suspend fun updateScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult { + require(scene.profileId == profileId) { + "Scene profileId must match the supplied profileId" + } + val scenes = currentScenes(profileId).orEmpty() + val index = scenes.indexOfFirst { it.id == scene.id } + if (index < 0) return notFound(scene.id) + val updated = scenes.toMutableList().apply { set(index, scene) } + publish(profileId, updated) + return DomainResult.Success(scene) + } + + override suspend fun deleteScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult { + val scenes = currentScenes(profileId).orEmpty() + val updated = scenes.filterNot { it.id == sceneId } + if (updated.size == scenes.size) return notFound(sceneId) + publish(profileId, updated) + return DomainResult.Success(Unit) + } + + /** + * Replaces the catalog for [profileId] in tests. Returns the same repository so callers can + * chain `repo.setScenes(profile, listOf(...))`. + */ + fun setScenes(profileId: ProviderProfileId, scenes: List): FakeSceneRepository { + require(scenes.all { it.profileId == profileId }) { + "All scenes must belong to the supplied provider profile" + } + publish(profileId, scenes) + return this + } + + fun setError(profileId: ProviderProfileId, error: DomainError) { + flowFor(profileId).value = DomainResult.Failure(error) + } + + private fun flowFor( + profileId: ProviderProfileId, + ): MutableStateFlow>> = + flows.getOrPut(profileId) { + val initial = if (profileId == localProfile) defaultScenes(profileId) else emptyList() + MutableStateFlow(DomainResult.Success(initial)) + } + + private fun currentScenes(profileId: ProviderProfileId): List? = + (flowFor(profileId).value as? DomainResult.Success)?.value + + private fun publish(profileId: ProviderProfileId, scenes: List) { + flowFor(profileId).value = DomainResult.Success(scenes) + } + + private fun notFound(sceneId: SceneId): DomainResult = DomainResult.Failure( + DomainError.InvalidRequest( + detail = "Scene ${sceneId.value} not found", + ), + ) + + private fun defaultScenes(profileId: ProviderProfileId): List = listOf( + Scene( + id = SceneId("${profileId.value}:scene-leaving-home"), + profileId = profileId, + name = "Leaving home", + icon = SceneIcon.AWAY, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ), + SceneAction.InfraredKey( + id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "power", + ), + ), + ), + Scene( + id = SceneId("${profileId.value}:scene-movie-night"), + profileId = profileId, + name = "Movie night", + icon = SceneIcon.MOVIE, + color = SceneColor("#7986CB"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ), + ), + ), + ) + + companion object { + /** + * The canonical profile id used by the in-app local experience provider. UI code that + * hard-codes a profile in phase 1 should use this constant so the Fake shows data. + */ + val localProfile: ProviderProfileId = ProviderProfileId("local-experience") + } +} + +/** + * Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes. + * + * Behaviour matches the project_plan.md §4.7 contract: + * - actions run sequentially in [Scene.actions] order; + * - a single action failure does not abort subsequent actions; + * - the returned [SceneExecutionResult] contains one entry per action; + * - re-entering execute() while a scene is running returns [DomainError.OperationFailed]. + */ +class FakeSceneExecutor( + private val socketGateway: SocketGateway, + private val infraredGateway: InfraredGateway, +) : SceneExecutor { + private val running = ConcurrentHashMap() + + override suspend fun execute(scene: Scene): DomainResult { + val alreadyRunning = running.putIfAbsent(scene.id, true) != null + if (alreadyRunning) { + return DomainResult.Failure( + DomainError.OperationFailed( + detail = "Scene ${scene.id.value} is already running", + ), + ) + } + return try { + val results = scene.actions.map { action -> executeAction(scene.profileId, action) } + DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) + } finally { + running.remove(scene.id) + } + } + + private suspend fun executeAction( + profileId: ProviderProfileId, + action: SceneAction, + ): SceneActionExecutionResult { + val result = when (action) { + is SceneAction.SocketPower -> socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + + is SceneAction.InfraredKey -> infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + } + return when (result) { + is DomainResult.Success -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + + is DomainResult.Failure -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = result.error, + ) + } + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt new file mode 100644 index 0000000..7e2551c --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt @@ -0,0 +1,167 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DeviceName +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.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +import com.flagship.abox.manager.domain.SceneActionOutcome +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.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeSceneExecutorTest { + private val profileId = ProviderProfileId("abox") + + @Test + fun executesActionsInOrder() = runTest { + val socketGateway = FakeSocketGateway() + val infraredGateway = FakeInfraredGateway() + val executor = FakeSceneExecutor(socketGateway, infraredGateway) + val scene = scene( + actions = listOf( + socketAction("socket-1", SocketPowerState.ON), + infraredAction("ir-1", "power"), + socketAction("socket-2", SocketPowerState.OFF), + ), + ) + + val result = executor.execute(scene) + + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(3, executionResult.results.size) + executionResult.results.forEach { + assertEquals(SceneActionOutcome.SUCCESS, it.outcome) + } + assertEquals(1, infraredGateway.sentKeys.size) + } + + @Test + fun continuesAfterActionFailure() = runTest { + val socketGateway = FakeSocketGateway() + socketGateway.setError( + profileId, + DeviceName("socket-1"), + DomainError.DeviceOffline(detail = "offline"), + ) + val infraredGateway = FakeInfraredGateway() + val executor = FakeSceneExecutor(socketGateway, infraredGateway) + val scene = scene( + actions = listOf( + socketAction("socket-1", SocketPowerState.ON), + infraredAction("ir-1", "power"), + ), + ) + + val result = executor.execute(scene) + + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome) + assertEquals(1, executionResult.succeededCount) + assertEquals(1, executionResult.failedCount) + assertFalse(executionResult.allSucceeded) + } + + @Test + fun runningSceneRejectsConcurrentExecution() = runTest { + val gate = CompletableDeferred() + val socketGateway = BlockingSocketGateway(gate) + val executor = FakeSceneExecutor(socketGateway, FakeInfraredGateway()) + val scene = scene( + actions = listOf(socketAction("socket-1", SocketPowerState.ON)), + ) + + val first = async { executor.execute(scene) } + // Let the first coroutine enter the executor and block on the socket gateway. + testScheduler.advanceUntilIdle() + val second = async { executor.execute(scene) } + val secondResult = second.await() + + assertTrue(secondResult is DomainResult.Failure) + assertTrue((secondResult as DomainResult.Failure).error is DomainError.OperationFailed) + + gate.complete(Unit) + val firstResult = first.await() + assertTrue(firstResult is DomainResult.Success) + } + + @Test + fun emptySceneSucceedsWithNoResults() = runTest { + val executor = FakeSceneExecutor(FakeSocketGateway(), FakeInfraredGateway()) + val scene = scene(actions = emptyList()) + + val result = executor.execute(scene) + + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(0, executionResult.results.size) + assertEquals(0, executionResult.succeededCount) + assertEquals(0, executionResult.failedCount) + assertFalse(executionResult.allSucceeded) + } + + private fun scene(actions: List): Scene = Scene( + id = SceneId("${profileId.value}:scene-test"), + profileId = profileId, + name = "Test scene", + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = actions, + ) + + private fun socketAction( + deviceName: String, + powerState: SocketPowerState, + ): SceneAction.SocketPower = SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:action-${deviceName}-${powerState.name}"), + profileId = profileId, + deviceName = DeviceName(deviceName), + powerState = powerState, + ) + + private fun infraredAction( + deviceName: String, + key: String, + ): SceneAction.InfraredKey = SceneAction.InfraredKey( + id = SceneActionId("${profileId.value}:action-${deviceName}-$key"), + profileId = profileId, + deviceName = DeviceName(deviceName), + key = key, + ) + + private class BlockingSocketGateway( + private val gate: CompletableDeferred, + ) : SocketGateway { + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = DomainResult.Success( + SocketStatus(profileId, deviceName, SocketPowerState.OFF), + ) + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + gate.await() + return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + } + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt new file mode 100644 index 0000000..e67f916 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt @@ -0,0 +1,149 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DeviceName +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.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +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.domain.SocketPowerState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeSceneRepositoryTest { + private val aboxProfile = ProviderProfileId("abox") + private val localProfile = FakeSceneRepository.localProfile + + @Test + fun defaultScenesAreIsolatedByProviderProfile() = runTest { + val repo = FakeSceneRepository() + + val aboxScenes = scenesFor(repo, aboxProfile) + val localScenes = scenesFor(repo, localProfile) + + assertEquals(0, aboxScenes.size) + assertEquals(2, localScenes.size) + assertTrue(localScenes.all { it.profileId == localProfile }) + } + + @Test + fun createScenePublishesSceneToFlow() = runTest { + val repo = FakeSceneRepository() + val scene = sampleScene(localProfile, "scene-1") + + val result = repo.createScene(localProfile, scene) + + assertTrue(result is DomainResult.Success) + assertEquals(scene, (result as DomainResult.Success).value) + val list = scenesFor(repo, localProfile) + assertEquals(3, list.size) + assertEquals(scene, list.last()) + } + + @Test + fun createSceneWithDuplicateIdFails() = runTest { + val repo = FakeSceneRepository() + val scene = sampleScene(localProfile, "dup") + + repo.createScene(localProfile, scene) + val second = repo.createScene(localProfile, scene) + + assertTrue(second is DomainResult.Failure) + assertTrue((second as DomainResult.Failure).error is DomainError.InvalidRequest) + } + + @Test + fun updateSceneReplacesExistingEntry() = runTest { + val repo = FakeSceneRepository() + val original = sampleScene(localProfile, "scene-2", "Original") + repo.createScene(localProfile, original) + + val updated = original.copy(name = "Renamed") + val result = repo.updateScene(localProfile, updated) + + assertTrue(result is DomainResult.Success) + val list = scenesFor(repo, localProfile) + assertEquals(3, list.size) + val stored = list.first { it.id == original.id } + assertEquals("Renamed", stored.name) + } + + @Test + fun deleteSceneRemovesEntry() = runTest { + val repo = FakeSceneRepository() + val scene = sampleScene(localProfile, "scene-3") + repo.createScene(localProfile, scene) + + val result = repo.deleteScene(localProfile, scene.id) + + assertTrue(result is DomainResult.Success) + val list = scenesFor(repo, localProfile) + assertEquals(2, list.size) + assertNull(list.firstOrNull { it.id == scene.id }) + } + + @Test + fun deleteUnknownSceneFails() = runTest { + val repo = FakeSceneRepository() + + val result = repo.deleteScene(localProfile, SceneId("missing")) + + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + } + + @Test + fun getSceneReturnsNullForUnknownId() = runTest { + val repo = FakeSceneRepository() + + val result = repo.getScene(localProfile, SceneId("missing")) + + assertTrue(result is DomainResult.Success) + assertNull((result as DomainResult.Success).value) + } + + @Test + fun setErrorStopsPublishingSuccess() = runTest { + val repo = FakeSceneRepository() + repo.setError(localProfile, DomainError.Unknown(detail = "boom")) + + val value = repo.observeScenes(localProfile).first() + + assertTrue(value is DomainResult.Failure) + assertNotNull((value as DomainResult.Failure).error) + } + + private suspend fun scenesFor( + repo: FakeSceneRepository, + profileId: ProviderProfileId, + ): List = (repo.observeScenes(profileId).first() as DomainResult.Success).value + + private fun sampleScene( + profileId: ProviderProfileId, + idSuffix: String, + name: String = "Test scene", + ): Scene = Scene( + id = SceneId("${profileId.value}:scene-$idSuffix"), + profileId = profileId, + name = name, + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-$idSuffix:1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ), + ), + ) +} -- 2.54.0 From df46d6273d250cc5dcf01840ff4705b0426163d4 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Tue, 25 Aug 2026 11:22:06 +0800 Subject: [PATCH 2/9] feat(scene): integrate scene domain, viewmodels, screens and tests --- .../flagship/abox/manager/di/SceneModule.kt | 41 ----- .../abox/manager/di/SceneIntegrationModule.kt | 31 ++++ .../abox/manager/ui/scene/LocalProfileId.kt | 14 -- .../manager/ui/scene/SceneEditorScreen.kt | 6 +- .../manager/ui/scene/SceneEditorViewModel.kt | 59 ++++--- .../manager/ui/scene/SceneExecutionScreen.kt | 8 +- .../ui/scene/SceneExecutionViewModel.kt | 12 +- .../abox/manager/ui/scene/SceneListScreen.kt | 45 ++++-- .../manager/ui/scene/SceneListViewModel.kt | 29 ++-- .../abox/manager/ui/scene/SceneNavigation.kt | 3 + .../ui/scene/SceneEditorViewModelTest.kt | 127 +++++++++++++++ .../ui/scene/SceneExecutionViewModelTest.kt | 90 +++++++++++ .../ui/scene/SceneListViewModelTest.kt | 101 ++++++++++++ .../manager/domain/DefaultSceneExecutor.kt | 69 ++++++++ .../manager/domain/InMemorySceneRepository.kt | 137 ++++++++++++++++ .../domain/DefaultSceneExecutorTest.kt | 152 ++++++++++++++++++ .../domain/InMemorySceneRepositoryTest.kt | 86 ++++++++++ gradle.properties | 5 +- 18 files changed, 898 insertions(+), 117 deletions(-) delete mode 100644 android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt delete mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt deleted file mode 100644 index fb2e90f..0000000 --- a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.flagship.abox.manager.di - -import com.flagship.abox.manager.domain.InfraredGateway -import com.flagship.abox.manager.domain.ProviderProfileId -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.testing.fakes.FakeSceneExecutor -import com.flagship.abox.manager.testing.fakes.FakeSceneRepository -import com.flagship.abox.manager.ui.scene.LocalProfileId -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** - * Phase 1 Hilt bindings for the scene module. Binds the in-memory [FakeSceneRepository] and the - * [FakeSceneExecutor] that drives the existing socket/infrared fakes. The bindings live in the - * debug source set so the release build does not ship the Fake implementation; the real provider - * bindings will land in `feat/scene-persistence` and `feat/provider-login` respectively. - */ -@Module -@InstallIn(SingletonComponent::class) -object SceneModule { - @Provides - @Singleton - fun provideSceneRepository(): SceneRepository = FakeSceneRepository() - - @Provides - @Singleton - fun provideSceneExecutor( - socketGateway: SocketGateway, - infraredGateway: InfraredGateway, - ): SceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway) - - @Provides - @Singleton - @LocalProfileId - fun provideLocalProfileId(): ProviderProfileId = FakeSceneRepository.localProfile -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt new file mode 100644 index 0000000..ed1aadb --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.DefaultSceneExecutor +import com.flagship.abox.manager.domain.InMemorySceneRepository +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SocketGateway +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Production dependency injection module for Scene management and execution. + */ +@Module +@InstallIn(SingletonComponent::class) +object SceneIntegrationModule { + @Provides + @Singleton + fun provideSceneRepository(): SceneRepository = InMemorySceneRepository() + + @Provides + @Singleton + fun provideSceneExecutor( + socketGateway: SocketGateway, + infraredGateway: InfraredGateway, + ): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway) +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt deleted file mode 100644 index 44d1730..0000000 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.flagship.abox.manager.ui.scene - -import javax.inject.Qualifier - -/** - * Qualifies the [com.flagship.abox.manager.domain.ProviderProfileId] used by the scene module - * while the real provider selector is still being built (see project_plan.md §7 dependency graph). - * - * Phase 2 will replace this binding with the active session's profile id observed from - * SessionGateway; the qualifier will be retired at that point. - */ -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class LocalProfileId diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index ee4e407..c31cfc3 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -14,8 +14,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowDownward -import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AssistChip import androidx.compose.material3.Button @@ -187,10 +185,10 @@ private fun ActionRow( ) } IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { - Icon(Icons.Filled.ArrowUpward, contentDescription = null) + Text("↑", style = MaterialTheme.typography.titleMedium) } IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) { - Icon(Icons.Filled.ArrowDownward, contentDescription = null) + Text("↓", style = MaterialTheme.typography.titleMedium) } IconButton(onClick = onRemove) { Icon(Icons.Filled.Delete, contentDescription = null) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index 5bf3b6b..4a4f695 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -4,7 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DomainResult -import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneActionId @@ -39,7 +39,7 @@ data class SceneEditorState( @HiltViewModel class SceneEditorViewModel @Inject constructor( private val sceneRepository: SceneRepository, - @LocalProfileId private val localProfileId: ProviderProfileId, + private val profileStore: ProviderProfileStore, ) : ViewModel() { private val _state = MutableStateFlow(SceneEditorState()) @@ -51,7 +51,12 @@ class SceneEditorViewModel @Inject constructor( return } viewModelScope.launch { - val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value + val profile = profileStore.activeProfile() + if (profile == null) { + _state.update { it.copy(errorMessage = "No active profile") } + return@launch + } + val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value if (scene != null) { _state.value = SceneEditorState( sceneId = scene.id, @@ -74,24 +79,30 @@ class SceneEditorViewModel @Inject constructor( fun addSocketAction(deviceName: String, powerState: SocketPowerState) { if (deviceName.isBlank()) return - val action = SceneAction.SocketPower( - id = SceneActionId(UUID.randomUUID().toString()), - profileId = localProfileId, - deviceName = DeviceName(deviceName), - powerState = powerState, - ) - _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.SocketPower( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + deviceName = DeviceName(deviceName), + powerState = powerState, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } } fun addInfraredAction(deviceName: String, key: String) { if (deviceName.isBlank() || key.isBlank()) return - val action = SceneAction.InfraredKey( - id = SceneActionId(UUID.randomUUID().toString()), - profileId = localProfileId, - deviceName = DeviceName(deviceName), - key = key, - ) - _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.InfraredKey( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + deviceName = DeviceName(deviceName), + key = key, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } } fun removeAction(actionId: SceneActionId) = @@ -109,18 +120,23 @@ class SceneEditorViewModel @Inject constructor( if (!state.isValid || state.isSaving) return _state.update { it.copy(isSaving = true, errorMessage = null) } viewModelScope.launch { + val profile = profileStore.activeProfile() + if (profile == null) { + _state.update { it.copy(isSaving = false, errorMessage = "No active profile") } + return@launch + } val scene = Scene( - id = state.sceneId ?: SceneId("${localProfileId.value}:scene-${UUID.randomUUID()}"), - profileId = localProfileId, + id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"), + profileId = profile.id, name = state.name.trim(), icon = state.icon, color = SceneColor(state.colorHex), actions = state.actions, ) val result = if (state.isEditing) { - sceneRepository.updateScene(localProfileId, scene) + sceneRepository.updateScene(profile.id, scene) } else { - sceneRepository.createScene(localProfileId, scene) + sceneRepository.createScene(profile.id, scene) } _state.update { current -> when (result) { @@ -134,3 +150,4 @@ class SceneEditorViewModel @Inject constructor( } } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt index 9630525..654a0bf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -13,8 +13,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator @@ -186,9 +186,9 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) { ) { Icon( imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { - Icons.Filled.CheckCircle + Icons.Filled.Check } else { - Icons.Filled.Error + Icons.Filled.Close }, contentDescription = null, tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt index b577d6b..f2acfac 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -3,7 +3,7 @@ package com.flagship.abox.manager.ui.scene import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.SceneExecutionResult import com.flagship.abox.manager.domain.SceneExecutor @@ -28,7 +28,7 @@ sealed interface SceneExecutionUiState { class SceneExecutionViewModel @Inject constructor( private val sceneRepository: SceneRepository, private val sceneExecutor: SceneExecutor, - @LocalProfileId private val localProfileId: ProviderProfileId, + private val profileStore: ProviderProfileStore, ) : ViewModel() { private val _uiState = MutableStateFlow(SceneExecutionUiState.Loading) @@ -37,7 +37,12 @@ class SceneExecutionViewModel @Inject constructor( fun initialize(sceneId: SceneId) { _uiState.value = SceneExecutionUiState.Loading viewModelScope.launch { - val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value + val profile = profileStore.activeProfile() + if (profile == null) { + _uiState.value = SceneExecutionUiState.Failed("No active profile") + return@launch + } + val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value _uiState.value = if (scene != null) { SceneExecutionUiState.Ready(scene) } else { @@ -62,3 +67,4 @@ class SceneExecutionViewModel @Inject constructor( } } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index 5b17696..0b9725a 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -19,9 +19,10 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Edit import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -44,11 +45,13 @@ import com.flagship.abox.manager.R import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneId +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SceneListScreen( onAddScene: () -> Unit, onEditScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit, + headerContent: @Composable (() -> Unit)? = null, viewModel: SceneListViewModel = hiltViewModel(), ) { val scenes by viewModel.scenes.collectAsStateWithLifecycle() @@ -68,19 +71,26 @@ fun SceneListScreen( ) }, ) { innerPadding -> - if (scenes.isEmpty()) { - EmptySceneList(modifier = Modifier.padding(innerPadding)) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 16.dp, - end = 16.dp, - top = innerPadding.calculateTopPadding(), - bottom = innerPadding.calculateBottomPadding() + 80.dp, - ), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding() + 80.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (headerContent != null) { + item(key = "header") { + headerContent() + } + } + if (scenes.isEmpty()) { + item(key = "empty") { + EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp)) + } + } else { items(scenes, key = { it.id.value }) { scene -> SceneRow( scene = scene, @@ -151,7 +161,7 @@ private fun SceneRow( } IconButton(onClick = onEdit) { Icon( - Icons.Filled.PlayArrow, + Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), ) } @@ -190,6 +200,7 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { } internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? { - val hex = value.removePrefix("#") - return runCatching { Color(hex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() + val cleanHex = hex.removePrefix("#") + return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt index 1e47bc7..bd62a57 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -3,35 +3,38 @@ package com.flagship.abox.manager.ui.scene import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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.SceneId import com.flagship.abox.manager.domain.SceneRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -/** - * Phase 1 scene list state. The repository is observed for the local-experience profile so the - * UI updates when scenes are added, edited or deleted from any screen. - * - * Phase 2 will replace the hard-coded [LocalProfileId] injection with the active session observed - * from SessionGateway (see project_plan.md §6.5 and §7). - */ +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class SceneListViewModel @Inject constructor( private val sceneRepository: SceneRepository, - @LocalProfileId private val localProfileId: ProviderProfileId, + private val profileStore: ProviderProfileStore, ) : ViewModel() { - val scenes: StateFlow> = sceneRepository - .observeScenes(localProfileId) + val scenes: StateFlow> = profileStore.observeActiveProfile() + .flatMapLatest { profile -> + if (profile == null) { + flowOf(DomainResult.Success(emptyList())) + } else { + sceneRepository.observeScenes(profile.id) + } + } .map { result -> (result as? DomainResult.Success)?.value.orEmpty() } .stateIn( scope = viewModelScope, @@ -44,7 +47,8 @@ class SceneListViewModel @Inject constructor( fun deleteScene(sceneId: SceneId) { viewModelScope.launch { - sceneRepository.deleteScene(localProfileId, sceneId) + val profile = profileStore.activeProfile() ?: return@launch + sceneRepository.deleteScene(profile.id, sceneId) _pendingDelete.value = null } } @@ -57,3 +61,4 @@ class SceneListViewModel @Inject constructor( _pendingDelete.value = sceneId } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt index d335c4e..23cf62e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -1,5 +1,6 @@ package com.flagship.abox.manager.ui.scene +import androidx.compose.runtime.Composable import androidx.navigation.NavGraphBuilder import androidx.navigation.NavType import androidx.navigation.compose.composable @@ -24,12 +25,14 @@ fun NavGraphBuilder.sceneListDestination( onAddScene: () -> Unit, onEditScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit, + headerContent: @Composable (() -> Unit)? = null, ) { composable(SceneRoutes.LIST) { SceneListScreen( onAddScene = onAddScene, onEditScene = onEditScene, onExecuteScene = onExecuteScene, + headerContent = headerContent, ) } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt new file mode 100644 index 0000000..6e90358 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -0,0 +1,127 @@ +package com.flagship.abox.manager.ui.scene + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +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.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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SceneEditorViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val profileStore = FakeProviderProfileStore() + private val sceneRepository = FakeSceneRepository() + private val profileId = ProviderProfileId("local-experience") + + private val existingScene = Scene( + id = SceneId("local-experience:scene-1"), + profileId = profileId, + name = "Existing Scene", + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("local-experience:action-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ), + ), + ) + + private suspend fun setupProfile() { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(existingScene)) + } + + @Test + fun initializeWithNullPreparesNewScene() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, profileStore) + viewModel.initialize(null) + + val state = viewModel.state.value + assertFalse(state.isEditing) + assertEquals("", state.name) + assertTrue(state.actions.isEmpty()) + assertFalse(state.isValid) + } + + @Test + fun initializeWithExistingSceneLoadsState() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, profileStore) + viewModel.initialize(existingScene.id) + advanceUntilIdle() + + val state = viewModel.state.value + assertTrue(state.isEditing) + assertEquals("Existing Scene", state.name) + assertEquals(1, state.actions.size) + assertTrue(state.isValid) + } + + @Test + fun addingAndReorderingActionsUpdatesState() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, profileStore) + viewModel.initialize(null) + viewModel.updateName("My New Scene") + + viewModel.addSocketAction("socket-1", SocketPowerState.ON) + viewModel.addInfraredAction("ir-1", "power") + advanceUntilIdle() + + assertEquals(2, viewModel.state.value.actions.size) + assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower) + assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey) + + // Move second to first + viewModel.moveAction(1, 0) + assertTrue(viewModel.state.value.actions[0] is SceneAction.InfraredKey) + assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower) + + // Remove first action + val firstId = viewModel.state.value.actions[0].id + viewModel.removeAction(firstId) + assertEquals(1, viewModel.state.value.actions.size) + } + + @Test + fun saveNewScenePersistsToRepository() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, profileStore) + viewModel.initialize(null) + viewModel.updateName("Brand New Scene") + viewModel.addSocketAction("socket-main", SocketPowerState.OFF) + advanceUntilIdle() + + assertTrue(viewModel.state.value.isValid) + viewModel.save() + advanceUntilIdle() + + assertTrue(viewModel.state.value.isSaved) + assertFalse(viewModel.state.value.isSaving) + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt new file mode 100644 index 0000000..02eb348 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt @@ -0,0 +1,90 @@ +package com.flagship.abox.manager.ui.scene + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +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.domain.SocketPowerState +import com.flagship.abox.manager.testing.MainDispatcherRule +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor +import com.flagship.abox.manager.testing.fakes.FakeSceneRepository +import com.flagship.abox.manager.testing.fakes.FakeSocketGateway +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SceneExecutionViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val profileStore = FakeProviderProfileStore() + private val sceneRepository = FakeSceneRepository() + private val fakeSocket = FakeSocketGateway() + private val fakeInfrared = FakeInfraredGateway() + private val sceneExecutor = FakeSceneExecutor(fakeSocket, fakeInfrared) + + private val profileId = ProviderProfileId("local-experience") + private val testScene = Scene( + id = SceneId("local-experience:scene-1"), + profileId = profileId, + name = "Execution Test Scene", + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("local-experience:action-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ), + ), + ) + + private suspend fun setupProfile() { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(testScene)) + } + + @Test + fun initializeLoadsSceneStateReady() = runTest { + setupProfile() + val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore) + viewModel.initialize(testScene.id) + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is SceneExecutionUiState.Ready) + val ready = viewModel.uiState.value as SceneExecutionUiState.Ready + assertEquals(testScene.id, ready.scene.id) + } + + @Test + fun executeRunsSceneAndTransitionsToCompleted() = runTest { + setupProfile() + val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore) + viewModel.initialize(testScene.id) + advanceUntilIdle() + + viewModel.execute() + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed) + val completed = viewModel.uiState.value as SceneExecutionUiState.Completed + assertEquals(testScene.id, completed.result.scene.id) + assertEquals(1, completed.result.results.size) + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt new file mode 100644 index 0000000..9fcf7e4 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt @@ -0,0 +1,101 @@ +package com.flagship.abox.manager.ui.scene + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +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.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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SceneListViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + private val profileStore = FakeProviderProfileStore() + private val sceneRepository = FakeSceneRepository() + + private val profileId = ProviderProfileId("local-experience") + private val testScene = Scene( + id = SceneId("local-experience:scene-1"), + profileId = profileId, + name = "Test Scene", + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("local-experience:action-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ), + ), + ) + + @Test + fun loadsScenesForActiveProfile() = runTest { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(testScene)) + + val viewModel = SceneListViewModel(sceneRepository, profileStore) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.scenes.collect {} + } + advanceUntilIdle() + + assertEquals(1, viewModel.scenes.value.size) + assertEquals("Test Scene", viewModel.scenes.value.first().name) + } + + @Test + fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(testScene)) + + val viewModel = SceneListViewModel(sceneRepository, profileStore) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.scenes.collect {} + } + advanceUntilIdle() + + viewModel.markPendingDelete(testScene.id) + assertEquals(testScene.id, viewModel.pendingDelete.value) + + viewModel.deleteScene(testScene.id) + advanceUntilIdle() + + assertNull(viewModel.pendingDelete.value) + assertTrue(viewModel.scenes.value.isEmpty()) + } + + @Test + fun cancelDeleteClearsPendingState() = runTest { + val viewModel = SceneListViewModel(sceneRepository, profileStore) + viewModel.markPendingDelete(testScene.id) + assertEquals(testScene.id, viewModel.pendingDelete.value) + + viewModel.cancelDelete() + assertNull(viewModel.pendingDelete.value) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt new file mode 100644 index 0000000..4105b90 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -0,0 +1,69 @@ +package com.flagship.abox.manager.domain + +import java.util.concurrent.ConcurrentHashMap + +/** + * Production implementation of [SceneExecutor]. + * + * Executes scene actions sequentially via [SocketGateway] and [InfraredGateway]. + * Guarantees: + * 1. Actions are run sequentially in the order of [Scene.actions]. + * 2. Individual action failures do not abort remaining actions; all actions run to completion. + * 3. Results are aggregated into a [SceneExecutionResult]. + * 4. Re-entrant / concurrent execution of the same scene is rejected with [DomainError.OperationFailed]. + */ +class DefaultSceneExecutor( + private val socketGateway: SocketGateway, + private val infraredGateway: InfraredGateway, +) : SceneExecutor { + private val runningScenes = ConcurrentHashMap() + + override suspend fun execute(scene: Scene): DomainResult { + val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null + if (alreadyRunning) { + return DomainResult.Failure( + DomainError.OperationFailed( + detail = "Scene ${scene.id.value} is already running", + ), + ) + } + return try { + val results = scene.actions.map { action -> executeAction(scene.profileId, action) } + DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) + } finally { + runningScenes.remove(scene.id) + } + } + + private suspend fun executeAction( + profileId: ProviderProfileId, + action: SceneAction, + ): SceneActionExecutionResult { + val result = when (action) { + is SceneAction.SocketPower -> socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + + is SceneAction.InfraredKey -> infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + } + return when (result) { + is DomainResult.Success -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + + is DomainResult.Failure -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = result.error, + ) + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt new file mode 100644 index 0000000..2df7cca --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -0,0 +1,137 @@ +package com.flagship.abox.manager.domain + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Provider-isolated in-memory [SceneRepository] implementation that supports + * seed defaults and real-time observation. + */ +class InMemorySceneRepository : SceneRepository { + private val flows = ConcurrentHashMap>>>() + + override fun observeScenes(profileId: ProviderProfileId): Flow>> = + flowFor(profileId) + + override suspend fun getScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult { + val scenes = currentScenes(profileId) ?: return notFound(sceneId) + return DomainResult.Success(scenes.firstOrNull { it.id == sceneId }) + } + + override suspend fun createScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult { + require(scene.profileId == profileId) { + "Scene profileId must match the supplied profileId" + } + val scenes = currentScenes(profileId).orEmpty() + if (scenes.any { it.id == scene.id }) { + return DomainResult.Failure( + DomainError.InvalidRequest( + detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}", + ), + ) + } + val updated = scenes + scene + publish(profileId, updated) + return DomainResult.Success(scene) + } + + override suspend fun updateScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult { + require(scene.profileId == profileId) { + "Scene profileId must match the supplied profileId" + } + val scenes = currentScenes(profileId).orEmpty() + val index = scenes.indexOfFirst { it.id == scene.id } + if (index < 0) return notFound(scene.id) + val updated = scenes.toMutableList().apply { set(index, scene) } + publish(profileId, updated) + return DomainResult.Success(scene) + } + + override suspend fun deleteScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult { + val scenes = currentScenes(profileId).orEmpty() + val updated = scenes.filterNot { it.id == sceneId } + if (updated.size == scenes.size) return notFound(sceneId) + publish(profileId, updated) + return DomainResult.Success(Unit) + } + + fun seedDefaults(profileId: ProviderProfileId, scenes: List) { + require(scenes.all { it.profileId == profileId }) { + "All scenes must belong to the supplied profileId" + } + publish(profileId, scenes) + } + + private fun flowFor(profileId: ProviderProfileId): MutableStateFlow>> = + flows.getOrPut(profileId) { + val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList() + MutableStateFlow(DomainResult.Success(initial)) + } + + private fun currentScenes(profileId: ProviderProfileId): List? = + (flowFor(profileId).value as? DomainResult.Success)?.value + + private fun publish(profileId: ProviderProfileId, scenes: List) { + flowFor(profileId).value = DomainResult.Success(scenes) + } + + private fun notFound(sceneId: SceneId): DomainResult = DomainResult.Failure( + DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"), + ) + + private fun defaultLocalScenes(profileId: ProviderProfileId): List = listOf( + Scene( + id = SceneId("${profileId.value}:scene-leaving-home"), + profileId = profileId, + name = "Leaving home", + icon = SceneIcon.AWAY, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ), + SceneAction.InfraredKey( + id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "power", + ), + ), + ), + Scene( + id = SceneId("${profileId.value}:scene-movie-night"), + profileId = profileId, + name = "Movie night", + icon = SceneIcon.MOVIE, + color = SceneColor("#7986CB"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ), + ), + ), + ) + + companion object { + val LOCAL_EXPERIENCE_PROFILE = ProviderProfileId("local-experience") + } +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt new file mode 100644 index 0000000..907060c --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt @@ -0,0 +1,152 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultSceneExecutorTest { + + private val profileId = ProviderProfileId("local-experience") + + private class TestSocketGateway( + var response: DomainResult = DomainResult.Success( + SocketStatus( + profileId = ProviderProfileId("local-experience"), + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ), + ), + ) : SocketGateway { + val calls = mutableListOf>() + + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = response + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + calls.add(Triple(profileId, deviceName, powerState)) + return response + } + } + + private class TestInfraredGateway( + var response: DomainResult = DomainResult.Success(Unit), + ) : InfraredGateway { + val calls = mutableListOf>() + + override suspend fun sendKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult { + calls.add(Triple(profileId, deviceName, key)) + return response + } + + override suspend fun learnKey( + profileId: ProviderProfileId, + deviceName: DeviceName, + key: String, + ): DomainResult = DomainResult.Success(Unit) + + override suspend fun downloadCodes( + profileId: ProviderProfileId, + codes: List, + ): DomainResult> = DomainResult.Success(emptyList()) + } + + @Test + fun executesActionsSequentiallyAndAggregatesResults() = runTest { + val socketGateway = TestSocketGateway() + val infraredGateway = TestInfraredGateway() + val executor = DefaultSceneExecutor(socketGateway, infraredGateway) + + val action1 = SceneAction.SocketPower( + id = SceneActionId("a1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ) + val action2 = SceneAction.InfraredKey( + id = SceneActionId("a2"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "power", + ) + val scene = Scene( + id = SceneId("s1"), + profileId = profileId, + name = "Test Scene", + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = listOf(action1, action2), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + + assertEquals(scene, executionResult.scene) + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome) + + assertEquals(1, socketGateway.calls.size) + assertEquals(Triple(profileId, DeviceName("socket-1"), SocketPowerState.ON), socketGateway.calls[0]) + + assertEquals(1, infraredGateway.calls.size) + assertEquals(Triple(profileId, DeviceName("ir-1"), "power"), infraredGateway.calls[0]) + } + + @Test + fun continuesExecutionWhenAnActionFails() = runTest { + val socketGateway = TestSocketGateway( + response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")), + ) + val infraredGateway = TestInfraredGateway( + response = DomainResult.Success(Unit), + ) + val executor = DefaultSceneExecutor(socketGateway, infraredGateway) + + val action1 = SceneAction.SocketPower( + id = SceneActionId("a1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ) + val action2 = SceneAction.InfraredKey( + id = SceneActionId("a2"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "vol_up", + ) + val scene = Scene( + id = SceneId("s1"), + profileId = profileId, + name = "Test Scene", + icon = SceneIcon.MOVIE, + color = SceneColor("#7986CB"), + actions = listOf(action1, action2), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertTrue(executionResult.results[0].error is DomainError.DeviceOffline) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome) + + assertEquals(1, socketGateway.calls.size) + assertEquals(1, infraredGateway.calls.size) + } +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt new file mode 100644 index 0000000..9ffda08 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt @@ -0,0 +1,86 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InMemorySceneRepositoryTest { + + private val localProfile = ProviderProfileId("local-experience") + private val aboxProfile = ProviderProfileId("abox:admin") + + @Test + fun isolatesScenesBetweenProfiles() = runTest { + val repository = InMemorySceneRepository() + + val localScenes = (repository.observeScenes(localProfile).first() as DomainResult.Success).value + assertTrue(localScenes.isNotEmpty()) + + val aboxScenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value + assertTrue(aboxScenes.isEmpty()) + + val newScene = Scene( + id = SceneId("abox:admin:scene-custom"), + profileId = aboxProfile, + name = "ABox Scene", + icon = SceneIcon.NIGHT, + color = SceneColor("#4CAF50"), + actions = emptyList(), + ) + + val createResult = repository.createScene(aboxProfile, newScene) + assertTrue(createResult is DomainResult.Success) + + val updatedAbox = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value + assertEquals(1, updatedAbox.size) + assertEquals("ABox Scene", updatedAbox.first().name) + + val updatedLocal = (repository.observeScenes(localProfile).first() as DomainResult.Success).value + assertEquals(localScenes.size, updatedLocal.size) + } + + @Test + fun crudOperationsFunctionCorrectly() = runTest { + val repository = InMemorySceneRepository() + + val sceneId = SceneId("abox:admin:scene-1") + val initialScene = Scene( + id = sceneId, + profileId = aboxProfile, + name = "Initial", + icon = SceneIcon.HOME, + color = SceneColor("#FFFFFF"), + actions = emptyList(), + ) + + // Create + val createResult = repository.createScene(aboxProfile, initialScene) + assertTrue(createResult is DomainResult.Success) + + // Get + val getResult = repository.getScene(aboxProfile, sceneId) + assertTrue(getResult is DomainResult.Success) + assertEquals("Initial", (getResult as DomainResult.Success).value?.name) + + // Update + val updatedScene = initialScene.copy(name = "Updated") + val updateResult = repository.updateScene(aboxProfile, updatedScene) + assertTrue(updateResult is DomainResult.Success) + + val getAfterUpdate = repository.getScene(aboxProfile, sceneId) + assertEquals("Updated", (getAfterUpdate as DomainResult.Success).value?.name) + + // Delete + val deleteResult = repository.deleteScene(aboxProfile, sceneId) + assertTrue(deleteResult is DomainResult.Success) + + val getAfterDelete = repository.getScene(aboxProfile, sceneId) + assertNull((getAfterDelete as DomainResult.Success).value) + } +} diff --git a/gradle.properties b/gradle.properties index badfccb..ce877bf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,8 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8 org.gradle.configuration-cache=false android.useAndroidX=true android.nonTransitiveRClass=true kotlin.code.style=official +android.overridePathCheck=true + + -- 2.54.0 From 6b4ef585fb66b3375f18af02a96c035486162675 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Tue, 25 Aug 2026 11:38:54 +0800 Subject: [PATCH 3/9] feat(scene): integrate scene navigation host into authenticated MainAppShell --- .../abox/manager/ui/ABoxManagerApp.kt | 4 +++ .../manager/ui/scene/SceneEditorScreen.kt | 4 +-- .../manager/ui/scene/SceneExecutionScreen.kt | 4 +-- .../abox/manager/ui/scene/SceneNavigation.kt | 36 ++++++++++++++++++- domain/build.gradle.kts | 1 + 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 9a2dc11..8623c0e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -17,6 +17,7 @@ import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute +import com.flagship.abox.manager.ui.scene.SceneNavHost import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel @@ -63,6 +64,9 @@ fun ABoxManagerApp( onLogout = sessionActionsViewModel::logout, actionInProgress = actionState.inProgress, errorMessage = actionState.errorMessage, + scenesSlot = { + SceneNavHost() + }, ) } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index c31cfc3..d2ae3c5 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -13,7 +13,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Delete import androidx.compose.material3.AssistChip import androidx.compose.material3.Button @@ -74,7 +74,7 @@ fun SceneEditorScreen( }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) } }, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt index 654a0bf..47ef46c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -12,7 +12,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button @@ -56,7 +56,7 @@ fun SceneExecutionScreen( title = { Text(stringResource(R.string.scene_execution_title)) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) } }, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt index 23cf62e..41d8177 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -1,14 +1,18 @@ package com.flagship.abox.manager.ui.scene import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController import androidx.navigation.NavType +import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import com.flagship.abox.manager.domain.SceneId /** - * Scene navigation routes. [SceneListRoute] is the entry point; the editor and execution routes + * Scene navigation routes. [SceneRoutes.LIST] is the entry point; the editor and execution routes * carry the scene id as a path segment so deep linking works without manual argument parsing. */ object SceneRoutes { @@ -21,6 +25,36 @@ object SceneRoutes { fun executionFor(sceneId: SceneId): String = "scene/execution/${sceneId.value}" } +/** + * Embedded navigation host for the scenes tab in [MainAppShell]. + */ +@Composable +fun SceneNavHost( + modifier: Modifier = Modifier, + navController: NavHostController = rememberNavController(), +) { + NavHost( + navController = navController, + startDestination = SceneRoutes.LIST, + modifier = modifier, + ) { + sceneListDestination( + onAddScene = { navController.navigate(SceneRoutes.EDITOR) }, + onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, + onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, + ) + sceneEditorDestination( + onBack = { navController.popBackStack() }, + onSaved = { + navController.popBackStack(SceneRoutes.LIST, inclusive = false) + }, + ) + sceneExecutionDestination( + onBack = { navController.popBackStack() }, + ) + } +} + fun NavGraphBuilder.sceneListDestination( onAddScene: () -> Unit, onEditScene: (SceneId) -> Unit, diff --git a/domain/build.gradle.kts b/domain/build.gradle.kts index 695db50..c4865b9 100644 --- a/domain/build.gradle.kts +++ b/domain/build.gradle.kts @@ -11,3 +11,4 @@ dependencies { testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) } + -- 2.54.0 From f70f034119b13c19e2dec6346e22abcd4607bd91 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 15:54:22 +0800 Subject: [PATCH 4/9] fix(scene): promote scene editor and execution screens to full-screen secondary destinations --- .../flagship/abox/manager/ui/ABoxManagerApp.kt | 18 ++++++++++++++++-- .../abox/manager/ui/scene/SceneListScreen.kt | 7 +------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 32eecfc..7928549 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -32,7 +32,10 @@ import com.flagship.abox.manager.ui.navigation.deviceDetailGraph import com.flagship.abox.manager.ui.navigation.navigateToSocket import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute -import com.flagship.abox.manager.ui.scene.SceneNavHost +import com.flagship.abox.manager.ui.scene.SceneListScreen +import com.flagship.abox.manager.ui.scene.SceneRoutes +import com.flagship.abox.manager.ui.scene.sceneEditorDestination +import com.flagship.abox.manager.ui.scene.sceneExecutionDestination import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel @@ -126,13 +129,24 @@ private fun AuthenticatedNavHost( ) }, scenesSlot = { - SceneNavHost() + SceneListScreen( + onAddScene = { navController.navigate(SceneRoutes.EDITOR) }, + onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, + onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, + ) }, ) } deviceDetailGraph( onBackClick = navController::popBackStack, ) + sceneEditorDestination( + onBack = { navController.popBackStack() }, + onSaved = { navController.popBackStack() }, + ) + sceneExecutionDestination( + onBack = { navController.popBackStack() }, + ) composable( route = "infrared_hub/{profileId}", arguments = listOf( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index 0b9725a..e59c082 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -58,11 +58,6 @@ fun SceneListScreen( val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle() Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.scene_list_title)) }, - ) - }, floatingActionButton = { ExtendedFloatingActionButton( onClick = onAddScene, @@ -76,7 +71,7 @@ fun SceneListScreen( contentPadding = PaddingValues( start = 16.dp, end = 16.dp, - top = innerPadding.calculateTopPadding(), + top = 16.dp, bottom = innerPadding.calculateBottomPadding() + 80.dp, ), verticalArrangement = Arrangement.spacedBy(12.dp), -- 2.54.0 From 9ac1616046e4b623a3c3ffa96258df829bc0d9ae Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 16:15:31 +0800 Subject: [PATCH 5/9] feat(scene): refactor scene automation into modular trigger-action system - Support manual one-tap commands and automatic condition-based rules - Add modular triggers (time, temperature, humidity, device state) - Add modular conditions (environment thresholds, socket state, time range) - Upgrade actions to support virtual remotes, sockets, delays, and notifications - Support sequential/parallel concurrency mode and error execution strategies - Refactor SceneListScreen with filter tabs, quick execution, and enable switches - Refactor SceneEditorScreen with modular builders and preset pickers - Enhance SceneExecutionScreen with step timing, skips, and rerun capability - Add comprehensive domain and UI ViewModel unit tests --- .../abox/manager/di/SceneIntegrationModule.kt | 8 +- .../abox/manager/ui/ABoxManagerApp.kt | 3 +- .../manager/ui/scene/SceneEditorScreen.kt | 1016 ++++++++++++++--- .../manager/ui/scene/SceneEditorViewModel.kt | 237 +++- .../manager/ui/scene/SceneExecutionScreen.kt | 219 +++- .../abox/manager/ui/scene/SceneListScreen.kt | 326 +++++- .../manager/ui/scene/SceneListViewModel.kt | 53 +- .../abox/manager/ui/scene/SceneNavigation.kt | 28 +- .../src/main/res/values-zh-rCN/strings.xml | 130 ++- android/app/src/main/res/values/strings.xml | 130 ++- .../ui/scene/SceneEditorViewModelTest.kt | 59 +- .../ui/scene/SceneListViewModelTest.kt | 95 +- .../manager/domain/DefaultSceneExecutor.kt | 127 ++- .../manager/domain/InMemorySceneRepository.kt | 49 +- .../com/flagship/abox/manager/domain/Scene.kt | 164 ++- .../abox/manager/domain/SceneExecution.kt | 10 +- .../domain/DefaultSceneExecutorTest.kt | 73 ++ .../testing/fakes/FakeSceneRepository.kt | 28 +- 18 files changed, 2345 insertions(+), 410 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt index ed1aadb..24df299 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt @@ -6,6 +6,7 @@ import com.flagship.abox.manager.domain.InfraredGateway 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 dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -27,5 +28,10 @@ object SceneIntegrationModule { fun provideSceneExecutor( socketGateway: SocketGateway, infraredGateway: InfraredGateway, - ): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway) + sendVirtualRemoteAction: SendVirtualRemoteAction, + ): SceneExecutor = DefaultSceneExecutor( + socketGateway = socketGateway, + infraredGateway = infraredGateway, + sendVirtualRemoteAction = sendVirtualRemoteAction, + ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 7928549..42bda6b 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -130,7 +130,8 @@ private fun AuthenticatedNavHost( }, scenesSlot = { SceneListScreen( - onAddScene = { navController.navigate(SceneRoutes.EDITOR) }, + onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, + onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, ) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index d2ae3c5..0a98780 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -1,64 +1,119 @@ package com.flagship.abox.manager.ui.scene +import androidx.compose.animation.AnimatedVisibility +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.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues 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.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.AssistChip +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Schedule +import androidx.compose.material.icons.outlined.Thermostat +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableDoubleStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.AutomationType +import com.flagship.abox.manager.domain.ComparisonOperator +import com.flagship.abox.manager.domain.ConcurrencyMode +import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition 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.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import kotlinx.coroutines.launch + +private val PRESET_COLORS = listOf( + "#3F51B5", "#26A69A", "#FF8A65", "#7986CB", + "#E91E63", "#9C27B0", "#4CAF50", "#FF9800", + "#00BCD4", "#607D8B", "#F44336", "#673AB7", +) @OptIn(ExperimentalMaterial3Api::class) @Composable fun SceneEditorScreen( - sceneId: com.flagship.abox.manager.domain.SceneId?, + sceneId: SceneId?, + initialType: AutomationType? = null, onBack: () -> Unit, onSaved: () -> Unit, viewModel: SceneEditorViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() - androidx.compose.runtime.LaunchedEffect(state.isSaved) { - if (state.isSaved) onSaved() + var showTriggerDialog by remember { mutableStateOf(false) } + var showConditionDialog by remember { mutableStateOf(false) } + var showActionDialog by remember { mutableStateOf(false) } + var expandAdvanced by remember { mutableStateOf(false) } + var expandOther by remember { mutableStateOf(false) } + + LaunchedEffect(sceneId) { + viewModel.initialize(sceneId, initialType) } - androidx.compose.runtime.LaunchedEffect(sceneId) { - viewModel.initialize(sceneId) + + LaunchedEffect(state.isSaved) { + if (state.isSaved) { + onSaved() + } } Scaffold( @@ -68,13 +123,27 @@ fun SceneEditorScreen( Text( stringResource( if (state.isEditing) R.string.scene_editor_edit_title - else R.string.scene_editor_new_title, + else if (state.type == AutomationType.AUTOMATIC) R.string.scene_editor_auto_title + else R.string.scene_editor_manual_title, ), + fontWeight = FontWeight.SemiBold, ) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_back)) + } + }, + actions = { + IconButton( + onClick = viewModel::save, + enabled = state.isValid && !state.isSaving, + ) { + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.scene_save_action), + tint = if (state.isValid && !state.isSaving) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) } }, ) @@ -84,75 +153,483 @@ fun SceneEditorScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - item { - OutlinedTextField( - value = state.name, - onValueChange = viewModel::updateName, - label = { Text(stringResource(R.string.scene_name_label)) }, - singleLine = true, + item(key = "title_card") { + Card( modifier = Modifier.fillMaxWidth(), - ) - } - item { - Text(stringResource(R.string.scene_icon_label), style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.size(8.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SceneIcon.entries.forEach { icon -> - FilterChip( - selected = state.icon == icon, - onClick = { viewModel.updateIcon(icon) }, - label = { Text(icon.displayLabel()) }, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.scene_name_label)) }, + placeholder = { Text(stringResource(R.string.scene_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + OutlinedTextField( + value = state.description, + onValueChange = viewModel::updateDescription, + label = { Text(stringResource(R.string.scene_description_label)) }, + placeholder = { Text(stringResource(R.string.scene_description_hint)) }, + singleLine = false, + maxLines = 2, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), ) } } } - item { - OutlinedTextField( - value = state.colorHex, - onValueChange = viewModel::updateColor, - label = { Text(stringResource(R.string.scene_color_label)) }, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + + if (state.type == AutomationType.AUTOMATIC) { + item(key = "triggers_card") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Schedule, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_triggers), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.primaryContainer, + ) { + Text( + text = "${state.triggers.size}", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + state.triggers.forEach { trigger -> + TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) + } + + OutlinedButton( + onClick = { showTriggerDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_add_trigger)) + } + } + } + } + } + + item(key = "conditions_card") { + Card( modifier = Modifier.fillMaxWidth(), - ) + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Thermostat, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_conditions), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = "${state.conditions.size}", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + state.conditions.forEach { condition -> + ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) + } + + OutlinedButton( + onClick = { showConditionDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_add_condition)) + } + } + } } - item { - Text( - stringResource(R.string.scene_actions_label, state.actions.size), - style = MaterialTheme.typography.titleSmall, - ) + + item(key = "actions_header") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_actions), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = when (state.concurrencyMode) { + ConcurrencyMode.SEQUENTIAL -> stringResource(R.string.scene_concurrency_sync) + ConcurrencyMode.PARALLEL -> stringResource(R.string.scene_concurrency_async) + }, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + ) + } + } + + if (state.actions.isEmpty()) { + Text( + text = "暂未添加动作,请点击下方按钮添加", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } - items(state.actions, key = { it.id.value }) { action -> - ActionRow( + + itemsIndexed(state.actions, key = { _, it -> it.id.value }) { index, action -> + ActionCardRow( action = action, - index = state.actions.indexOf(action), + index = index, total = state.actions.size, onMoveUp = { i -> viewModel.moveAction(i, i - 1) }, onMoveDown = { i -> viewModel.moveAction(i, i + 1) }, onRemove = { viewModel.removeAction(action.id) }, ) } - item { AddSocketActionSection(viewModel) } - item { AddInfraredActionSection(viewModel) } - item { - state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } + + item(key = "add_action_btn") { + Button( + onClick = { showActionDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_add_action_btn), fontWeight = FontWeight.SemiBold) + } + } + + item(key = "advanced_options") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expandAdvanced = !expandAdvanced }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "★ " + stringResource(R.string.scene_section_advanced), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Icon( + if (expandAdvanced) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, + ) + } + + AnimatedVisibility(visible = expandAdvanced) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL, + onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) }, + label = { Text(stringResource(R.string.scene_concurrency_sync)) }, + ) + FilterChip( + selected = state.concurrencyMode == ConcurrencyMode.PARALLEL, + onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL) }, + label = { Text(stringResource(R.string.scene_concurrency_async)) }, + ) + } + + Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.labelMedium) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR, + onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) }, + label = { Text(stringResource(R.string.scene_policy_continue)) }, + ) + FilterChip( + selected = state.strategy == ExecutionStrategy.ABORT_ON_ERROR, + onClick = { viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR) }, + label = { Text(stringResource(R.string.scene_policy_abort)) }, + ) + } + } + } + } + } + } + + item(key = "other_options") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expandOther = !expandOther }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "🎨 " + stringResource(R.string.scene_section_other), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + Icon( + if (expandOther) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, + contentDescription = null, + ) + } + + AnimatedVisibility(visible = expandOther) { + Column( + modifier = Modifier.padding(top = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text(stringResource(R.string.scene_appearance_icon), style = MaterialTheme.typography.labelMedium) + IconPickerRow( + selectedIcon = state.icon, + onSelect = viewModel::updateIcon, + ) + + Text(stringResource(R.string.scene_appearance_color), style = MaterialTheme.typography.labelMedium) + ColorPickerRow( + selectedColorHex = state.colorHex, + onSelect = viewModel::updateColor, + ) + + if (state.type == AutomationType.AUTOMATIC) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(stringResource(R.string.scene_enable_rule), style = MaterialTheme.typography.bodyMedium) + Switch( + checked = state.enabled, + onCheckedChange = viewModel::updateEnabled, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(stringResource(R.string.scene_enable_logging), style = MaterialTheme.typography.bodyMedium) + Switch( + checked = state.enableLogging, + onCheckedChange = viewModel::updateLogging, + ) + } + } + } + } + } + } + + item(key = "save_section") { + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + } Button( onClick = viewModel::save, enabled = state.isValid && !state.isSaving, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), ) { - Text(stringResource(R.string.scene_save_action)) + Text(stringResource(R.string.scene_save_action), fontWeight = FontWeight.Bold) } } } } + + if (showTriggerDialog) { + AddTriggerDialog( + onDismiss = { showTriggerDialog = false }, + onAdd = { triggerType, time, temp, hum, op -> + when (triggerType) { + 0 -> viewModel.addTimeTrigger(time) + 1 -> viewModel.addTemperatureTrigger("温湿度传感器", op, temp) + 2 -> viewModel.addHumidityTrigger("温湿度传感器", op, hum) + } + showTriggerDialog = false + }, + ) + } + + if (showConditionDialog) { + AddConditionDialog( + onDismiss = { showConditionDialog = false }, + onAddTemp = { op, temp -> + viewModel.addTemperatureCondition("温湿度传感器", op, temp) + showConditionDialog = false + }, + onAddHumidity = { op, hum -> + viewModel.addHumidityCondition("温湿度传感器", op, hum) + showConditionDialog = false + }, + onAddSocket = { powerState -> + viewModel.addSocketPowerCondition("Zigbee插座控制器", powerState) + showConditionDialog = false + }, + onAddTimeRange = { start, end -> + viewModel.addTimeRangeCondition(start, end) + showConditionDialog = false + }, + ) + } + + if (showActionDialog) { + AddActionDialog( + availableRemotes = state.availableRemotes, + onDismiss = { showActionDialog = false }, + viewModel = viewModel, + onAddSocket = { name, power -> + viewModel.addSocketAction(name, power) + showActionDialog = false + }, + onAddRemote = { remoteId, remoteName, btnId, btnName -> + viewModel.addVirtualRemoteAction(remoteId, remoteName, btnId, btnName) + showActionDialog = false + }, + onAddDelay = { seconds -> + viewModel.addDelayAction(seconds) + showActionDialog = false + }, + onAddNotify = { title, msg -> + viewModel.addNotificationAction(title, msg) + showActionDialog = false + }, + ) + } +} + +@Composable +private fun TriggerItemRow(trigger: SceneTrigger, onRemove: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val (icon, desc) = when (trigger) { + is SceneTrigger.Time -> Pair("⏰", "定时: ${trigger.timeOfDay}") + is SceneTrigger.Temperature -> Pair("🌡️", "温度 ${trigger.operator.symbol} ${trigger.thresholdCelsius}°C") + is SceneTrigger.Humidity -> Pair("💧", "湿度 ${trigger.operator.symbol} ${trigger.thresholdPercent}%") + is SceneTrigger.DeviceState -> Pair("🔌", "${trigger.deviceName.value} 状态变为 ${trigger.expectedState}") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(icon, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.width(8.dp)) + Text(desc, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + } } @Composable -private fun ActionRow( +private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Row( + modifier = Modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + val (icon, desc) = when (condition) { + is SceneCondition.Temperature -> Pair("🌡️", "温度需 ${condition.operator.symbol} ${condition.thresholdCelsius}°C") + is SceneCondition.Humidity -> Pair("💧", "湿度需 ${condition.operator.symbol} ${condition.thresholdPercent}%") + is SceneCondition.SocketPower -> Pair("🔌", "${condition.deviceName.value} 处于 ${condition.expectedState.name}") + is SceneCondition.TimeRange -> Pair("🕒", "时间在 ${condition.startTime} ~ ${condition.endTime} 之间") + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text(icon, style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.width(8.dp)) + Text(desc, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + } + } + } +} + +@Composable +private fun ActionCardRow( action: SceneAction, index: Int, total: Int, @@ -160,122 +637,369 @@ private fun ActionRow( onMoveDown: (Int) -> Unit, onRemove: () -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { Row( modifier = Modifier .fillMaxWidth() .padding(12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = when (action) { - is SceneAction.SocketPower -> stringResource( - R.string.scene_action_socket, - action.deviceName.value, - action.powerState.name, - ) - is SceneAction.InfraredKey -> stringResource( - R.string.scene_action_infrared, - action.deviceName.value, - action.key, - ) - }, - style = MaterialTheme.typography.bodyMedium, - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(28.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text("${index + 1}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + } } - IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { + Spacer(Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + val title = when (action) { + is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, action.deviceName.value, action.powerState.name) + is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, action.remoteDisplayName, action.buttonDisplayName) + is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, action.deviceName.value, action.key) + is SceneAction.Delay -> stringResource(R.string.scene_action_delay, action.durationSeconds) + is SceneAction.Notification -> stringResource(R.string.scene_action_notification, action.title) + } + Text(text = title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + } + IconButton(onClick = { onMoveUp(index) }, enabled = index > 0, modifier = Modifier.size(32.dp)) { Text("↑", style = MaterialTheme.typography.titleMedium) } - IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) { + IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1, modifier = Modifier.size(32.dp)) { Text("↓", style = MaterialTheme.typography.titleMedium) } - IconButton(onClick = onRemove) { - Icon(Icons.Filled.Delete, contentDescription = null) + IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) } } } } +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun AddSocketActionSection(viewModel: SceneEditorViewModel) { - var deviceName by remember { mutableStateOf("") } - var powerOn by remember { mutableStateOf(true) } - - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - stringResource(R.string.scene_add_socket_action), - style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = deviceName, - onValueChange = { deviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - SocketPowerState.entries.forEach { state -> - FilterChip( - selected = powerOn == (state == SocketPowerState.ON), - onClick = { powerOn = state == SocketPowerState.ON }, - label = { Text(state.name) }, - ) +private fun IconPickerRow(selectedIcon: SceneIcon, onSelect: (SceneIcon) -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + SceneIcon.entries.forEach { icon -> + Surface( + shape = RoundedCornerShape(8.dp), + color = if (selectedIcon == icon) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable { onSelect(icon) } + .border( + width = if (selectedIcon == icon) 2.dp else 1.dp, + color = if (selectedIcon == icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, + shape = RoundedCornerShape(8.dp), + ), + ) { + Box(contentAlignment = Alignment.Center) { + Text(icon.displayEmoji(), style = MaterialTheme.typography.titleMedium) + } } } - AssistChip( - onClick = { - viewModel.addSocketAction( - deviceName, - if (powerOn) SocketPowerState.ON else SocketPowerState.OFF, - ) - deviceName = "" - }, - label = { Text(stringResource(R.string.scene_add_action)) }, - ) + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ColorPickerRow(selectedColorHex: String, onSelect: (String) -> Unit) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + PRESET_COLORS.forEach { hex -> + val color = SceneColor(hex).toColorOrNull() ?: Color.Gray + Box( + modifier = Modifier + .size(34.dp) + .background(color, shape = CircleShape) + .clip(CircleShape) + .clickable { onSelect(hex) } + .border( + width = if (selectedColorHex.equals(hex, ignoreCase = true)) 3.dp else 1.dp, + color = if (selectedColorHex.equals(hex, ignoreCase = true)) MaterialTheme.colorScheme.onSurface else Color.Transparent, + shape = CircleShape, + ), + ) + } } } @Composable -private fun AddInfraredActionSection(viewModel: SceneEditorViewModel) { - var deviceName by remember { mutableStateOf("") } - var key by remember { mutableStateOf("") } +private fun AddTriggerDialog( + onDismiss: () -> Unit, + onAdd: (type: Int, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, +) { + var selectedType by remember { mutableIntStateOf(0) } + var timeInput by remember { mutableStateOf("07:30") } + var tempThreshold by remember { mutableDoubleStateOf(28.0) } + var humThreshold by remember { mutableDoubleStateOf(40.0) } + var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - stringResource(R.string.scene_add_infrared_action), - style = MaterialTheme.typography.titleSmall, - ) - OutlinedTextField( - value = deviceName, - onValueChange = { deviceName = it }, - label = { Text(stringResource(R.string.scene_device_name_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = key, - onValueChange = { key = it }, - label = { Text(stringResource(R.string.scene_infrared_key_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - AssistChip( - onClick = { - viewModel.addInfraredAction(deviceName, key) - deviceName = "" - key = "" - }, - label = { Text(stringResource(R.string.scene_add_action)) }, - ) + 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)) { + FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("⏰ 定时") }) + FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("🌡️ 温度") }) + FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text("💧 湿度") }) + } + when (selectedType) { + 0 -> { + OutlinedTextField( + value = timeInput, + onValueChange = { timeInput = it }, + label = { Text(stringResource(R.string.scene_trigger_time_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + 1, 2 -> { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) + FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text("< 低于") }) + } + if (selectedType == 1) { + Text("设定阈值: ${tempThreshold.toInt()} °C") + Slider(value = tempThreshold.toFloat(), onValueChange = { tempThreshold = it.toDouble() }, valueRange = 10f..40f) + } else { + Text("设定阈值: ${humThreshold.toInt()} %") + Slider(value = humThreshold.toFloat(), onValueChange = { humThreshold = it.toDouble() }, valueRange = 10f..90f) + } + } + } + } + }, + confirmButton = { + Button(onClick = { onAdd(selectedType, timeInput, tempThreshold, humThreshold, operator) }) { + Text(stringResource(R.string.scene_add_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.scene_delete_cancel)) } + }, + ) +} + +@Composable +private fun AddConditionDialog( + onDismiss: () -> Unit, + onAddTemp: (ComparisonOperator, Double) -> Unit, + onAddHumidity: (ComparisonOperator, Double) -> Unit, + onAddSocket: (SocketPowerState) -> Unit, + onAddTimeRange: (String, String) -> Unit, +) { + var selectedType by remember { mutableIntStateOf(0) } + var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } + var tempVal by remember { mutableDoubleStateOf(26.0) } + var humVal by remember { mutableDoubleStateOf(50.0) } + var socketPower by remember { mutableStateOf(SocketPowerState.OFF) } + var startTime by remember { mutableStateOf("22:00") } + var endTime by remember { mutableStateOf("07:00") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("🌡️ 温度") }) + FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("💧 湿度") }) + FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text("🔌 插座") }) + FilterChip(selected = selectedType == 3, onClick = { selectedType = 3 }, label = { Text("🕒 时间") }) + } + when (selectedType) { + 0 -> { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) + FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text("< 低于") }) + } + Text("设定温度: ${tempVal.toInt()} °C") + Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f) + } + 1 -> { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) + FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text("< 低于") }) + } + Text("设定湿度: ${humVal.toInt()} %") + Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f) + } + 2 -> { + Text("要求插座当前状态为:") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text("开启") }) + FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text("关闭") }) + } + } + 3 -> { + OutlinedTextField(value = startTime, onValueChange = { startTime = it }, label = { Text(stringResource(R.string.scene_condition_start_time)) }, singleLine = true) + OutlinedTextField(value = endTime, onValueChange = { endTime = it }, label = { Text(stringResource(R.string.scene_condition_end_time)) }, singleLine = true) + } + } + } + }, + confirmButton = { + Button(onClick = { + when (selectedType) { + 0 -> onAddTemp(operator, tempVal) + 1 -> onAddHumidity(operator, humVal) + 2 -> onAddSocket(socketPower) + 3 -> onAddTimeRange(startTime, endTime) + } + }) { + Text(stringResource(R.string.scene_add_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.scene_delete_cancel)) } + }, + ) +} + +@Composable +private fun AddActionDialog( + availableRemotes: List, + onDismiss: () -> Unit, + viewModel: SceneEditorViewModel, + onAddSocket: (deviceName: String, powerState: SocketPowerState) -> Unit, + onAddRemote: (remoteId: String, remoteName: String, btnId: String, btnName: String) -> Unit, + onAddDelay: (seconds: Int) -> Unit, + onAddNotify: (title: String, msg: String) -> Unit, +) { + var actionType by remember { mutableIntStateOf(0) } + var socketName by remember { mutableStateOf("Zigbee插座控制器") } + var socketPower by remember { mutableStateOf(SocketPowerState.ON) } + + var selectedRemoteIndex by remember { mutableIntStateOf(0) } + var keyBindings by remember { mutableStateOf>(emptyList()) } + var selectedButtonId by remember { mutableStateOf("power") } + var selectedButtonName by remember { mutableStateOf("电源") } + + var delaySeconds by remember { mutableIntStateOf(3) } + var notifyTitle by remember { mutableStateOf("情景完成") } + var notifyMsg by remember { mutableStateOf("自动化任务执行完成") } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(availableRemotes, selectedRemoteIndex) { + if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { + val remote = availableRemotes[selectedRemoteIndex] + coroutineScope.launch { + val bindings = viewModel.getKeyBindings(remote.id) + keyBindings = bindings + if (bindings.isNotEmpty()) { + selectedButtonId = bindings[0].buttonId + selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId } + } + } + } } -} -private fun SceneIcon.displayLabel(): String = when (this) { - SceneIcon.HOME -> "Home" - SceneIcon.OFFICE -> "Office" - SceneIcon.NIGHT -> "Night" - SceneIcon.MOVIE -> "Movie" - SceneIcon.AWAY -> "Away" - SceneIcon.CUSTOM -> "Custom" + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.scene_dialog_add_action_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text("🔌 插座") }) + FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text("📺 遥控") }) + FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text("⏱️ 延时") }) + FilterChip(selected = actionType == 3, onClick = { actionType = 3 }, label = { Text("🔔 提示") }) + } + 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_socket_power_state)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text("开启") }) + FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text("关闭") }) + } + } + 1 -> { + if (availableRemotes.isEmpty()) { + Text("未找到虚拟遥控器,将默认使用红外控制器") + OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text("按键名称/槽位") }, singleLine = true) + } else { + Text(stringResource(R.string.scene_select_virtual_remote)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + availableRemotes.forEachIndexed { idx, remote -> + FilterChip( + selected = selectedRemoteIndex == idx, + onClick = { selectedRemoteIndex = idx }, + label = { Text(remote.displayName) }, + ) + } + } + Text(stringResource(R.string.scene_select_remote_button)) + if (keyBindings.isNotEmpty()) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + keyBindings.take(4).forEach { binding -> + val label = binding.displayName.ifBlank { binding.buttonId } + FilterChip( + selected = selectedButtonId == binding.buttonId, + onClick = { + selectedButtonId = binding.buttonId + selectedButtonName = label + }, + label = { Text(label) }, + ) + } + } + } else { + OutlinedTextField(value = selectedButtonName, onValueChange = { selectedButtonName = it; selectedButtonId = it }, label = { Text("按键名称") }, singleLine = true) + } + } + } + 2 -> { + Text(stringResource(R.string.scene_delay_duration_label) + ": $delaySeconds 秒") + Slider( + value = delaySeconds.toFloat(), + onValueChange = { delaySeconds = it.toInt() }, + valueRange = 1f..30f, + steps = 29, + ) + } + 3 -> { + OutlinedTextField(value = notifyTitle, onValueChange = { notifyTitle = it }, label = { Text(stringResource(R.string.scene_notification_title_label)) }, singleLine = true) + OutlinedTextField(value = notifyMsg, onValueChange = { notifyMsg = it }, label = { Text(stringResource(R.string.scene_notification_msg_label)) }, singleLine = true) + } + } + } + }, + confirmButton = { + Button(onClick = { + when (actionType) { + 0 -> onAddSocket(socketName, socketPower) + 1 -> { + val remote = availableRemotes.getOrNull(selectedRemoteIndex) + if (remote != null) { + onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) + } else { + onAddRemote("default-ir", "红外控制器", selectedButtonId, selectedButtonName) + } + } + 2 -> onAddDelay(delaySeconds) + 3 -> onAddNotify(notifyTitle, notifyMsg) + } + }) { + Text(stringResource(R.string.scene_add_action)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text(stringResource(R.string.scene_delete_cancel)) } + }, + ) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index 4a4f695..66127a0 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -2,32 +2,54 @@ package com.flagship.abox.manager.ui.scene import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +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.DomainResult +import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition +import com.flagship.abox.manager.domain.SceneConditionId import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SceneTrigger +import com.flagship.abox.manager.domain.SceneTriggerId import com.flagship.abox.manager.domain.SocketPowerState +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.domain.infrared.VirtualRemoteRepository import dagger.hilt.android.lifecycle.HiltViewModel import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch data class SceneEditorState( val sceneId: SceneId? = null, + val type: AutomationType = AutomationType.MANUAL, val name: String = "", + val description: String = "", + val enabled: Boolean = true, val icon: SceneIcon = SceneIcon.HOME, - val colorHex: String = "#FF8A65", + val colorHex: String = "#3F51B5", + val triggers: List = emptyList(), + val conditions: List = emptyList(), val actions: List = emptyList(), + val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR, + val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, + val enableLogging: Boolean = true, + val availableRemotes: List = emptyList(), val isSaving: Boolean = false, val isSaved: Boolean = false, val errorMessage: String? = null, @@ -39,15 +61,40 @@ data class SceneEditorState( @HiltViewModel class SceneEditorViewModel @Inject constructor( private val sceneRepository: SceneRepository, + private val virtualRemoteRepository: VirtualRemoteRepository, private val profileStore: ProviderProfileStore, ) : ViewModel() { private val _state = MutableStateFlow(SceneEditorState()) val state: StateFlow = _state.asStateFlow() - fun initialize(sceneId: SceneId?) { + init { + observeAvailableRemotes() + } + + private fun observeAvailableRemotes() { + viewModelScope.launch { + profileStore.observeActiveProfile().collectLatest { profile -> + if (profile != null) { + virtualRemoteRepository.observeRemotes(profile.id, DeviceName("红外控制器")).collectLatest { res -> + if (res is DomainResult.Success) { + _state.update { it.copy(availableRemotes = res.value) } + } + } + } + } + } + } + + fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) { if (sceneId == null) { - _state.value = SceneEditorState() + _state.update { + SceneEditorState( + type = initialType ?: AutomationType.MANUAL, + colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5", + availableRemotes = it.availableRemotes, + ) + } return } viewModelScope.launch { @@ -58,13 +105,23 @@ class SceneEditorViewModel @Inject constructor( } val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value if (scene != null) { - _state.value = SceneEditorState( - sceneId = scene.id, - name = scene.name, - icon = scene.icon, - colorHex = scene.color.hex, - actions = scene.actions, - ) + _state.update { + it.copy( + sceneId = scene.id, + type = scene.type, + name = scene.name, + description = scene.description, + enabled = scene.enabled, + icon = scene.icon, + colorHex = scene.color.hex, + triggers = scene.triggers, + conditions = scene.conditions, + actions = scene.actions, + strategy = scene.strategy, + concurrencyMode = scene.concurrencyMode, + enableLogging = scene.enableLogging, + ) + } } else { _state.update { it.copy(errorMessage = "Scene not found") } } @@ -72,11 +129,104 @@ class SceneEditorViewModel @Inject constructor( } fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) } - + fun updateDescription(desc: String) = _state.update { it.copy(description = desc) } + fun updateType(type: AutomationType) = _state.update { it.copy(type = type) } + fun updateEnabled(enabled: Boolean) = _state.update { it.copy(enabled = enabled) } fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) } - fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) } + fun updateStrategy(strategy: ExecutionStrategy) = _state.update { it.copy(strategy = strategy) } + fun updateConcurrencyMode(mode: ConcurrencyMode) = _state.update { it.copy(concurrencyMode = mode) } + fun updateLogging(enabled: Boolean) = _state.update { it.copy(enableLogging = enabled) } + // --- Triggers --- + fun addTimeTrigger(timeOfDay: String, daysOfWeek: Set = emptySet()) { + val trigger = SceneTrigger.Time( + id = SceneTriggerId(UUID.randomUUID().toString()), + timeOfDay = timeOfDay, + daysOfWeek = daysOfWeek, + label = "定时 $timeOfDay", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun addTemperatureTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val trigger = SceneTrigger.Temperature( + id = SceneTriggerId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdCelsius = threshold, + label = "$deviceName 温度 ${operator.symbol} $threshold°C", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun addHumidityTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val trigger = SceneTrigger.Humidity( + id = SceneTriggerId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdPercent = threshold, + label = "$deviceName 湿度 ${operator.symbol} $threshold%", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun addDeviceStateTrigger(deviceName: String, expectedState: String) { + val trigger = SceneTrigger.DeviceState( + id = SceneTriggerId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + expectedState = expectedState, + label = "$deviceName 状态变为 $expectedState", + ) + _state.update { it.copy(triggers = it.triggers + trigger) } + } + + fun removeTrigger(triggerId: SceneTriggerId) = + _state.update { it.copy(triggers = it.triggers.filterNot { t -> t.id == triggerId }) } + + // --- Conditions --- + fun addTemperatureCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val cond = SceneCondition.Temperature( + id = SceneConditionId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdCelsius = threshold, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun addHumidityCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) { + val cond = SceneCondition.Humidity( + id = SceneConditionId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + operator = operator, + thresholdPercent = threshold, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun addSocketPowerCondition(deviceName: String, powerState: SocketPowerState) { + val cond = SceneCondition.SocketPower( + id = SceneConditionId(UUID.randomUUID().toString()), + deviceName = DeviceName(deviceName), + expectedState = powerState, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun addTimeRangeCondition(startTime: String, endTime: String) { + val cond = SceneCondition.TimeRange( + id = SceneConditionId(UUID.randomUUID().toString()), + startTime = startTime, + endTime = endTime, + ) + _state.update { it.copy(conditions = it.conditions + cond) } + } + + fun removeCondition(conditionId: SceneConditionId) = + _state.update { it.copy(conditions = it.conditions.filterNot { c -> c.id == conditionId }) } + + // --- Actions --- fun addSocketAction(deviceName: String, powerState: SocketPowerState) { if (deviceName.isBlank()) return viewModelScope.launch { @@ -91,6 +241,26 @@ class SceneEditorViewModel @Inject constructor( } } + fun addVirtualRemoteAction( + remoteId: String, + remoteDisplayName: String, + buttonId: String, + buttonDisplayName: String, + ) { + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.VirtualRemoteButton( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + remoteId = remoteId, + remoteDisplayName = remoteDisplayName, + buttonId = buttonId, + buttonDisplayName = buttonDisplayName, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + } + fun addInfraredAction(deviceName: String, key: String) { if (deviceName.isBlank() || key.isBlank()) return viewModelScope.launch { @@ -105,6 +275,33 @@ class SceneEditorViewModel @Inject constructor( } } + fun addDelayAction(seconds: Int) { + if (seconds <= 0) return + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.Delay( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + durationSeconds = seconds, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + } + + fun addNotificationAction(title: String, message: String) { + if (title.isBlank()) return + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val action = SceneAction.Notification( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = profile.id, + title = title, + message = message, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + } + fun removeAction(actionId: SceneActionId) = _state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) } @@ -115,6 +312,13 @@ class SceneEditorViewModel @Inject constructor( _state.update { it.copy(actions = updated) } } + suspend fun getKeyBindings(remoteId: VirtualRemoteId): List { + return when (val res = virtualRemoteRepository.getKeyBindings(remoteId)) { + is DomainResult.Success -> res.value + is DomainResult.Failure -> emptyList() + } + } + fun save() { val state = _state.value if (!state.isValid || state.isSaving) return @@ -129,9 +333,17 @@ class SceneEditorViewModel @Inject constructor( id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"), profileId = profile.id, name = state.name.trim(), + description = state.description.trim(), + type = state.type, + enabled = state.enabled, icon = state.icon, color = SceneColor(state.colorHex), + triggers = state.triggers, + conditions = state.conditions, actions = state.actions, + strategy = state.strategy, + concurrencyMode = state.concurrencyMode, + enableLogging = state.enableLogging, ) val result = if (state.isEditing) { sceneRepository.updateScene(profile.id, scene) @@ -151,3 +363,4 @@ class SceneEditorViewModel @Inject constructor( } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt index 47ef46c..c21663c 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -1,28 +1,39 @@ package com.flagship.abox.manager.ui.scene import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.outlined.HourglassDisabled import androidx.compose.material3.Button import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -30,8 +41,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -39,21 +50,25 @@ import com.flagship.abox.manager.R 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.SceneExecutionResult +import com.flagship.abox.manager.domain.SceneId @OptIn(ExperimentalMaterial3Api::class) @Composable fun SceneExecutionScreen( - sceneId: com.flagship.abox.manager.domain.SceneId, + sceneId: SceneId, onBack: () -> Unit, viewModel: SceneExecutionViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - LaunchedEffect(sceneId) { viewModel.initialize(sceneId) } + LaunchedEffect(sceneId) { + viewModel.initialize(sceneId) + } Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.scene_execution_title)) }, + title = { Text(stringResource(R.string.scene_execution_title), fontWeight = FontWeight.SemiBold) }, navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) @@ -64,29 +79,28 @@ fun SceneExecutionScreen( ) { innerPadding -> when (val state = uiState) { SceneExecutionUiState.Loading -> CenteredLoading(innerPadding) - is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message) + is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message, onRetry = { viewModel.initialize(sceneId) }) is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute) is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name) - is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack) + is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack, onRerun = viewModel::execute) } } } @Composable private fun CenteredLoading(paddingValues: PaddingValues) { - Row( + Box( modifier = Modifier .fillMaxSize() .padding(paddingValues), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + contentAlignment = Alignment.Center, ) { CircularProgressIndicator() } } @Composable -private fun CenteredMessage(paddingValues: PaddingValues, message: String) { +private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) { Column( modifier = Modifier .fillMaxSize() @@ -96,6 +110,12 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String) { verticalArrangement = Arrangement.Center, ) { Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Icon(Icons.Filled.Refresh, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text("重试") + } } } @@ -113,10 +133,18 @@ private fun ReadyContent( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Text(sceneName, style = MaterialTheme.typography.headlineSmall) - Spacer(Modifier.size(24.dp)) - Button(onClick = onExecute) { - Text(stringResource(R.string.scene_execution_run)) + Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text("点击下方按钮立即开始执行", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(24.dp)) + Button( + onClick = onExecute, + modifier = Modifier.fillMaxWidth(0.6f).height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.scene_execution_run), fontWeight = FontWeight.SemiBold) } } } @@ -131,11 +159,12 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - CircularProgressIndicator() - Spacer(Modifier.size(16.dp)) + CircularProgressIndicator(modifier = Modifier.size(56.dp)) + Spacer(Modifier.height(20.dp)) Text( stringResource(R.string.scene_execution_running, sceneName), - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, ) } } @@ -143,8 +172,9 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) { @Composable private fun CompletedContent( paddingValues: PaddingValues, - result: com.flagship.abox.manager.domain.SceneExecutionResult, + result: SceneExecutionResult, onBack: () -> Unit, + onRerun: () -> Unit, ) { LazyColumn( modifier = Modifier @@ -153,57 +183,131 @@ private fun CompletedContent( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - item { - Text( - text = stringResource( - R.string.scene_execution_summary, - result.succeededCount, - result.failedCount, - result.totalActions, + item(key = "summary") { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = if (result.allSucceeded) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + } else { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + }, ), - style = MaterialTheme.typography.titleMedium, - ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.scene_execution_done), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource( + R.string.scene_execution_summary, + result.succeededCount, + result.failedCount, + result.totalActions, + ), + style = MaterialTheme.typography.bodyMedium, + ) + if (result.totalDurationMillis > 0) { + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.scene_execution_duration, result.totalDurationMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } - items(result.results, key = { it.action.id.value }) { entry -> - ActionResultRow(entry) + + itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry -> + ActionResultRow(index + 1, entry) } - item { - Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { - Text(stringResource(R.string.scene_execution_done)) + + item(key = "buttons") { + Spacer(Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = onRerun, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("重新执行") + } + Button( + onClick = onBack, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text("返回", fontWeight = FontWeight.SemiBold) + } } } } } @Composable -private fun ActionResultRow(entry: SceneActionExecutionResult) { - Card(modifier = Modifier.fillMaxWidth()) { +private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { Row( modifier = Modifier .fillMaxWidth() - .padding(12.dp), + .padding(14.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { - Icons.Filled.Check - } else { - Icons.Filled.Close + Surface( + shape = CircleShape, + color = when (entry.outcome) { + SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer + SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer + SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant }, - contentDescription = null, - tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.error - }, - ) - Spacer(Modifier.size(12.dp)) + modifier = Modifier.size(32.dp), + ) { + Box(contentAlignment = Alignment.Center) { + when (entry.outcome) { + SceneActionOutcome.SUCCESS -> Icon( + Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + SceneActionOutcome.FAILURE -> Icon( + Icons.Filled.Close, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) + SceneActionOutcome.SKIPPED -> Icon( + Icons.Outlined.HourglassDisabled, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp), + ) + } + } + } + Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = entry.action.describe(), + text = "步骤 $stepNumber: ${entry.action.describe()}", style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, ) entry.error?.detail?.let { detail -> + Spacer(Modifier.height(2.dp)) Text( text = detail, style = MaterialTheme.typography.bodySmall, @@ -211,11 +315,22 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) { ) } } + if (entry.executionDurationMillis > 0) { + Text( + text = "${entry.executionDurationMillis}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } private fun SceneAction.describe(): String = when (this) { - is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}" - is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key" + is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}" + is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName" + is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key" + is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒" + is SceneAction.Notification -> "🔔 提示: $title" } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index e59c082..d23f7af 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -1,5 +1,6 @@ package com.flagship.abox.manager.ui.scene +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -10,60 +11,119 @@ 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.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.SecondaryTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Tab import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.AutomationType 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 kotlinx.coroutines.flow.collectLatest @OptIn(ExperimentalMaterial3Api::class) @Composable fun SceneListScreen( - onAddScene: () -> Unit, + onAddManual: () -> Unit, + onAddAutomatic: () -> Unit, onEditScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit, headerContent: @Composable (() -> Unit)? = null, viewModel: SceneListViewModel = hiltViewModel(), ) { val scenes by viewModel.scenes.collectAsStateWithLifecycle() + val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle() val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle() + var showFabMenu by remember { mutableStateOf(false) } + + val context = LocalContext.current + LaunchedEffect(Unit) { + viewModel.userMessage.collectLatest { msg -> + if (msg == "executed") { + Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show() + } + } + } Scaffold( floatingActionButton = { - ExtendedFloatingActionButton( - onClick = onAddScene, - icon = { Icon(Icons.Filled.Add, contentDescription = null) }, - text = { Text(stringResource(R.string.scene_create_action)) }, - ) + Box { + ExtendedFloatingActionButton( + onClick = { showFabMenu = true }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.scene_create_action)) }, + ) + DropdownMenu( + expanded = showFabMenu, + onDismissRequest = { showFabMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.scene_create_manual)) }, + leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) }, + onClick = { + showFabMenu = false + onAddManual() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scene_create_automatic)) }, + leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, + onClick = { + showFabMenu = false + onAddAutomatic() + }, + ) + } + } }, ) { innerPadding -> LazyColumn( @@ -71,7 +131,7 @@ fun SceneListScreen( contentPadding = PaddingValues( start = 16.dp, end = 16.dp, - top = 16.dp, + top = 12.dp, bottom = innerPadding.calculateBottomPadding() + 80.dp, ), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -81,15 +141,48 @@ fun SceneListScreen( headerContent() } } + + item(key = "tabs") { + SecondaryTabRow( + selectedTabIndex = selectedTab.ordinal, + modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)), + ) { + SceneFilterTab.entries.forEach { tab -> + Tab( + selected = selectedTab == tab, + onClick = { viewModel.selectTab(tab) }, + text = { + Text( + text = when (tab) { + SceneFilterTab.ALL -> stringResource(R.string.scene_tab_all) + SceneFilterTab.MANUAL -> stringResource(R.string.scene_tab_manual) + SceneFilterTab.AUTOMATIC -> stringResource(R.string.scene_tab_automatic) + }, + fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal, + ) + }, + ) + } + } + } + if (scenes.isEmpty()) { item(key = "empty") { EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp)) } } else { items(scenes, key = { it.id.value }) { scene -> - SceneRow( + SceneCard( scene = scene, - onClick = { onExecuteScene(scene.id) }, + onClick = { + if (scene.type == AutomationType.MANUAL) { + onExecuteScene(scene.id) + } else { + onEditScene(scene.id) + } + }, + onQuickExecute = { viewModel.executeQuick(scene) }, + onToggleEnabled = { viewModel.toggleEnabled(scene) }, onEdit = { onEditScene(scene.id) }, onDelete = { viewModel.markPendingDelete(scene.id) }, ) @@ -105,7 +198,7 @@ fun SceneListScreen( text = { Text(stringResource(R.string.scene_delete_dialog_message)) }, confirmButton = { TextButton(onClick = { viewModel.deleteScene(sceneId) }) { - Text(stringResource(R.string.scene_delete_confirm)) + Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error) } }, dismissButton = { @@ -118,58 +211,169 @@ fun SceneListScreen( } @Composable -private fun SceneRow( +private fun SceneCard( scene: Scene, onClick: () -> Unit, + onQuickExecute: () -> Unit, + onToggleEnabled: () -> Unit, onEdit: () -> Unit, onDelete: () -> Unit, ) { - Card(modifier = Modifier.fillMaxWidth()) { - Row( + Card( + modifier = Modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + ), + ) { + Column( modifier = Modifier .clickable(onClick = onClick) .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, ) { - Box( - modifier = Modifier - .size(40.dp) - .background( - color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, - shape = CircleShape, - ), - ) - Spacer(Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = scene.name, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource( - R.string.scene_action_count, - scene.actions.size, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = Modifier + .size(44.dp) + .background( + color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = scene.icon.displayEmoji(), + style = MaterialTheme.typography.titleMedium, + ) + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = scene.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TypeBadge(type = scene.type) + } + if (scene.description.isNotBlank()) { + Spacer(Modifier.height(2.dp)) + Text( + text = scene.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + if (scene.type == AutomationType.AUTOMATIC) { + Switch( + checked = scene.enabled, + onCheckedChange = { onToggleEnabled() }, + ) + } } - IconButton(onClick = onEdit) { - Icon( - Icons.Filled.Edit, - contentDescription = stringResource(R.string.scene_edit), - ) - } - IconButton(onClick = onDelete) { - Icon( - Icons.Filled.Delete, - contentDescription = stringResource(R.string.scene_delete), - ) + + Spacer(Modifier.height(10.dp)) + + // Trigger & Action summary pills + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (scene.type == AutomationType.AUTOMATIC && scene.triggers.isNotEmpty()) { + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Text( + text = "⚡ ${scene.triggers.size} 触发器", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + Surface( + shape = RoundedCornerShape(6.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + ) { + Text( + text = stringResource(R.string.scene_action_count, scene.actions.size), + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (scene.type == AutomationType.MANUAL) { + FilledTonalButton( + onClick = onQuickExecute, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.height(32.dp), + ) { + Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium) + } + Spacer(Modifier.width(4.dp)) + } + IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp)) + } + IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp)) + } + } } } } } +@Composable +private fun TypeBadge(type: AutomationType) { + val (label, bg, fg) = when (type) { + AutomationType.MANUAL -> Triple( + stringResource(R.string.scene_tab_manual), + MaterialTheme.colorScheme.primaryContainer, + MaterialTheme.colorScheme.onPrimaryContainer, + ) + AutomationType.AUTOMATIC -> Triple( + stringResource(R.string.scene_tab_automatic), + MaterialTheme.colorScheme.secondaryContainer, + MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Surface( + shape = RoundedCornerShape(4.dp), + color = bg, + ) { + Text( + text = label, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + style = MaterialTheme.typography.labelSmall, + color = fg, + ) + } +} + @Composable private fun EmptySceneList(modifier: Modifier = Modifier) { Column( @@ -179,12 +383,18 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { + Text( + text = "⚡", + style = MaterialTheme.typography.displayMedium, + ) + Spacer(Modifier.size(8.dp)) Text( text = stringResource(R.string.scene_empty_title), style = MaterialTheme.typography.titleLarge, textAlign = TextAlign.Center, + fontWeight = FontWeight.SemiBold, ) - Spacer(Modifier.size(12.dp)) + Spacer(Modifier.size(8.dp)) Text( text = stringResource(R.string.scene_empty_message), style = MaterialTheme.typography.bodyMedium, @@ -194,8 +404,24 @@ private fun EmptySceneList(modifier: Modifier = Modifier) { } } -internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? { +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 -> "✨" +} + +internal fun SceneColor.toColorOrNull(): Color? { val cleanHex = hex.removePrefix("#") return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt index bd62a57..22f2e39 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -2,32 +2,48 @@ package com.flagship.abox.manager.ui.scene import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.ProviderProfileStore import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneExecutor import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneRepository import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +enum class SceneFilterTab { + ALL, + MANUAL, + AUTOMATIC, +} + @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class SceneListViewModel @Inject constructor( private val sceneRepository: SceneRepository, + private val sceneExecutor: SceneExecutor, private val profileStore: ProviderProfileStore, ) : ViewModel() { - val scenes: StateFlow> = profileStore.observeActiveProfile() + private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL) + val selectedTab: StateFlow = _selectedTab.asStateFlow() + + private val allScenes: StateFlow> = profileStore.observeActiveProfile() .flatMapLatest { profile -> if (profile == null) { flowOf(DomainResult.Success(emptyList())) @@ -42,9 +58,43 @@ class SceneListViewModel @Inject constructor( initialValue = emptyList(), ) + val scenes: StateFlow> = combine(allScenes, _selectedTab) { list, tab -> + when (tab) { + SceneFilterTab.ALL -> list + SceneFilterTab.MANUAL -> list.filter { it.type == AutomationType.MANUAL } + SceneFilterTab.AUTOMATIC -> list.filter { it.type == AutomationType.AUTOMATIC } + } + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + private val _pendingDelete = MutableStateFlow(null) val pendingDelete: StateFlow = _pendingDelete.asStateFlow() + private val _userMessage = MutableSharedFlow() + val userMessage: SharedFlow = _userMessage.asSharedFlow() + + fun selectTab(tab: SceneFilterTab) { + _selectedTab.value = tab + } + + fun toggleEnabled(scene: Scene) { + viewModelScope.launch { + val profile = profileStore.activeProfile() ?: return@launch + val updated = scene.copy(enabled = !scene.enabled) + sceneRepository.updateScene(profile.id, updated) + } + } + + fun executeQuick(scene: Scene) { + viewModelScope.launch { + sceneExecutor.execute(scene) + _userMessage.emit("executed") + } + } + fun deleteScene(sceneId: SceneId) { viewModelScope.launch { val profile = profileStore.activeProfile() ?: return@launch @@ -62,3 +112,4 @@ class SceneListViewModel @Inject constructor( } } + diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt index 41d8177..a94bfaf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -9,15 +9,17 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.SceneId /** - * Scene navigation routes. [SceneRoutes.LIST] is the entry point; the editor and execution routes - * carry the scene id as a path segment so deep linking works without manual argument parsing. + * Scene navigation routes. */ object SceneRoutes { const val LIST = "scene/list" const val EDITOR = "scene/editor" + const val EDITOR_MANUAL = "scene/editor/manual" + const val EDITOR_AUTOMATIC = "scene/editor/automatic" const val EDITOR_WITH_ID = "scene/editor/{sceneId}" const val EXECUTION = "scene/execution/{sceneId}" @@ -39,7 +41,8 @@ fun SceneNavHost( modifier = modifier, ) { sceneListDestination( - onAddScene = { navController.navigate(SceneRoutes.EDITOR) }, + onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, + onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, ) @@ -56,14 +59,16 @@ fun SceneNavHost( } fun NavGraphBuilder.sceneListDestination( - onAddScene: () -> Unit, + onAddManual: () -> Unit, + onAddAutomatic: () -> Unit, onEditScene: (SceneId) -> Unit, onExecuteScene: (SceneId) -> Unit, headerContent: @Composable (() -> Unit)? = null, ) { composable(SceneRoutes.LIST) { SceneListScreen( - onAddScene = onAddScene, + onAddManual = onAddManual, + onAddAutomatic = onAddAutomatic, onEditScene = onEditScene, onExecuteScene = onExecuteScene, headerContent = headerContent, @@ -75,10 +80,14 @@ fun NavGraphBuilder.sceneEditorDestination( onBack: () -> Unit, onSaved: () -> Unit, ) { - composable( - route = SceneRoutes.EDITOR, - ) { - SceneEditorScreen(sceneId = null, onBack = onBack, onSaved = onSaved) + composable(SceneRoutes.EDITOR) { + SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved) + } + composable(SceneRoutes.EDITOR_MANUAL) { + SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved) + } + composable(SceneRoutes.EDITOR_AUTOMATIC) { + SceneEditorScreen(sceneId = null, initialType = AutomationType.AUTOMATIC, onBack = onBack, onSaved = onSaved) } composable( route = SceneRoutes.EDITOR_WITH_ID, @@ -87,6 +96,7 @@ fun NavGraphBuilder.sceneEditorDestination( val id = backStackEntry.arguments?.getString("sceneId") SceneEditorScreen( sceneId = id?.let { SceneId(it) }, + initialType = null, onBack = onBack, onSaved = onSaved, ) diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 8e3cf9e..7da15d5 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -42,44 +42,124 @@ 活动记录 未记录任何事件。 - - 情景模式 - 新建情景 + + 自动化与情景 + 全部 + 一键指令 + 自动指令 + 建立一键指令 + 建立自动指令 + 新建指令 %1$d 个动作 - 编辑情景 - 删除情景 - 还没有情景 - 新建第一个情景,组合插座和红外动作。 + 编辑指令 + 删除指令 + 还没有自动化指令 + 点击右下角按钮,建立一键指令或带触发器的自动指令。 + 立即执行 + 指令已触发执行 - 删除情景 - 此操作无法撤销。 + 删除指令 + 确定要删除该指令吗?此操作无法撤销。 删除 取消 - 新建情景 - 编辑情景 + 建立一键指令 + 建立自动指令 + 编辑指令 返回 - 名称 - 图标 - 主题色(十六进制) - 动作(%1$d) - 添加插座动作 - 添加红外动作 - 设备名称 - 红外按键 + 标题 + 请输入指令标题(如:回家一键开灯、高温自动降温) + 描述 + 可填写该指令的详细说明或备注 + + + 触发器 + 条件(可选) + 动作 + 进阶选项 + 其他选项与外观 + + + + 新增触发器 + + 新增条件 + + 新增动作 添加 - 保存情景 - 插座 %1$s -> %2$s - 红外 %1$s -> %2$s + 保存指令 + + + 重复执行策略 + 继续执行新任务 + 多指令同步策略 + 同步 (串行执行) + 异步 (并发执行) + 遇到错误时 + 继续执行后续动作 + 立即中止剩余动作 + 图示 + 图示背景色 + 启用该自动指令 + 开启指令执行记录 + + + 选择触发器类型 + ⏰ 定时时间触发 + 🌡️ 环境温度触发 + 💧 环境湿度触发 + 🔌 插座状态变更触发 + 触发时间点(如:07:30) + 温度阈值 (°C) + 湿度阈值 (%) + 比较条件 + 高于 (>) + 低于 (<) + 等于 (=) + + + 选择限制条件 + 🌡️ 环境温度范围 + 💧 环境湿度范围 + 🔌 插座当前电源状态 + 🕒 生效时间区间 + 起始时间(如:22:00) + 结束时间(如:07:00) + + + 选择执行动作 + 🔌 智能插座控制 + 📺 红外遥控按键 + ⏱️ 延时等待 (缓冲) + 🔔 发送系统提示 + 选择目标插座 + 设定电源状态 + 选择虚拟遥控器 + 选择遥控器功能按键 + 等待时长 (秒) + 提示标题 + 提示消息内容 + %1$d 秒 + + + 插座【%1$s】: 设为 %2$s + 遥控【%1$s】: 发送 %2$s + 红外【%1$s】: 槽位 %2$s + ⏱️ 等待 %1$d 秒 + 🔔 提示: %1$s - 执行情景 + 执行指令 执行 - 正在执行 %1$s… + 正在执行:%1$s… %3$d 个动作中成功 %1$d 个,失败 %2$d 个 - 完成 + 执行完成 + 总耗时:%1$s ms + 排队中 + 正在执行… + 正在延时等待 (%1$d s)… + 已完成 + 执行失败 + 已跳过 设备列表 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index c93100b..9ee38d7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -42,44 +42,124 @@ Activity Log No recorded events yet. - - Scenes - New scene + + Automation & Scenes + All + Manual + Automatic + New Manual Command + New Automatic Rule + New Command %1$d actions - Edit scene - Delete scene - No scenes yet - Create your first scene to combine socket and infrared actions. + Edit Command + Delete Command + No automation commands yet + Tap the button below to create instant commands or trigger-based automatic rules. + Run Now + Command triggered - Delete scene - This action cannot be undone. + Delete Command + Are you sure you want to delete this command? This cannot be undone. Delete Cancel - New scene - Edit scene + Create Manual Command + Create Automatic Rule + Edit Command Back - Name - Icon - Accent color (hex) - Actions (%1$d) - Add socket action - Add infrared action - Device name - Infrared key + Title + Enter title (e.g. Movie Night, High Temp Auto Cooling) + Description + Optional notes or details about this rule + + + Triggers + Conditions (Optional) + Actions + Advanced Options + Other Options & Appearance + + + + Add Trigger + + Add Condition + + Add Action Add - Save scene - Socket %1$s -> %2$s - Infrared %1$s -> %2$s + Save Command + + + Repeat Execution Strategy + Continue new task + Concurrency Mode + Synchronous (Sequential) + Asynchronous (Parallel) + On Error Policy + Continue subsequent actions + Abort remaining actions + Icon + Icon Background Color + Enable this automatic rule + Enable command execution logs + + + Select Trigger Type + ⏰ Time / Schedule Trigger + 🌡️ Temperature Threshold + 💧 Humidity Threshold + 🔌 Socket State Changed + Trigger Time (e.g. 07:30) + Temperature Threshold (°C) + Humidity Threshold (%) + Condition + Greater than (>) + Less than (<) + Equals (=) + + + Select Condition + 🌡️ Temperature Range + 💧 Humidity Range + 🔌 Current Socket Power + 🕒 Active Time Range + Start Time (e.g. 22:00) + End Time (e.g. 07:00) + + + Select Action + 🔌 Smart Socket Control + 📺 Infrared Remote Button + ⏱️ Delay / Buffer Timer + 🔔 System Notification + Select Target Socket + Target Power State + Select Virtual Remote + Select Remote Key + Delay Duration (seconds) + Notification Title + Notification Message + %1$d sec + + + Socket [%1$s]: Set %2$s + Remote [%1$s]: Send %2$s + Infrared [%1$s]: Slot %2$s + ⏱️ Delay %1$d seconds + 🔔 Notify: %1$s - Run scene + Execute Command Run - Running %1$s… + Running: %1$s… %1$d succeeded, %2$d failed of %3$d actions - Done + Execution Completed + Total Elapsed: %1$s ms + Pending + Running… + Waiting (%1$d s)… + Completed + Failed + Skipped Device List diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt index 6e90358..cd02189 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -1,6 +1,10 @@ package com.flagship.abox.manager.ui.scene +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.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderType @@ -8,12 +12,15 @@ import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneAction import com.flagship.abox.manager.domain.SceneActionId import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneCondition 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.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.FakeVirtualRemoteRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -31,6 +38,7 @@ class SceneEditorViewModelTest { private val profileStore = FakeProviderProfileStore() private val sceneRepository = FakeSceneRepository() + private val virtualRemoteRepository = FakeVirtualRemoteRepository() private val profileId = ProviderProfileId("local-experience") private val existingScene = Scene( @@ -58,8 +66,8 @@ class SceneEditorViewModelTest { @Test fun initializeWithNullPreparesNewScene() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) - viewModel.initialize(null) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) val state = viewModel.state.value assertFalse(state.isEditing) @@ -71,7 +79,7 @@ class SceneEditorViewModelTest { @Test fun initializeWithExistingSceneLoadsState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) viewModel.initialize(existingScene.id) advanceUntilIdle() @@ -85,34 +93,60 @@ class SceneEditorViewModelTest { @Test fun addingAndReorderingActionsUpdatesState() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) - viewModel.initialize(null) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("My New Scene") viewModel.addSocketAction("socket-1", SocketPowerState.ON) - viewModel.addInfraredAction("ir-1", "power") + viewModel.addDelayAction(3) + viewModel.addNotificationAction("Done", "Completed") advanceUntilIdle() - assertEquals(2, viewModel.state.value.actions.size) + assertEquals(3, viewModel.state.value.actions.size) assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower) - assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey) + assertTrue(viewModel.state.value.actions[1] is SceneAction.Delay) + assertTrue(viewModel.state.value.actions[2] is SceneAction.Notification) // Move second to first viewModel.moveAction(1, 0) - assertTrue(viewModel.state.value.actions[0] is SceneAction.InfraredKey) + assertTrue(viewModel.state.value.actions[0] is SceneAction.Delay) assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower) // Remove first action val firstId = viewModel.state.value.actions[0].id viewModel.removeAction(firstId) - assertEquals(1, viewModel.state.value.actions.size) + assertEquals(2, viewModel.state.value.actions.size) + } + + @Test + fun addingTriggersAndConditionsForAutomaticRule() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.AUTOMATIC) + viewModel.updateName("Auto Rule") + + viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0) + viewModel.addSocketPowerCondition("socket-1", SocketPowerState.OFF) + viewModel.addSocketAction("socket-1", SocketPowerState.ON) + viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL) + viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR) + advanceUntilIdle() + + val state = viewModel.state.value + assertEquals(1, state.triggers.size) + assertTrue(state.triggers[0] is SceneTrigger.Temperature) + assertEquals(1, state.conditions.size) + assertTrue(state.conditions[0] is SceneCondition.SocketPower) + assertEquals(ConcurrencyMode.PARALLEL, state.concurrencyMode) + assertEquals(ExecutionStrategy.ABORT_ON_ERROR, state.strategy) + assertTrue(state.isValid) } @Test fun saveNewScenePersistsToRepository() = runTest { setupProfile() - val viewModel = SceneEditorViewModel(sceneRepository, profileStore) - viewModel.initialize(null) + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) viewModel.updateName("Brand New Scene") viewModel.addSocketAction("socket-main", SocketPowerState.OFF) advanceUntilIdle() @@ -125,3 +159,4 @@ class SceneEditorViewModelTest { assertFalse(viewModel.state.value.isSaving) } } + diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt index 9fcf7e4..f5b0181 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt @@ -1,5 +1,6 @@ package com.flagship.abox.manager.ui.scene +import com.flagship.abox.manager.domain.AutomationType import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId @@ -12,14 +13,18 @@ import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.testing.MainDispatcherRule +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor import com.flagship.abox.manager.testing.fakes.FakeSceneRepository +import com.flagship.abox.manager.testing.fakes.FakeSocketGateway import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Rule @@ -33,12 +38,16 @@ class SceneListViewModelTest { private val profileStore = FakeProviderProfileStore() private val sceneRepository = FakeSceneRepository() + private val socketGateway = FakeSocketGateway() + private val infraredGateway = FakeInfraredGateway() + private val sceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway) private val profileId = ProviderProfileId("local-experience") - private val testScene = Scene( + private val manualScene = Scene( id = SceneId("local-experience:scene-1"), profileId = profileId, - name = "Test Scene", + name = "Manual Scene", + type = AutomationType.MANUAL, icon = SceneIcon.HOME, color = SceneColor("#FF8A65"), actions = listOf( @@ -50,39 +59,98 @@ class SceneListViewModelTest { ), ), ) + private val autoScene = Scene( + id = SceneId("local-experience:scene-2"), + profileId = profileId, + name = "Auto Rule", + type = AutomationType.AUTOMATIC, + enabled = true, + icon = SceneIcon.THERMOSTAT, + color = SceneColor("#26A69A"), + actions = listOf( + SceneAction.Delay( + id = SceneActionId("local-experience:action-2"), + profileId = profileId, + durationSeconds = 5, + ), + ), + ) @Test fun loadsScenesForActiveProfile() = runTest { profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) profileStore.setActiveProfile(profileId) - sceneRepository.setScenes(profileId, listOf(testScene)) + sceneRepository.setScenes(profileId, listOf(manualScene, autoScene)) - val viewModel = SceneListViewModel(sceneRepository, profileStore) + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.scenes.collect {} } advanceUntilIdle() + assertEquals(2, viewModel.scenes.value.size) + assertEquals("Manual Scene", viewModel.scenes.value[0].name) + assertEquals("Auto Rule", viewModel.scenes.value[1].name) + } + + @Test + fun filterTabFiltersScenes() = runTest { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(manualScene, autoScene)) + + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.scenes.collect {} + } + advanceUntilIdle() + + viewModel.selectTab(SceneFilterTab.MANUAL) + advanceUntilIdle() assertEquals(1, viewModel.scenes.value.size) - assertEquals("Test Scene", viewModel.scenes.value.first().name) + assertEquals(AutomationType.MANUAL, viewModel.scenes.value[0].type) + + viewModel.selectTab(SceneFilterTab.AUTOMATIC) + advanceUntilIdle() + assertEquals(1, viewModel.scenes.value.size) + assertEquals(AutomationType.AUTOMATIC, viewModel.scenes.value[0].type) + } + + @Test + fun toggleEnabledUpdatesSceneInRepository() = runTest { + profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) + profileStore.setActiveProfile(profileId) + sceneRepository.setScenes(profileId, listOf(autoScene)) + + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.scenes.collect {} + } + advanceUntilIdle() + + viewModel.toggleEnabled(autoScene) + advanceUntilIdle() + + val updated = viewModel.scenes.value.first() + assertFalse(updated.enabled) } @Test fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest { profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local")) profileStore.setActiveProfile(profileId) - sceneRepository.setScenes(profileId, listOf(testScene)) + sceneRepository.setScenes(profileId, listOf(manualScene)) - val viewModel = SceneListViewModel(sceneRepository, profileStore) + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { viewModel.scenes.collect {} } advanceUntilIdle() - viewModel.markPendingDelete(testScene.id) - assertEquals(testScene.id, viewModel.pendingDelete.value) + viewModel.markPendingDelete(manualScene.id) + assertEquals(manualScene.id, viewModel.pendingDelete.value) - viewModel.deleteScene(testScene.id) + viewModel.deleteScene(manualScene.id) advanceUntilIdle() assertNull(viewModel.pendingDelete.value) @@ -91,11 +159,12 @@ class SceneListViewModelTest { @Test fun cancelDeleteClearsPendingState() = runTest { - val viewModel = SceneListViewModel(sceneRepository, profileStore) - viewModel.markPendingDelete(testScene.id) - assertEquals(testScene.id, viewModel.pendingDelete.value) + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) + viewModel.markPendingDelete(manualScene.id) + assertEquals(manualScene.id, viewModel.pendingDelete.value) viewModel.cancelDelete() assertNull(viewModel.pendingDelete.value) } } + diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt index 4105b90..162f2eb 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -1,20 +1,21 @@ package com.flagship.abox.manager.domain +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import kotlinx.coroutines.delay import java.util.concurrent.ConcurrentHashMap /** * Production implementation of [SceneExecutor]. * - * Executes scene actions sequentially via [SocketGateway] and [InfraredGateway]. - * Guarantees: - * 1. Actions are run sequentially in the order of [Scene.actions]. - * 2. Individual action failures do not abort remaining actions; all actions run to completion. - * 3. Results are aggregated into a [SceneExecutionResult]. - * 4. Re-entrant / concurrent execution of the same scene is rejected with [DomainError.OperationFailed]. + * Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction], + * and built-in timers. */ class DefaultSceneExecutor( private val socketGateway: SocketGateway, private val infraredGateway: InfraredGateway, + private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null, + private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) }, ) : SceneExecutor { private val runningScenes = ConcurrentHashMap() @@ -27,9 +28,41 @@ class DefaultSceneExecutor( ), ) } + val startTime = System.currentTimeMillis() + val results = mutableListOf() + var aborted = false + return try { - val results = scene.actions.map { action -> executeAction(scene.profileId, action) } - DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) + for (action in scene.actions) { + if (aborted) { + results.add( + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SKIPPED, + error = null, + ), + ) + continue + } + + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enrichedResult = execResult.copy(executionDurationMillis = duration) + results.add(enrichedResult) + + if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + aborted = true + } + } + val totalDuration = System.currentTimeMillis() - startTime + DomainResult.Success( + SceneExecutionResult( + scene = scene, + results = results, + totalDurationMillis = totalDuration, + ), + ) } finally { runningScenes.remove(scene.id) } @@ -39,30 +72,80 @@ class DefaultSceneExecutor( profileId: ProviderProfileId, action: SceneAction, ): SceneActionExecutionResult { - val result = when (action) { - is SceneAction.SocketPower -> socketGateway.setPower( - profileId = profileId, - deviceName = action.deviceName, - powerState = action.powerState, - ) + return when (action) { + is SceneAction.SocketPower -> { + val res = socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + toExecutionResult(action, res) + } - is SceneAction.InfraredKey -> infraredGateway.sendKey( - profileId = profileId, - deviceName = action.deviceName, - key = action.key, - ) + is SceneAction.InfraredKey -> { + val res = infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + toExecutionResult(action, res) + } + + is SceneAction.VirtualRemoteButton -> { + if (sendVirtualRemoteAction != null) { + val res = sendVirtualRemoteAction.sendButton( + profileId = profileId, + remoteId = VirtualRemoteId(action.remoteId), + buttonId = action.buttonId, + ) + toExecutionResult(action, res) + } else { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } + } + + is SceneAction.Delay -> { + try { + delayDurationProvider(action.durationSeconds * 1000L) + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } catch (e: Exception) { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = DomainError.OperationFailed(detail = e.message), + ) + } + } + + is SceneAction.Notification -> { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } } - return when (result) { + } + + private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult { + return when (res) { is DomainResult.Success -> SceneActionExecutionResult( action = action, outcome = SceneActionOutcome.SUCCESS, error = null, ) - is DomainResult.Failure -> SceneActionExecutionResult( action = action, outcome = SceneActionOutcome.FAILURE, - error = result.error, + error = res.error, ) } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt index 2df7cca..4112ef7 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -96,36 +96,63 @@ class InMemorySceneRepository : SceneRepository { Scene( id = SceneId("${profileId.value}:scene-leaving-home"), profileId = profileId, - name = "Leaving home", + name = "离家模式", + description = "关闭插座并延时关闭电视", + type = AutomationType.MANUAL, + enabled = true, icon = SceneIcon.AWAY, color = SceneColor("#FF8A65"), actions = listOf( SceneAction.SocketPower( id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"), profileId = profileId, - deviceName = DeviceName("socket-1"), + deviceName = DeviceName("Zigbee插座控制器"), powerState = SocketPowerState.OFF, ), + SceneAction.Delay( + id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"), + profileId = profileId, + durationSeconds = 1, + ), SceneAction.InfraredKey( id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), profileId = profileId, - deviceName = DeviceName("ir-1"), - key = "power", + deviceName = DeviceName("红外控制器"), + key = "1", ), ), ), Scene( - id = SceneId("${profileId.value}:scene-movie-night"), + id = SceneId("${profileId.value}:scene-cooling-auto"), profileId = profileId, - name = "Movie night", - icon = SceneIcon.MOVIE, - color = SceneColor("#7986CB"), + name = "高温自动降温", + description = "当室温高于 28°C 时开启插座", + type = AutomationType.AUTOMATIC, + enabled = true, + icon = SceneIcon.HOME, + color = SceneColor("#26A69A"), + triggers = listOf( + SceneTrigger.Temperature( + id = SceneTriggerId("${profileId.value}:trigger-temp"), + deviceName = DeviceName("温湿度传感器"), + operator = ComparisonOperator.GREATER_THAN, + thresholdCelsius = 28.0, + label = "温度高于 28°C", + ), + ), + conditions = listOf( + SceneCondition.SocketPower( + id = SceneConditionId("${profileId.value}:cond-socket"), + deviceName = DeviceName("Zigbee插座控制器"), + expectedState = SocketPowerState.OFF, + ), + ), actions = listOf( SceneAction.SocketPower( - id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"), + id = SceneActionId("${profileId.value}:scene-cooling:socket-1"), profileId = profileId, - deviceName = DeviceName("socket-1"), - powerState = SocketPowerState.OFF, + deviceName = DeviceName("Zigbee插座控制器"), + powerState = SocketPowerState.ON, ), ), ), diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt index c0ac4eb..aa97602 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -6,51 +6,164 @@ value class SceneId(val value: String) @JvmInline value class SceneActionId(val value: String) +@JvmInline +value class SceneTriggerId(val value: String) + +@JvmInline +value class SceneConditionId(val value: String) + +enum class AutomationType { + MANUAL, // 一键指令 (One-Tap / Instant Command) + AUTOMATIC, // 自动指令 (Trigger & Condition Based Automation) +} + enum class SceneActionType { SOCKET_POWER, + VIRTUAL_REMOTE_BUTTON, INFRARED_KEY, + DELAY, + NOTIFICATION, +} + +enum class ComparisonOperator(val symbol: String) { + GREATER_THAN(">"), + LESS_THAN("<"), + EQUALS("="), + GREATER_OR_EQUAL(">="), + LESS_OR_EQUAL("<="), +} + +sealed interface SceneTrigger { + val id: SceneTriggerId + + data class Time( + override val id: SceneTriggerId, + val timeOfDay: String, // e.g. "08:30" + val daysOfWeek: Set = emptySet(), // 1..7 (Monday..Sunday), empty means everyday + val label: String = "", + ) : SceneTrigger + + data class Temperature( + override val id: SceneTriggerId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdCelsius: Double, + val label: String = "", + ) : SceneTrigger + + data class Humidity( + override val id: SceneTriggerId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdPercent: Double, + val label: String = "", + ) : SceneTrigger + + data class DeviceState( + override val id: SceneTriggerId, + val deviceName: DeviceName, + val expectedState: String, // e.g. "ON" / "OFF" + val label: String = "", + ) : SceneTrigger +} + +sealed interface SceneCondition { + val id: SceneConditionId + + data class Temperature( + override val id: SceneConditionId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdCelsius: Double, + ) : SceneCondition + + data class Humidity( + override val id: SceneConditionId, + val deviceName: DeviceName, + val operator: ComparisonOperator, + val thresholdPercent: Double, + ) : SceneCondition + + data class SocketPower( + override val id: SceneConditionId, + val deviceName: DeviceName, + val expectedState: SocketPowerState, + ) : SceneCondition + + data class TimeRange( + override val id: SceneConditionId, + val startTime: String, // "22:00" + val endTime: String, // "07:00" + ) : SceneCondition } /** * A single executable step inside a [Scene]. - * - * Socket actions reference the [DeviceName] accepted by ABSDK 0630 and a target [SocketPowerState]. - * Infrared actions reference a [DeviceName] and the stable key string provided by the infrared - * module; the executor delegates the actual send to [InfraredGateway.sendKey]. - * - * `DeviceName` is used (rather than `DeviceId`) because ABSDK addresses devices by name, matching - * the existing [SocketGateway] and [InfraredGateway] contracts. The infrared module is responsible - * for keeping the key string stable across edits, so scenes can reference it without coupling to - * the infrared storage primary key. */ sealed interface SceneAction { val id: SceneActionId val profileId: ProviderProfileId - val deviceName: DeviceName val type: SceneActionType data class SocketPower( override val id: SceneActionId, override val profileId: ProviderProfileId, - override val deviceName: DeviceName, + val deviceName: DeviceName, val powerState: SocketPowerState, ) : SceneAction { override val type: SceneActionType get() = SceneActionType.SOCKET_POWER } + data class VirtualRemoteButton( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + val remoteId: String, + val remoteDisplayName: String, + val buttonId: String, + val buttonDisplayName: String, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.VIRTUAL_REMOTE_BUTTON + } + data class InfraredKey( override val id: SceneActionId, override val profileId: ProviderProfileId, - override val deviceName: DeviceName, + val deviceName: DeviceName, val key: String, ) : SceneAction { override val type: SceneActionType get() = SceneActionType.INFRARED_KEY } + + data class Delay( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + val durationSeconds: Int, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.DELAY + } + + data class Notification( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + val title: String, + val message: String, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.NOTIFICATION + } +} + +enum class ExecutionStrategy { + CONTINUE_ON_ERROR, // 遇错继续 + ABORT_ON_ERROR, // 遇错中止 +} + +enum class ConcurrencyMode { + SEQUENTIAL, // 同步 / 串行 + PARALLEL, // 异步 / 并行 } /** - * Visual identity for a scene. Kept as a small enum so the UI can resolve the icon without - * external resources and so the persistence layer stays stable across icon set changes. + * Visual identity for a scene. */ enum class SceneIcon { HOME, @@ -58,12 +171,17 @@ enum class SceneIcon { NIGHT, MOVIE, AWAY, + READING, + DINING, + GAMING, + THERMOSTAT, + LIGHT, + CLEAN, CUSTOM, } /** - * Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the - * Compose color space; persistence stores it verbatim. + * Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`). */ @JvmInline value class SceneColor(val hex: String) @@ -72,7 +190,15 @@ data class Scene( val id: SceneId, val profileId: ProviderProfileId, val name: String, - val icon: SceneIcon, - val color: SceneColor, - val actions: List, + val description: String = "", + val type: AutomationType = AutomationType.MANUAL, + val enabled: Boolean = true, + val icon: SceneIcon = SceneIcon.HOME, + val color: SceneColor = SceneColor("#3F51B5"), + val triggers: List = emptyList(), + val conditions: List = emptyList(), + val actions: List = emptyList(), + val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR, + val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, + val enableLogging: Boolean = true, ) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt index 8637c78..f6267ed 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -3,6 +3,7 @@ package com.flagship.abox.manager.domain enum class SceneActionOutcome { SUCCESS, FAILURE, + SKIPPED, } /** @@ -12,21 +13,24 @@ data class SceneActionExecutionResult( val action: SceneAction, val outcome: SceneActionOutcome, val error: DomainError?, + val executionDurationMillis: Long = 0L, ) /** - * Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they - * appear in [scene.actions]; a per-action failure does not abort subsequent actions. + * Aggregated result of executing a [Scene]. */ data class SceneExecutionResult( val scene: Scene, val results: List, + val totalDurationMillis: Long = 0L, ) { val totalActions: Int get() = results.size val succeededCount: Int get() = results.count { it.outcome == SceneActionOutcome.SUCCESS } val failedCount: Int get() = results.count { it.outcome == SceneActionOutcome.FAILURE } + val skippedCount: Int + get() = results.count { it.outcome == SceneActionOutcome.SKIPPED } val allSucceeded: Boolean - get() = results.isNotEmpty() && failedCount == 0 + get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0 } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt index 907060c..90173e0 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt @@ -149,4 +149,77 @@ class DefaultSceneExecutorTest { assertEquals(1, socketGateway.calls.size) assertEquals(1, infraredGateway.calls.size) } + + @Test + fun abortsExecutionOnFailureWhenStrategyIsAbortOnError() = runTest { + val socketGateway = TestSocketGateway( + response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")), + ) + val infraredGateway = TestInfraredGateway( + response = DomainResult.Success(Unit), + ) + val executor = DefaultSceneExecutor(socketGateway, infraredGateway) + + val action1 = SceneAction.SocketPower( + id = SceneActionId("a1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ) + val action2 = SceneAction.InfraredKey( + id = SceneActionId("a2"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "vol_up", + ) + val scene = Scene( + id = SceneId("s1"), + profileId = profileId, + name = "Test Scene", + icon = SceneIcon.MOVIE, + color = SceneColor("#7986CB"), + actions = listOf(action1, action2), + strategy = ExecutionStrategy.ABORT_ON_ERROR, + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome) + assertEquals(0, infraredGateway.calls.size) + } + + @Test + fun executesDelayActionSuccessfully() = runTest { + var delayCalled = 0L + val executor = DefaultSceneExecutor( + socketGateway = TestSocketGateway(), + infraredGateway = TestInfraredGateway(), + delayDurationProvider = { delayCalled = it }, + ) + + val action = SceneAction.Delay( + id = SceneActionId("d1"), + profileId = profileId, + durationSeconds = 5, + ) + val scene = Scene( + id = SceneId("s1"), + profileId = profileId, + name = "Delay Scene", + actions = listOf(action), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + + assertEquals(1, executionResult.results.size) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome) + assertEquals(5000L, delayCalled) + } } + diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt index 7d47222..28e620b 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -181,12 +181,6 @@ class FakeSceneRepository : SceneRepository { /** * Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes. - * - * Behaviour matches the project_plan.md §4.7 contract: - * - actions run sequentially in [Scene.actions] order; - * - a single action failure does not abort subsequent actions; - * - the returned [SceneExecutionResult] contains one entry per action; - * - re-entering execute() while a scene is running returns [DomainError.OperationFailed]. */ class FakeSceneExecutor( private val socketGateway: SocketGateway, @@ -203,9 +197,23 @@ class FakeSceneExecutor( ), ) } + val startTime = System.currentTimeMillis() + val results = mutableListOf() return try { - val results = scene.actions.map { action -> executeAction(scene.profileId, action) } - DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) + for (action in scene.actions) { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + results.add(execResult.copy(executionDurationMillis = duration)) + } + val totalDuration = System.currentTimeMillis() - startTime + DomainResult.Success( + SceneExecutionResult( + scene = scene, + results = results, + totalDurationMillis = totalDuration, + ), + ) } finally { running.remove(scene.id) } @@ -227,6 +235,10 @@ class FakeSceneExecutor( deviceName = action.deviceName, key = action.key, ) + + is SceneAction.VirtualRemoteButton, + is SceneAction.Delay, + is SceneAction.Notification -> DomainResult.Success(Unit) } return when (result) { is DomainResult.Success -> SceneActionExecutionResult( -- 2.54.0 From db6a7978a959c29286386e7c94255af772d11e23 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 16:21:16 +0800 Subject: [PATCH 6/9] fix(scene): simplify card styling to flat outlined cards without nested double shells --- .../manager/ui/scene/SceneEditorScreen.kt | 37 ++++--- .../manager/ui/scene/SceneExecutionScreen.kt | 20 ++-- .../abox/manager/ui/scene/SceneListScreen.kt | 100 ++++++++---------- 3 files changed, 80 insertions(+), 77 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index 0a98780..95d2cfe 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -1,6 +1,7 @@ package com.flagship.abox.manager.ui.scene import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -41,6 +42,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Slider @@ -157,10 +159,11 @@ fun SceneEditorScreen( verticalArrangement = Arrangement.spacedBy(14.dp), ) { item(key = "title_card") { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { OutlinedTextField( @@ -188,10 +191,11 @@ fun SceneEditorScreen( if (state.type == AutomationType.AUTOMATIC) { item(key = "triggers_card") { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row( @@ -237,10 +241,11 @@ fun SceneEditorScreen( } item(key = "conditions_card") { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row( @@ -285,10 +290,11 @@ fun SceneEditorScreen( } item(key = "actions_header") { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Row( @@ -353,10 +359,11 @@ fun SceneEditorScreen( } item(key = "advanced_options") { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -416,10 +423,11 @@ fun SceneEditorScreen( } item(key = "other_options") { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Column(modifier = Modifier.padding(16.dp)) { Row( @@ -637,10 +645,11 @@ private fun ActionCardRow( onMoveDown: (Int) -> Unit, onRemove: () -> Unit, ) { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Row( modifier = Modifier diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt index c21663c..a6dd083 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -1,5 +1,6 @@ package com.flagship.abox.manager.ui.scene +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -32,6 +33,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -184,16 +186,21 @@ private fun CompletedContent( verticalArrangement = Arrangement.spacedBy(12.dp), ) { item(key = "summary") { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( + colors = CardDefaults.outlinedCardColors( containerColor = if (result.allSucceeded) { - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) } else { - MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) }, ), + border = BorderStroke( + 1.dp, + if (result.allSucceeded) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f) + else MaterialTheme.colorScheme.error.copy(alpha = 0.4f), + ), ) { Column(modifier = Modifier.padding(16.dp)) { Text( @@ -256,10 +263,11 @@ private fun CompletedContent( @Composable private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) { - Card( + OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Row( modifier = Modifier diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index d23f7af..8130cbd 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -1,6 +1,7 @@ package com.flagship.abox.manager.ui.scene import android.widget.Toast +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -17,7 +18,6 @@ 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -27,7 +27,6 @@ import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.outlined.Bolt import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -37,6 +36,7 @@ import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SecondaryTabRow import androidx.compose.material3.Surface @@ -95,6 +95,7 @@ fun SceneListScreen( } Scaffold( + containerColor = Color.Transparent, floatingActionButton = { Box { ExtendedFloatingActionButton( @@ -145,6 +146,8 @@ fun SceneListScreen( item(key = "tabs") { SecondaryTabRow( selectedTabIndex = selectedTab.ordinal, + containerColor = Color.Transparent, + divider = {}, modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)), ) { SceneFilterTab.entries.forEach { tab -> @@ -219,17 +222,20 @@ private fun SceneCard( onEdit: () -> Unit, onDelete: () -> Unit, ) { - Card( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + val accentColor = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary + + OutlinedCard( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = MaterialTheme.colorScheme.surface, ), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { Column( - modifier = Modifier - .clickable(onClick = onClick) - .padding(16.dp), + modifier = Modifier.padding(16.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, @@ -239,7 +245,7 @@ private fun SceneCard( modifier = Modifier .size(44.dp) .background( - color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, + color = accentColor.copy(alpha = 0.12f), shape = RoundedCornerShape(12.dp), ), contentAlignment = Alignment.Center, @@ -284,40 +290,30 @@ private fun SceneCard( } } - Spacer(Modifier.height(10.dp)) + Spacer(Modifier.height(12.dp)) - // Trigger & Action summary pills + // Trigger & Action summary and controls Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { + // Summary text without nested heavy container shells Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), ) { - if (scene.type == AutomationType.AUTOMATIC && scene.triggers.isNotEmpty()) { - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Text( - text = "⚡ ${scene.triggers.size} 触发器", - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) - } - } - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, - ) { + if (scene.type == AutomationType.AUTOMATIC) { Text( - text = stringResource(R.string.scene_action_count, scene.actions.size), - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSecondaryContainer, + text = if (scene.triggers.isNotEmpty()) "⚡ ${scene.triggers.size} 个触发器 • ${scene.actions.size} 个动作" else "${scene.actions.size} 个动作", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + Text( + text = "${scene.actions.size} 个执行动作", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -326,8 +322,9 @@ private fun SceneCard( if (scene.type == AutomationType.MANUAL) { FilledTonalButton( onClick = onQuickExecute, - contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier.height(32.dp), + shape = RoundedCornerShape(8.dp), ) { Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(4.dp)) @@ -336,10 +333,10 @@ private fun SceneCard( Spacer(Modifier.width(4.dp)) } IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) { - Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp)) + Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) { - Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp)) + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -349,27 +346,20 @@ private fun SceneCard( @Composable private fun TypeBadge(type: AutomationType) { - val (label, bg, fg) = when (type) { - AutomationType.MANUAL -> Triple( - stringResource(R.string.scene_tab_manual), - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.onPrimaryContainer, - ) - AutomationType.AUTOMATIC -> Triple( - stringResource(R.string.scene_tab_automatic), - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.onSecondaryContainer, - ) + val label = when (type) { + AutomationType.MANUAL -> stringResource(R.string.scene_tab_manual) + AutomationType.AUTOMATIC -> stringResource(R.string.scene_tab_automatic) } Surface( shape = RoundedCornerShape(4.dp), - color = bg, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), ) { Text( text = label, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp), style = MaterialTheme.typography.labelSmall, - color = fg, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } @@ -377,9 +367,7 @@ private fun TypeBadge(type: AutomationType) { @Composable private fun EmptySceneList(modifier: Modifier = Modifier) { Column( - modifier = modifier - .fillMaxSize() - .padding(32.dp), + modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -423,5 +411,3 @@ internal fun SceneColor.toColorOrNull(): Color? { val cleanHex = hex.removePrefix("#") return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() } - - -- 2.54.0 From 56451be0d8042e726428694e73a14a86dd94bffb Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 17:16:04 +0800 Subject: [PATCH 7/9] fix(scene): address bot review comments on concurrency, cancellation, validation, and race conditions --- .../manager/ui/scene/SceneEditorScreen.kt | 21 +-- .../manager/ui/scene/SceneEditorViewModel.kt | 9 +- .../ui/scene/SceneExecutionViewModel.kt | 19 ++- .../ui/scene/SceneEditorViewModelTest.kt | 34 ++++ .../ui/scene/SceneExecutionViewModelTest.kt | 35 ++++ .../manager/domain/DefaultSceneExecutor.kt | 153 ++++++++++-------- .../manager/domain/InMemorySceneRepository.kt | 27 +++- .../domain/DefaultSceneExecutorTest.kt | 87 ++++++++++ .../domain/InMemorySceneRepositoryTest.kt | 42 +++++ .../testing/fakes/FakeSceneRepository.kt | 135 +++++++++++----- .../testing/fakes/FakeSceneRepositoryTest.kt | 42 +++++ 11 files changed, 484 insertions(+), 120 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index 95d2cfe..c99a702 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -58,7 +58,6 @@ import androidx.compose.runtime.mutableDoubleStateOf import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -895,19 +894,21 @@ private fun AddActionDialog( var notifyTitle by remember { mutableStateOf("情景完成") } var notifyMsg by remember { mutableStateOf("自动化任务执行完成") } - val coroutineScope = rememberCoroutineScope() - LaunchedEffect(availableRemotes, selectedRemoteIndex) { if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { val remote = availableRemotes[selectedRemoteIndex] - coroutineScope.launch { - val bindings = viewModel.getKeyBindings(remote.id) - keyBindings = bindings - if (bindings.isNotEmpty()) { - selectedButtonId = bindings[0].buttonId - selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId } - } + keyBindings = emptyList() + val bindings = viewModel.getKeyBindings(remote.id) + keyBindings = bindings + if (bindings.isNotEmpty()) { + selectedButtonId = bindings[0].buttonId + selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId } + } else { + selectedButtonId = "power" + selectedButtonName = "电源" } + } else { + keyBindings = emptyList() } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index 66127a0..17d5dcf 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -54,7 +54,7 @@ data class SceneEditorState( val isSaved: Boolean = false, val errorMessage: String? = null, ) { - val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() + val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty()) val isEditing: Boolean get() = sceneId != null } @@ -68,6 +68,9 @@ class SceneEditorViewModel @Inject constructor( private val _state = MutableStateFlow(SceneEditorState()) val state: StateFlow = _state.asStateFlow() + private var initializedSceneId: SceneId? = null + private var isInitialized: Boolean = false + init { observeAvailableRemotes() } @@ -87,6 +90,10 @@ class SceneEditorViewModel @Inject constructor( } fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) { + if (isInitialized && initializedSceneId == sceneId) return + isInitialized = true + initializedSceneId = sceneId + if (sceneId == null) { _state.update { SceneEditorState( diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt index f2acfac..8bc1c00 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -34,7 +34,13 @@ class SceneExecutionViewModel @Inject constructor( private val _uiState = MutableStateFlow(SceneExecutionUiState.Loading) val uiState: StateFlow = _uiState.asStateFlow() + private var initializedSceneId: SceneId? = null + fun initialize(sceneId: SceneId) { + if (initializedSceneId == sceneId && _uiState.value !is SceneExecutionUiState.Loading) { + return + } + initializedSceneId = sceneId _uiState.value = SceneExecutionUiState.Loading viewModelScope.launch { val profile = profileStore.activeProfile() @@ -53,11 +59,18 @@ class SceneExecutionViewModel @Inject constructor( fun execute() { val current = _uiState.value - val scene = (current as? SceneExecutionUiState.Ready)?.scene - ?: (current as? SceneExecutionUiState.Running)?.scene - ?: return + val scene = when (current) { + is SceneExecutionUiState.Ready -> current.scene + is SceneExecutionUiState.Completed -> current.result.scene + else -> return + } _uiState.value = SceneExecutionUiState.Running(scene) viewModelScope.launch { + val profile = profileStore.activeProfile() + if (profile == null || profile.id != scene.profileId) { + _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( diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt index cd02189..863c6fa 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -158,5 +158,39 @@ class SceneEditorViewModelTest { assertTrue(viewModel.state.value.isSaved) assertFalse(viewModel.state.value.isSaving) } + + @Test + fun automaticSceneWithoutTriggersIsInvalid() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.AUTOMATIC) + viewModel.updateName("Auto Without Trigger") + viewModel.addSocketAction("socket-1", SocketPowerState.ON) + advanceUntilIdle() + + // Missing triggers -> invalid + assertFalse(viewModel.state.value.isValid) + + // Adding trigger makes it valid + viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0) + advanceUntilIdle() + assertTrue(viewModel.state.value.isValid) + } + + @Test + fun initializeIsIdempotentAndPreservesDraftState() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(existingScene.id) + advanceUntilIdle() + + viewModel.updateName("Modified Unsaved Name") + assertEquals("Modified Unsaved Name", viewModel.state.value.name) + + // Re-invoking initialize with same sceneId should NOT overwrite draft + viewModel.initialize(existingScene.id) + advanceUntilIdle() + assertEquals("Modified Unsaved Name", viewModel.state.value.name) + } } diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt index 02eb348..0a4e444 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt @@ -87,4 +87,39 @@ class SceneExecutionViewModelTest { assertEquals(testScene.id, completed.result.scene.id) assertEquals(1, completed.result.results.size) } + + @Test + fun initializeIsIdempotentAndPreservesCompletedState() = runTest { + setupProfile() + val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore) + viewModel.initialize(testScene.id) + advanceUntilIdle() + + viewModel.execute() + advanceUntilIdle() + assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed) + + // Re-initializing with same sceneId should preserve Completed state + viewModel.initialize(testScene.id) + advanceUntilIdle() + assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed) + } + + @Test + fun executeFailsWhenActiveProfileMismatches() = runTest { + setupProfile() + val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore) + viewModel.initialize(testScene.id) + advanceUntilIdle() + + // Switch to a different profile + profileStore.setActiveProfile(ProviderProfileId("different-profile")) + + viewModel.execute() + advanceUntilIdle() + + assertTrue(viewModel.uiState.value is SceneExecutionUiState.Failed) + val failed = viewModel.uiState.value as SceneExecutionUiState.Failed + assertEquals("Active profile mismatch", failed.message) + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt index 162f2eb..127a5bd 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -2,8 +2,12 @@ package com.flagship.abox.manager.domain import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import com.flagship.abox.manager.domain.infrared.VirtualRemoteId -import kotlinx.coroutines.delay import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay /** * Production implementation of [SceneExecutor]. @@ -29,32 +33,47 @@ class DefaultSceneExecutor( ) } val startTime = System.currentTimeMillis() - val results = mutableListOf() - var aborted = false return try { - for (action in scene.actions) { - if (aborted) { - results.add( - SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.SKIPPED, - error = null, - ), - ) - continue + val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { + coroutineScope { + scene.actions.map { action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + execResult.copy(executionDurationMillis = duration) + } + }.awaitAll() } + } else { + val sequentialResults = mutableListOf() + var aborted = false + for (action in scene.actions) { + if (aborted) { + sequentialResults.add( + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SKIPPED, + error = null, + ), + ) + continue + } - val actionStart = System.currentTimeMillis() - val execResult = executeAction(scene.profileId, action) - val duration = System.currentTimeMillis() - actionStart - val enrichedResult = execResult.copy(executionDurationMillis = duration) - results.add(enrichedResult) + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enrichedResult = execResult.copy(executionDurationMillis = duration) + sequentialResults.add(enrichedResult) - if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { - aborted = true + if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + aborted = true + } } + sequentialResults } + val totalDuration = System.currentTimeMillis() - startTime DomainResult.Success( SceneExecutionResult( @@ -63,6 +82,8 @@ class DefaultSceneExecutor( totalDurationMillis = totalDuration, ), ) + } catch (e: CancellationException) { + throw e } finally { runningScenes.remove(scene.id) } @@ -72,66 +93,70 @@ class DefaultSceneExecutor( profileId: ProviderProfileId, action: SceneAction, ): SceneActionExecutionResult { - return when (action) { - is SceneAction.SocketPower -> { - val res = socketGateway.setPower( - profileId = profileId, - deviceName = action.deviceName, - powerState = action.powerState, - ) - toExecutionResult(action, res) - } - - is SceneAction.InfraredKey -> { - val res = infraredGateway.sendKey( - profileId = profileId, - deviceName = action.deviceName, - key = action.key, - ) - toExecutionResult(action, res) - } - - is SceneAction.VirtualRemoteButton -> { - if (sendVirtualRemoteAction != null) { - val res = sendVirtualRemoteAction.sendButton( + return try { + when (action) { + is SceneAction.SocketPower -> { + val res = socketGateway.setPower( profileId = profileId, - remoteId = VirtualRemoteId(action.remoteId), - buttonId = action.buttonId, + deviceName = action.deviceName, + powerState = action.powerState, ) toExecutionResult(action, res) - } else { - SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.SUCCESS, - error = null, - ) } - } - is SceneAction.Delay -> { - try { + is SceneAction.InfraredKey -> { + val res = infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + toExecutionResult(action, res) + } + + is SceneAction.VirtualRemoteButton -> { + if (sendVirtualRemoteAction != null) { + val res = sendVirtualRemoteAction.sendButton( + profileId = profileId, + remoteId = VirtualRemoteId(action.remoteId), + buttonId = action.buttonId, + ) + toExecutionResult(action, res) + } else { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = DomainError.OperationFailed( + detail = "Virtual remote action executor is not available", + ), + ) + } + } + + is SceneAction.Delay -> { delayDurationProvider(action.durationSeconds * 1000L) SceneActionExecutionResult( action = action, outcome = SceneActionOutcome.SUCCESS, error = null, ) - } catch (e: Exception) { + } + + is SceneAction.Notification -> { SceneActionExecutionResult( action = action, - outcome = SceneActionOutcome.FAILURE, - error = DomainError.OperationFailed(detail = e.message), + outcome = SceneActionOutcome.SUCCESS, + error = null, ) } } - - is SceneAction.Notification -> { - SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.SUCCESS, - error = null, - ) - } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = DomainError.OperationFailed(detail = e.message), + ) } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt index 4112ef7..c642da1 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -3,6 +3,8 @@ package com.flagship.abox.manager.domain import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * Provider-isolated in-memory [SceneRepository] implementation that supports @@ -10,6 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow */ class InMemorySceneRepository : SceneRepository { private val flows = ConcurrentHashMap>>>() + private val mutexes = ConcurrentHashMap() + + private fun mutexFor(profileId: ProviderProfileId): Mutex = + mutexes.getOrPut(profileId) { Mutex() } override fun observeScenes(profileId: ProviderProfileId): Flow>> = flowFor(profileId) @@ -25,10 +31,13 @@ class InMemorySceneRepository : SceneRepository { override suspend fun createScene( profileId: ProviderProfileId, scene: Scene, - ): DomainResult { + ): DomainResult = mutexFor(profileId).withLock { require(scene.profileId == profileId) { "Scene profileId must match the supplied profileId" } + require(scene.actions.all { it.profileId == profileId }) { + "All scene actions must belong to the supplied profileId" + } val scenes = currentScenes(profileId).orEmpty() if (scenes.any { it.id == scene.id }) { return DomainResult.Failure( @@ -39,39 +48,45 @@ class InMemorySceneRepository : SceneRepository { } val updated = scenes + scene publish(profileId, updated) - return DomainResult.Success(scene) + DomainResult.Success(scene) } override suspend fun updateScene( profileId: ProviderProfileId, scene: Scene, - ): DomainResult { + ): DomainResult = mutexFor(profileId).withLock { require(scene.profileId == profileId) { "Scene profileId must match the supplied profileId" } + require(scene.actions.all { it.profileId == profileId }) { + "All scene actions must belong to the supplied profileId" + } val scenes = currentScenes(profileId).orEmpty() val index = scenes.indexOfFirst { it.id == scene.id } if (index < 0) return notFound(scene.id) val updated = scenes.toMutableList().apply { set(index, scene) } publish(profileId, updated) - return DomainResult.Success(scene) + DomainResult.Success(scene) } override suspend fun deleteScene( profileId: ProviderProfileId, sceneId: SceneId, - ): DomainResult { + ): DomainResult = mutexFor(profileId).withLock { val scenes = currentScenes(profileId).orEmpty() val updated = scenes.filterNot { it.id == sceneId } if (updated.size == scenes.size) return notFound(sceneId) publish(profileId, updated) - return DomainResult.Success(Unit) + DomainResult.Success(Unit) } fun seedDefaults(profileId: ProviderProfileId, scenes: List) { require(scenes.all { it.profileId == profileId }) { "All scenes must belong to the supplied profileId" } + require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) { + "All scene actions must belong to the supplied profileId" + } publish(profileId, scenes) } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt index 90173e0..ae7a092 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt @@ -221,5 +221,92 @@ class DefaultSceneExecutorTest { assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome) assertEquals(5000L, delayCalled) } + + @Test + fun executesActionsConcurrentlyWhenParallelMode() = runTest { + val socketGateway = TestSocketGateway() + val infraredGateway = TestInfraredGateway() + val executor = DefaultSceneExecutor(socketGateway, infraredGateway) + + val action1 = SceneAction.SocketPower( + id = SceneActionId("a1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ) + val action2 = SceneAction.InfraredKey( + id = SceneActionId("a2"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "power", + ) + val scene = Scene( + id = SceneId("s1"), + profileId = profileId, + name = "Parallel Scene", + concurrencyMode = ConcurrencyMode.PARALLEL, + actions = listOf(action1, action2), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome) + assertEquals(1, socketGateway.calls.size) + assertEquals(1, infraredGateway.calls.size) + } + + @Test + fun failsVirtualRemoteButtonActionWhenSenderNotConfigured() = runTest { + val executor = DefaultSceneExecutor( + socketGateway = TestSocketGateway(), + infraredGateway = TestInfraredGateway(), + sendVirtualRemoteAction = null, + ) + val action = SceneAction.VirtualRemoteButton( + id = SceneActionId("vr1"), + profileId = profileId, + remoteId = "tv-remote", + remoteDisplayName = "TV", + buttonId = "power", + buttonDisplayName = "Power", + ) + val scene = Scene( + id = SceneId("s-vr"), + profileId = profileId, + name = "VR Scene", + actions = listOf(action), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(1, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertTrue(executionResult.results[0].error is DomainError.OperationFailed) + } + + @Test(expected = kotlinx.coroutines.CancellationException::class) + fun rethrowsCancellationExceptionWhenDelayCancelled() = runTest { + val executor = DefaultSceneExecutor( + socketGateway = TestSocketGateway(), + infraredGateway = TestInfraredGateway(), + delayDurationProvider = { throw kotlinx.coroutines.CancellationException("Job cancelled") }, + ) + val action = SceneAction.Delay( + id = SceneActionId("d1"), + profileId = profileId, + durationSeconds = 10, + ) + val scene = Scene( + id = SceneId("s-cancelled"), + profileId = profileId, + name = "Cancelled Scene", + actions = listOf(action), + ) + executor.execute(scene) + } } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt index 9ffda08..448f6c9 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt @@ -1,6 +1,9 @@ package com.flagship.abox.manager.domain import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -83,4 +86,43 @@ class InMemorySceneRepositoryTest { val getAfterDelete = repository.getScene(aboxProfile, sceneId) assertNull((getAfterDelete as DomainResult.Success).value) } + + @Test(expected = IllegalArgumentException::class) + fun rejectsSceneWithMismatchedActionProfileId() = runTest { + val repository = InMemorySceneRepository() + val crossAction = SceneAction.SocketPower( + id = SceneActionId("a-cross"), + profileId = ProviderProfileId("other-profile"), + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ) + val scene = Scene( + id = SceneId("abox:admin:scene-invalid"), + profileId = aboxProfile, + name = "Invalid Scene", + actions = listOf(crossAction), + ) + repository.createScene(aboxProfile, scene) + } + + @Test + fun concurrentCreationsAreThreadSafe() = runTest { + val repository = InMemorySceneRepository() + val count = 20 + coroutineScope { + (1..count).map { idx -> + async { + val scene = Scene( + id = SceneId("abox:admin:scene-$idx"), + profileId = aboxProfile, + name = "Scene $idx", + actions = emptyList(), + ) + repository.createScene(aboxProfile, scene) + } + }.awaitAll() + } + val scenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value + assertEquals(count, scenes.size) + } } diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt index 28e620b..bce1d66 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -1,8 +1,10 @@ package com.flagship.abox.manager.testing.fakes +import com.flagship.abox.manager.domain.ConcurrencyMode import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.Scene @@ -18,9 +20,15 @@ import com.flagship.abox.manager.domain.SceneId import com.flagship.abox.manager.domain.SceneRepository import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.SocketPowerState +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock /** * In-memory [SceneRepository] used by the scene UI and ViewModel tests. Scenes are isolated by @@ -35,6 +43,10 @@ class FakeSceneRepository : SceneRepository { ProviderProfileId, MutableStateFlow>>, >() + private val mutexes = ConcurrentHashMap() + + private fun mutexFor(profileId: ProviderProfileId): Mutex = + mutexes.getOrPut(profileId) { Mutex() } override fun observeScenes( profileId: ProviderProfileId, @@ -51,10 +63,13 @@ class FakeSceneRepository : SceneRepository { override suspend fun createScene( profileId: ProviderProfileId, scene: Scene, - ): DomainResult { + ): DomainResult = mutexFor(profileId).withLock { require(scene.profileId == profileId) { "Scene profileId must match the supplied profileId" } + require(scene.actions.all { it.profileId == profileId }) { + "All scene actions must belong to the supplied provider profile" + } val scenes = currentScenes(profileId).orEmpty() if (scenes.any { it.id == scene.id }) { return DomainResult.Failure( @@ -65,33 +80,36 @@ class FakeSceneRepository : SceneRepository { } val updated = scenes + scene publish(profileId, updated) - return DomainResult.Success(scene) + DomainResult.Success(scene) } override suspend fun updateScene( profileId: ProviderProfileId, scene: Scene, - ): DomainResult { + ): DomainResult = mutexFor(profileId).withLock { require(scene.profileId == profileId) { "Scene profileId must match the supplied profileId" } + require(scene.actions.all { it.profileId == profileId }) { + "All scene actions must belong to the supplied provider profile" + } val scenes = currentScenes(profileId).orEmpty() val index = scenes.indexOfFirst { it.id == scene.id } if (index < 0) return notFound(scene.id) val updated = scenes.toMutableList().apply { set(index, scene) } publish(profileId, updated) - return DomainResult.Success(scene) + DomainResult.Success(scene) } override suspend fun deleteScene( profileId: ProviderProfileId, sceneId: SceneId, - ): DomainResult { + ): DomainResult = mutexFor(profileId).withLock { val scenes = currentScenes(profileId).orEmpty() val updated = scenes.filterNot { it.id == sceneId } if (updated.size == scenes.size) return notFound(sceneId) publish(profileId, updated) - return DomainResult.Success(Unit) + DomainResult.Success(Unit) } /** @@ -102,6 +120,9 @@ class FakeSceneRepository : SceneRepository { require(scenes.all { it.profileId == profileId }) { "All scenes must belong to the supplied provider profile" } + require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) { + "All scene actions must belong to the supplied provider profile" + } publish(profileId, scenes) return this } @@ -198,13 +219,43 @@ class FakeSceneExecutor( ) } val startTime = System.currentTimeMillis() - val results = mutableListOf() + return try { - for (action in scene.actions) { - val actionStart = System.currentTimeMillis() - val execResult = executeAction(scene.profileId, action) - val duration = System.currentTimeMillis() - actionStart - results.add(execResult.copy(executionDurationMillis = duration)) + val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { + coroutineScope { + scene.actions.map { action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + execResult.copy(executionDurationMillis = duration) + } + }.awaitAll() + } + } else { + val sequentialResults = mutableListOf() + var aborted = false + for (action in scene.actions) { + if (aborted) { + sequentialResults.add( + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SKIPPED, + error = null, + ), + ) + continue + } + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enrichedResult = execResult.copy(executionDurationMillis = duration) + sequentialResults.add(enrichedResult) + if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + aborted = true + } + } + sequentialResults } val totalDuration = System.currentTimeMillis() - startTime DomainResult.Success( @@ -214,6 +265,8 @@ class FakeSceneExecutor( totalDurationMillis = totalDuration, ), ) + } catch (e: CancellationException) { + throw e } finally { running.remove(scene.id) } @@ -223,34 +276,44 @@ class FakeSceneExecutor( profileId: ProviderProfileId, action: SceneAction, ): SceneActionExecutionResult { - val result = when (action) { - is SceneAction.SocketPower -> socketGateway.setPower( - profileId = profileId, - deviceName = action.deviceName, - powerState = action.powerState, - ) + return try { + val result = when (action) { + is SceneAction.SocketPower -> socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) - is SceneAction.InfraredKey -> infraredGateway.sendKey( - profileId = profileId, - deviceName = action.deviceName, - key = action.key, - ) + is SceneAction.InfraredKey -> infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) - is SceneAction.VirtualRemoteButton, - is SceneAction.Delay, - is SceneAction.Notification -> DomainResult.Success(Unit) - } - return when (result) { - is DomainResult.Success -> SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.SUCCESS, - error = null, - ) + is SceneAction.VirtualRemoteButton, + is SceneAction.Delay, + is SceneAction.Notification -> DomainResult.Success(Unit) + } + when (result) { + is DomainResult.Success -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) - is DomainResult.Failure -> SceneActionExecutionResult( + is DomainResult.Failure -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = result.error, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + SceneActionExecutionResult( action = action, outcome = SceneActionOutcome.FAILURE, - error = result.error, + error = DomainError.OperationFailed(detail = e.message), ) } } diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt index e67f916..ee00b6d 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt @@ -11,6 +11,9 @@ 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.domain.SocketPowerState +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -122,6 +125,45 @@ class FakeSceneRepositoryTest { assertNotNull((value as DomainResult.Failure).error) } + @Test(expected = IllegalArgumentException::class) + fun createSceneRejectsMismatchedActionProfileId() = runTest { + val repo = FakeSceneRepository() + val crossAction = SceneAction.SocketPower( + id = SceneActionId("a-cross"), + profileId = ProviderProfileId("other"), + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ) + val scene = Scene( + id = SceneId("${localProfile.value}:scene-cross"), + profileId = localProfile, + name = "Cross Action", + actions = listOf(crossAction), + ) + repo.createScene(localProfile, scene) + } + + @Test + fun concurrentCreationsAreThreadSafe() = runTest { + val repo = FakeSceneRepository() + val count = 20 + coroutineScope { + (1..count).map { idx -> + async { + val scene = Scene( + id = SceneId("${aboxProfile.value}:scene-$idx"), + profileId = aboxProfile, + name = "Scene $idx", + actions = emptyList(), + ) + repo.createScene(aboxProfile, scene) + } + }.awaitAll() + } + val scenes = scenesFor(repo, aboxProfile) + assertEquals(count, scenes.size) + } + private suspend fun scenesFor( repo: FakeSceneRepository, profileId: ProviderProfileId, -- 2.54.0 From b48079564f97b77af3f7786c4d068299f8442d4f Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 17:35:32 +0800 Subject: [PATCH 8/9] fix(scene): address bot review on fake executor delays, time input validation, and localization --- .../manager/ui/scene/SceneEditorScreen.kt | 187 ++++++++++++------ .../manager/ui/scene/SceneExecutionScreen.kt | 21 +- .../abox/manager/ui/scene/SceneListScreen.kt | 8 +- .../src/main/res/values-zh-rCN/strings.xml | 41 ++++ android/app/src/main/res/values/strings.xml | 41 ++++ .../testing/fakes/FakeSceneRepository.kt | 91 ++++++--- .../testing/fakes/FakeSceneExecutorTest.kt | 40 ++++ 7 files changed, 329 insertions(+), 100 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index c99a702..555ad99 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -223,6 +223,14 @@ fun SceneEditorScreen( } } + if (state.triggers.isEmpty()) { + Text( + text = stringResource(R.string.scene_empty_triggers_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + state.triggers.forEach { trigger -> TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) } @@ -273,6 +281,14 @@ fun SceneEditorScreen( } } + if (state.conditions.isEmpty()) { + Text( + text = stringResource(R.string.scene_empty_conditions_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + state.conditions.forEach { condition -> ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) } @@ -327,7 +343,7 @@ fun SceneEditorScreen( if (state.actions.isEmpty()) { Text( - text = "暂未添加动作,请点击下方按钮添加", + text = stringResource(R.string.scene_empty_actions_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -514,14 +530,17 @@ fun SceneEditorScreen( } } + val defaultSensorName = stringResource(R.string.device_temperature_humidity_title) + val defaultSocketName = stringResource(R.string.scene_default_socket_name) + if (showTriggerDialog) { AddTriggerDialog( onDismiss = { showTriggerDialog = false }, onAdd = { triggerType, time, temp, hum, op -> when (triggerType) { 0 -> viewModel.addTimeTrigger(time) - 1 -> viewModel.addTemperatureTrigger("温湿度传感器", op, temp) - 2 -> viewModel.addHumidityTrigger("温湿度传感器", op, hum) + 1 -> viewModel.addTemperatureTrigger(defaultSensorName, op, temp) + 2 -> viewModel.addHumidityTrigger(defaultSensorName, op, hum) } showTriggerDialog = false }, @@ -532,15 +551,15 @@ fun SceneEditorScreen( AddConditionDialog( onDismiss = { showConditionDialog = false }, onAddTemp = { op, temp -> - viewModel.addTemperatureCondition("温湿度传感器", op, temp) + viewModel.addTemperatureCondition(defaultSensorName, op, temp) showConditionDialog = false }, onAddHumidity = { op, hum -> - viewModel.addHumidityCondition("温湿度传感器", op, hum) + viewModel.addHumidityCondition(defaultSensorName, op, hum) showConditionDialog = false }, onAddSocket = { powerState -> - viewModel.addSocketPowerCondition("Zigbee插座控制器", powerState) + viewModel.addSocketPowerCondition(defaultSocketName, powerState) showConditionDialog = false }, onAddTimeRange = { start, end -> @@ -588,10 +607,10 @@ private fun TriggerItemRow(trigger: SceneTrigger, onRemove: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, ) { val (icon, desc) = when (trigger) { - is SceneTrigger.Time -> Pair("⏰", "定时: ${trigger.timeOfDay}") - is SceneTrigger.Temperature -> Pair("🌡️", "温度 ${trigger.operator.symbol} ${trigger.thresholdCelsius}°C") - is SceneTrigger.Humidity -> Pair("💧", "湿度 ${trigger.operator.symbol} ${trigger.thresholdPercent}%") - is SceneTrigger.DeviceState -> Pair("🔌", "${trigger.deviceName.value} 状态变为 ${trigger.expectedState}") + is SceneTrigger.Time -> Pair("⏰", stringResource(R.string.scene_trigger_type_time) + ": ${trigger.timeOfDay}") + is SceneTrigger.Temperature -> Pair("🌡️", stringResource(R.string.scene_trigger_desc_temp, trigger.operator.symbol, trigger.thresholdCelsius.toInt())) + is SceneTrigger.Humidity -> Pair("💧", stringResource(R.string.scene_trigger_desc_humidity, trigger.operator.symbol, trigger.thresholdPercent.toInt())) + is SceneTrigger.DeviceState -> Pair("🔌", stringResource(R.string.scene_trigger_desc_device, trigger.deviceName.value, trigger.expectedState)) } Row(verticalAlignment = Alignment.CenterVertically) { Text(icon, style = MaterialTheme.typography.titleMedium) @@ -618,10 +637,10 @@ private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) { horizontalArrangement = Arrangement.SpaceBetween, ) { val (icon, desc) = when (condition) { - is SceneCondition.Temperature -> Pair("🌡️", "温度需 ${condition.operator.symbol} ${condition.thresholdCelsius}°C") - is SceneCondition.Humidity -> Pair("💧", "湿度需 ${condition.operator.symbol} ${condition.thresholdPercent}%") - is SceneCondition.SocketPower -> Pair("🔌", "${condition.deviceName.value} 处于 ${condition.expectedState.name}") - is SceneCondition.TimeRange -> Pair("🕒", "时间在 ${condition.startTime} ~ ${condition.endTime} 之间") + is SceneCondition.Temperature -> Pair("🌡️", stringResource(R.string.scene_condition_desc_temp, condition.operator.symbol, condition.thresholdCelsius.toInt())) + is SceneCondition.Humidity -> Pair("💧", stringResource(R.string.scene_condition_desc_humidity, condition.operator.symbol, condition.thresholdPercent.toInt())) + is SceneCondition.SocketPower -> Pair("🔌", stringResource(R.string.scene_condition_desc_socket, condition.deviceName.value, condition.expectedState.name)) + is SceneCondition.TimeRange -> Pair("🕒", stringResource(R.string.scene_condition_desc_time, condition.startTime, condition.endTime)) } Row(verticalAlignment = Alignment.CenterVertically) { Text(icon, style = MaterialTheme.typography.titleMedium) @@ -748,15 +767,18 @@ private fun AddTriggerDialog( var humThreshold by remember { mutableDoubleStateOf(40.0) } var operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } + val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") } + val isTimeValid = timeRegex.matches(timeInput.trim()) + 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)) { - FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("⏰ 定时") }) - FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("🌡️ 温度") }) - FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text("💧 湿度") }) + 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)) }) } when (selectedType) { 0 -> { @@ -765,19 +787,23 @@ private fun AddTriggerDialog( onValueChange = { timeInput = it }, label = { Text(stringResource(R.string.scene_trigger_time_label)) }, singleLine = true, + isError = !isTimeValid, + supportingText = if (!isTimeValid) { + { Text(stringResource(R.string.scene_trigger_time_error)) } + } else null, modifier = Modifier.fillMaxWidth(), ) } 1, 2 -> { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) - FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text("< 低于") }) + 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)) }) } if (selectedType == 1) { - Text("设定阈值: ${tempThreshold.toInt()} °C") + Text(stringResource(R.string.scene_threshold_temp, tempThreshold.toInt())) Slider(value = tempThreshold.toFloat(), onValueChange = { tempThreshold = it.toDouble() }, valueRange = 10f..40f) } else { - Text("设定阈值: ${humThreshold.toInt()} %") + Text(stringResource(R.string.scene_threshold_hum, humThreshold.toInt())) Slider(value = humThreshold.toFloat(), onValueChange = { humThreshold = it.toDouble() }, valueRange = 10f..90f) } } @@ -785,7 +811,11 @@ private fun AddTriggerDialog( } }, confirmButton = { - Button(onClick = { onAdd(selectedType, timeInput, tempThreshold, humThreshold, operator) }) { + val canConfirm = selectedType != 0 || isTimeValid + Button( + onClick = { onAdd(selectedType, timeInput.trim(), tempThreshold, humThreshold, operator) }, + enabled = canConfirm, + ) { Text(stringResource(R.string.scene_add_action)) } }, @@ -811,57 +841,86 @@ private fun AddConditionDialog( var startTime by remember { mutableStateOf("22:00") } var endTime by remember { mutableStateOf("07:00") } + val timeRegex = remember { Regex("^([01]\\d|2[0-3]):[0-5]\\d$") } + val isStartTimeValid = timeRegex.matches(startTime.trim()) + val isEndTimeValid = timeRegex.matches(endTime.trim()) + val isTimeRangeValid = isStartTimeValid && isEndTimeValid + AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text("🌡️ 温度") }) - FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text("💧 湿度") }) - FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text("🔌 插座") }) - FilterChip(selected = selectedType == 3, onClick = { selectedType = 3 }, label = { Text("🕒 时间") }) + 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)) }) + FilterChip(selected = selectedType == 3, onClick = { selectedType = 3 }, label = { Text(stringResource(R.string.scene_chip_time)) }) } when (selectedType) { 0 -> { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) - FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text("< 低于") }) + 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)) }) } - Text("设定温度: ${tempVal.toInt()} °C") + Text(stringResource(R.string.scene_threshold_temp, tempVal.toInt())) Slider(value = tempVal.toFloat(), onValueChange = { tempVal = it.toDouble() }, valueRange = 10f..40f) } 1 -> { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text("> 高于") }) - FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text("< 低于") }) + 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)) }) } - Text("设定湿度: ${humVal.toInt()} %") + Text(stringResource(R.string.scene_threshold_hum, humVal.toInt())) Slider(value = humVal.toFloat(), onValueChange = { humVal = it.toDouble() }, valueRange = 10f..90f) } 2 -> { - Text("要求插座当前状态为:") + Text(stringResource(R.string.scene_target_socket_state)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text("开启") }) - FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text("关闭") }) + 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)) }) } } 3 -> { - OutlinedTextField(value = startTime, onValueChange = { startTime = it }, label = { Text(stringResource(R.string.scene_condition_start_time)) }, singleLine = true) - OutlinedTextField(value = endTime, onValueChange = { endTime = it }, label = { Text(stringResource(R.string.scene_condition_end_time)) }, singleLine = true) + OutlinedTextField( + value = startTime, + onValueChange = { startTime = it }, + label = { Text(stringResource(R.string.scene_condition_start_time)) }, + singleLine = true, + isError = !isStartTimeValid, + supportingText = if (!isStartTimeValid) { + { Text(stringResource(R.string.scene_condition_time_error)) } + } else null, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = endTime, + onValueChange = { endTime = it }, + label = { Text(stringResource(R.string.scene_condition_end_time)) }, + singleLine = true, + isError = !isEndTimeValid, + supportingText = if (!isEndTimeValid) { + { Text(stringResource(R.string.scene_condition_time_error)) } + } else null, + modifier = Modifier.fillMaxWidth(), + ) } } } }, confirmButton = { - Button(onClick = { - when (selectedType) { - 0 -> onAddTemp(operator, tempVal) - 1 -> onAddHumidity(operator, humVal) - 2 -> onAddSocket(socketPower) - 3 -> onAddTimeRange(startTime, endTime) - } - }) { + val canConfirm = selectedType != 3 || isTimeRangeValid + Button( + onClick = { + when (selectedType) { + 0 -> onAddTemp(operator, tempVal) + 1 -> onAddHumidity(operator, humVal) + 2 -> onAddSocket(socketPower) + 3 -> onAddTimeRange(startTime.trim(), endTime.trim()) + } + }, + enabled = canConfirm, + ) { Text(stringResource(R.string.scene_add_action)) } }, @@ -882,17 +941,23 @@ private fun AddActionDialog( onAddNotify: (title: String, msg: String) -> Unit, ) { var actionType by remember { mutableIntStateOf(0) } - var socketName by remember { mutableStateOf("Zigbee插座控制器") } + val defaultSocketName = stringResource(R.string.scene_default_socket_name) + val defaultIrName = stringResource(R.string.scene_default_ir_name) + val defaultPowerBtn = stringResource(R.string.scene_default_button_power) + val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title) + val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg) + + var socketName by remember { mutableStateOf(defaultSocketName) } var socketPower by remember { mutableStateOf(SocketPowerState.ON) } var selectedRemoteIndex by remember { mutableIntStateOf(0) } var keyBindings by remember { mutableStateOf>(emptyList()) } var selectedButtonId by remember { mutableStateOf("power") } - var selectedButtonName by remember { mutableStateOf("电源") } + var selectedButtonName by remember { mutableStateOf(defaultPowerBtn) } var delaySeconds by remember { mutableIntStateOf(3) } - var notifyTitle by remember { mutableStateOf("情景完成") } - var notifyMsg by remember { mutableStateOf("自动化任务执行完成") } + var notifyTitle by remember { mutableStateOf(defaultNotifyTitle) } + var notifyMsg by remember { mutableStateOf(defaultNotifyMsg) } LaunchedEffect(availableRemotes, selectedRemoteIndex) { if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { @@ -905,7 +970,7 @@ private fun AddActionDialog( selectedButtonName = bindings[0].displayName.ifBlank { bindings[0].buttonId } } else { selectedButtonId = "power" - selectedButtonName = "电源" + selectedButtonName = defaultPowerBtn } } else { keyBindings = emptyList() @@ -918,10 +983,10 @@ private fun AddActionDialog( text = { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text("🔌 插座") }) - FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text("📺 遥控") }) - FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text("⏱️ 延时") }) - FilterChip(selected = actionType == 3, onClick = { actionType = 3 }, label = { Text("🔔 提示") }) + 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)) }) + FilterChip(selected = actionType == 3, onClick = { actionType = 3 }, label = { Text(stringResource(R.string.scene_chip_notify)) }) } when (actionType) { 0 -> { @@ -934,14 +999,14 @@ private fun AddActionDialog( ) Text(stringResource(R.string.scene_socket_power_state)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text("开启") }) - FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text("关闭") }) + 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)) }) } } 1 -> { if (availableRemotes.isEmpty()) { - Text("未找到虚拟遥控器,将默认使用红外控制器") - OutlinedTextField(value = selectedButtonId, onValueChange = { selectedButtonId = it }, label = { Text("按键名称/槽位") }, singleLine = true) + 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)) { @@ -969,12 +1034,12 @@ private fun AddActionDialog( } } } else { - OutlinedTextField(value = selectedButtonName, onValueChange = { selectedButtonName = it; selectedButtonId = it }, label = { Text("按键名称") }, singleLine = true) + OutlinedTextField(value = selectedButtonName, onValueChange = { selectedButtonName = it; selectedButtonId = it }, label = { Text(stringResource(R.string.scene_remote_button_name_label)) }, singleLine = true) } } } 2 -> { - Text(stringResource(R.string.scene_delay_duration_label) + ": $delaySeconds 秒") + Text(stringResource(R.string.scene_delay_duration_label) + ": " + stringResource(R.string.scene_unit_seconds, delaySeconds)) Slider( value = delaySeconds.toFloat(), onValueChange = { delaySeconds = it.toInt() }, @@ -998,7 +1063,7 @@ private fun AddActionDialog( if (remote != null) { onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) } else { - onAddRemote("default-ir", "红外控制器", selectedButtonId, selectedButtonName) + onAddRemote("default-ir", defaultIrName, selectedButtonId, selectedButtonName) } } 2 -> onAddDelay(delaySeconds) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt index a6dd083..b288953 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -116,7 +116,7 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRet Button(onClick = onRetry) { Icon(Icons.Filled.Refresh, contentDescription = null) Spacer(Modifier.width(6.dp)) - Text("重试") + Text(stringResource(R.string.action_retry)) } } } @@ -137,7 +137,7 @@ private fun ReadyContent( ) { Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) Spacer(Modifier.height(8.dp)) - Text("点击下方按钮立即开始执行", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(stringResource(R.string.scene_exec_ready_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(24.dp)) Button( onClick = onExecute, @@ -247,14 +247,14 @@ private fun CompletedContent( ) { Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) Spacer(Modifier.width(6.dp)) - Text("重新执行") + Text(stringResource(R.string.scene_exec_rerun)) } Button( onClick = onBack, modifier = Modifier.weight(1f).height(48.dp), shape = RoundedCornerShape(12.dp), ) { - Text("返回", fontWeight = FontWeight.SemiBold) + Text(stringResource(R.string.action_back), fontWeight = FontWeight.SemiBold) } } } @@ -310,7 +310,7 @@ private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "步骤 $stepNumber: ${entry.action.describe()}", + text = stringResource(R.string.scene_step_format, stepNumber, entry.action.describe()), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, ) @@ -334,11 +334,12 @@ private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) } } +@Composable private fun SceneAction.describe(): String = when (this) { - is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}" - is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName" - is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key" - is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒" - is SceneAction.Notification -> "🔔 提示: $title" + is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name) + is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName) + is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key) + is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds) + is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title) } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt index 8130cbd..5560346 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -305,13 +305,17 @@ private fun SceneCard( ) { if (scene.type == AutomationType.AUTOMATIC) { Text( - text = if (scene.triggers.isNotEmpty()) "⚡ ${scene.triggers.size} 个触发器 • ${scene.actions.size} 个动作" else "${scene.actions.size} 个动作", + text = if (scene.triggers.isNotEmpty()) { + stringResource(R.string.scene_summary_triggers_and_actions, scene.triggers.size, scene.actions.size) + } else { + stringResource(R.string.scene_summary_actions_only, scene.actions.size) + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } else { Text( - text = "${scene.actions.size} 个执行动作", + text = stringResource(R.string.scene_summary_actions_only, scene.actions.size), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 7da15d5..257617c 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -161,6 +161,47 @@ 执行失败 已跳过 + + 时间格式无效(请按 HH:mm 格式输入,如 07:30) + 时间格式无效(请按 HH:mm 格式输入,如 22:00) + ⏰ 定时 + 🌡️ 温度 + 💧 湿度 + 🔌 插座 + 📺 遥控 + ⏱️ 延时 + 🔔 提示 + > 高于 + < 低于 + 开启 + 关闭 + 设定阈值:%1$d °C + 设定阈值:%1$d %% + 要求插座当前状态为: + 未找到虚拟遥控器,将默认使用红外控制器 + 按键名称/槽位 + 按键名称 + 尚未添加触发器,请点击下方按钮添加 + 尚未添加限制条件,请点击下方按钮添加 + 尚未添加执行动作,请点击下方按钮添加 + ⚡ %1$d 个触发器 • %2$d 个动作 + %1$d 个执行动作 + 点击下方按钮立即开始执行 + 重新执行 + 步骤 %1$d:%2$s + 温度 %1$s %2$s°C + 湿度 %1$s %2$s%% + %1$s 状态变为 %2$s + 温度需 %1$s %2$s°C + 湿度需 %1$s %2$s%% + %1$s 处于 %2$s + 时间在 %1$s ~ %2$s 之间 + Zigbee插座控制器 + 红外控制器 + 电源 + 情景完成 + 自动化任务执行完成 + 设备列表 选择需要控制或查看状态的 ABox 终端设备。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 9ee38d7..ec69aa7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -161,6 +161,47 @@ Failed Skipped + + Invalid time format (use HH:mm, e.g. 07:30) + Invalid time format (use HH:mm, e.g. 22:00) + ⏰ Schedule + 🌡️ Temperature + 💧 Humidity + 🔌 Socket + 📺 Remote + ⏱️ Delay + 🔔 Notify + > Above + < Below + Turn ON + Turn OFF + Target threshold: %1$d °C + Target threshold: %1$d %% + Require socket state to be: + No virtual remotes found; using infrared controller by default. + Button Name / Slot + Button Name + No triggers configured. Tap + below to add. + No conditions configured. Tap + below to add. + No actions configured. Tap + below to add. + ⚡ %1$d triggers • %2$d actions + %1$d actions + Tap the button below to run immediately + Re-run + Step %1$d: %2$s + Temp %1$s %2$s°C + Humidity %1$s %2$s%% + %1$s state changed to %2$s + Temp must be %1$s %2$s°C + Humidity must be %1$s %2$s%% + %1$s is %2$s + Time is between %1$s ~ %2$s + Zigbee Smart Socket + Infrared Controller + Power + Scene Completed + Automation task executed successfully + Device List Select an ABox terminal device to control or check status. diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt index bce1d66..81c5bf8 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.sync.Mutex @@ -201,13 +202,17 @@ class FakeSceneRepository : SceneRepository { } /** - * Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes. + * Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes, + * respecting delays and recording virtual remote / notification actions. */ class FakeSceneExecutor( private val socketGateway: SocketGateway, private val infraredGateway: InfraredGateway, + private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) }, ) : SceneExecutor { private val running = ConcurrentHashMap() + val executedRemoteButtons = mutableListOf() + val sentNotifications = mutableListOf() override suspend fun execute(scene: Scene): DomainResult { val alreadyRunning = running.putIfAbsent(scene.id, true) != null @@ -277,35 +282,51 @@ class FakeSceneExecutor( action: SceneAction, ): SceneActionExecutionResult { return try { - val result = when (action) { - is SceneAction.SocketPower -> socketGateway.setPower( - profileId = profileId, - deviceName = action.deviceName, - powerState = action.powerState, - ) + when (action) { + is SceneAction.SocketPower -> { + val result = socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + toExecutionResult(action, result) + } - is SceneAction.InfraredKey -> infraredGateway.sendKey( - profileId = profileId, - deviceName = action.deviceName, - key = action.key, - ) + is SceneAction.InfraredKey -> { + val result = infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + toExecutionResult(action, result) + } - is SceneAction.VirtualRemoteButton, - is SceneAction.Delay, - is SceneAction.Notification -> DomainResult.Success(Unit) - } - when (result) { - is DomainResult.Success -> SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.SUCCESS, - error = null, - ) + is SceneAction.VirtualRemoteButton -> { + executedRemoteButtons.add(action) + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } - is DomainResult.Failure -> SceneActionExecutionResult( - action = action, - outcome = SceneActionOutcome.FAILURE, - error = result.error, - ) + is SceneAction.Delay -> { + delayDurationProvider(action.durationSeconds * 1000L) + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } + + is SceneAction.Notification -> { + sentNotifications.add(action) + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } } } catch (e: CancellationException) { throw e @@ -317,4 +338,20 @@ class FakeSceneExecutor( ) } } + + private fun toExecutionResult(action: SceneAction, result: DomainResult<*>): SceneActionExecutionResult { + return when (result) { + is DomainResult.Success -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + + is DomainResult.Failure -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = result.error, + ) + } + } } diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt index 7e2551c..b2cb7d5 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt @@ -116,6 +116,46 @@ class FakeSceneExecutorTest { assertFalse(executionResult.allSucceeded) } + @Test + fun executesDelayVirtualRemoteAndNotification() = runTest { + val delays = mutableListOf() + val executor = FakeSceneExecutor( + socketGateway = FakeSocketGateway(), + infraredGateway = FakeInfraredGateway(), + delayDurationProvider = { delays.add(it) }, + ) + + val remoteAction = SceneAction.VirtualRemoteButton( + id = SceneActionId("abox:btn-1"), + profileId = profileId, + remoteId = "remote-1", + remoteDisplayName = "TV Remote", + buttonId = "power", + buttonDisplayName = "Power", + ) + val delayAction = SceneAction.Delay( + id = SceneActionId("abox:delay-1"), + profileId = profileId, + durationSeconds = 5, + ) + val notificationAction = SceneAction.Notification( + id = SceneActionId("abox:notif-1"), + profileId = profileId, + title = "Test Done", + message = "Success", + ) + + val scene = scene(listOf(remoteAction, delayAction, notificationAction)) + val result = executor.execute(scene) + + assertTrue(result is DomainResult.Success) + val execResult = (result as DomainResult.Success).value + assertTrue(execResult.allSucceeded) + assertEquals(listOf(5000L), delays) + assertEquals(listOf(remoteAction), executor.executedRemoteButtons) + assertEquals(listOf(notificationAction), executor.sentNotifications) + } + private fun scene(actions: List): Scene = Scene( id = SceneId("${profileId.value}:scene-test"), profileId = profileId, -- 2.54.0 From e7da708e3712c3d7bf28ed15543bbd4144e75d27 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 18:00:25 +0800 Subject: [PATCH 9/9] fix(scene): preserve fixed gateway device identifiers, fix edit loading race condition and parallel abort strategy --- .../manager/ui/scene/SceneEditorScreen.kt | 943 ++++++++++-------- .../manager/ui/scene/SceneEditorViewModel.kt | 18 +- .../src/main/res/values-zh-rCN/strings.xml | 5 +- android/app/src/main/res/values/strings.xml | 5 +- .../ui/scene/SceneEditorViewModelTest.kt | 8 + .../manager/domain/DefaultSceneExecutor.kt | 50 +- .../domain/DefaultSceneExecutorTest.kt | 40 + .../testing/fakes/FakeSceneRepository.kt | 50 +- .../testing/fakes/FakeSceneExecutorTest.kt | 40 + 9 files changed, 694 insertions(+), 465 deletions(-) diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt index 555ad99..314c4d7 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -1,15 +1,11 @@ package com.flagship.abox.manager.ui.scene -import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.BorderStroke 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.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -24,18 +20,24 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.outlined.Add import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.CheckCircle +import androidx.compose.material.icons.outlined.HourglassDisabled +import androidx.compose.material.icons.outlined.PlayCircle import androidx.compose.material.icons.outlined.Schedule -import androidx.compose.material.icons.outlined.Thermostat +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Tune 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.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -74,7 +76,6 @@ import com.flagship.abox.manager.domain.ComparisonOperator import com.flagship.abox.manager.domain.ConcurrencyMode import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.SceneAction -import com.flagship.abox.manager.domain.SceneColor import com.flagship.abox.manager.domain.SceneCondition import com.flagship.abox.manager.domain.SceneIcon import com.flagship.abox.manager.domain.SceneId @@ -82,13 +83,10 @@ import com.flagship.abox.manager.domain.SceneTrigger import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote -import kotlinx.coroutines.launch -private val PRESET_COLORS = listOf( - "#3F51B5", "#26A69A", "#FF8A65", "#7986CB", - "#E91E63", "#9C27B0", "#4CAF50", "#FF9800", - "#00BCD4", "#607D8B", "#F44336", "#673AB7", -) +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 @@ -99,24 +97,36 @@ fun SceneEditorScreen( onSaved: () -> Unit, viewModel: SceneEditorViewModel = hiltViewModel(), ) { - val state by viewModel.state.collectAsStateWithLifecycle() - - var showTriggerDialog by remember { mutableStateOf(false) } - var showConditionDialog by remember { mutableStateOf(false) } - var showActionDialog by remember { mutableStateOf(false) } - var expandAdvanced by remember { mutableStateOf(false) } - var expandOther by remember { mutableStateOf(false) } - - LaunchedEffect(sceneId) { + LaunchedEffect(sceneId, initialType) { viewModel.initialize(sceneId, initialType) } + val state by viewModel.state.collectAsStateWithLifecycle() + LaunchedEffect(state.isSaved) { if (state.isSaved) { onSaved() } } + SceneEditorContent( + state = state, + viewModel = viewModel, + onBack = onBack, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SceneEditorContent( + state: SceneEditorState, + viewModel: SceneEditorViewModel, + onBack: () -> Unit, +) { + var showTriggerDialog by remember { mutableStateOf(false) } + var showConditionDialog by remember { mutableStateOf(false) } + var showActionDialog by remember { mutableStateOf(false) } + Scaffold( topBar = { TopAppBar( @@ -127,69 +137,229 @@ fun SceneEditorScreen( else if (state.type == AutomationType.AUTOMATIC) R.string.scene_editor_auto_title else R.string.scene_editor_manual_title, ), - fontWeight = FontWeight.SemiBold, + fontWeight = FontWeight.Bold, ) }, navigationIcon = { IconButton(onClick = onBack) { - Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_back)) + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } }, actions = { IconButton( onClick = viewModel::save, - enabled = state.isValid && !state.isSaving, + enabled = state.isValid && !state.isSaving && !state.isLoading, ) { Icon( Icons.Filled.Check, contentDescription = stringResource(R.string.scene_save_action), - tint = if (state.isValid && !state.isSaving) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + tint = if (state.isValid && !state.isSaving && !state.isLoading) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), ) } }, ) }, ) { innerPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - item(key = "title_card") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - OutlinedTextField( - value = state.name, - onValueChange = viewModel::updateName, - label = { Text(stringResource(R.string.scene_name_label)) }, - placeholder = { Text(stringResource(R.string.scene_name_hint)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) - OutlinedTextField( - value = state.description, - onValueChange = viewModel::updateDescription, - label = { Text(stringResource(R.string.scene_description_label)) }, - placeholder = { Text(stringResource(R.string.scene_description_hint)) }, - singleLine = false, - maxLines = 2, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) - } + if (state.isLoading) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + stringResource(R.string.scene_loading_scene), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + item(key = "title_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = state.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.scene_name_label)) }, + placeholder = { Text(stringResource(R.string.scene_name_hint)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + OutlinedTextField( + value = state.description, + onValueChange = viewModel::updateDescription, + label = { Text(stringResource(R.string.scene_description_label)) }, + placeholder = { Text(stringResource(R.string.scene_description_hint)) }, + singleLine = false, + maxLines = 2, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) + } + } + } - if (state.type == AutomationType.AUTOMATIC) { - item(key = "triggers_card") { + item(key = "icon_color_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + stringResource(R.string.scene_section_other), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + listOf( + SceneIcon.HOME to "Home", + SceneIcon.NIGHT to "Night", + SceneIcon.AWAY to "Away", + SceneIcon.MOVIE to "Movie", + SceneIcon.CUSTOM to "Custom", + ).forEach { (icon, name) -> + val isSelected = state.icon == icon + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isSelected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier + .size(52.dp) + .clickable { viewModel.updateIcon(icon) }, + border = if (isSelected) BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = when (icon) { + SceneIcon.HOME -> Icons.Outlined.CheckCircle + SceneIcon.NIGHT -> Icons.Outlined.Schedule + SceneIcon.AWAY -> Icons.Outlined.HourglassDisabled + SceneIcon.MOVIE -> Icons.Outlined.PlayCircle + else -> Icons.Outlined.Settings + }, + contentDescription = name, + tint = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) + } + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + listOf( + "#3F51B5", "#E91E63", "#26A69A", "#FF8A65", "#673AB7", "#4CAF50", + ).forEach { hex -> + val isSelected = state.colorHex.equals(hex, ignoreCase = true) + val parsedColor = try { + Color(android.graphics.Color.parseColor(hex)) + } catch (e: Exception) { + MaterialTheme.colorScheme.primary + } + Box( + modifier = Modifier + .size(36.dp) + .clip(CircleShape) + .background(parsedColor) + .clickable { viewModel.updateColor(hex) }, + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + Icons.Filled.Check, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } + } + } + } + } + } + } + + if (state.type == AutomationType.AUTOMATIC) { + item(key = "triggers_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Schedule, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_triggers), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + "${state.triggers.size}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (state.triggers.isEmpty()) { + Text( + stringResource(R.string.scene_empty_triggers_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + state.triggers.forEach { trigger -> + TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) + } + } + + OutlinedButton( + onClick = { showTriggerDialog = true }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.scene_add_trigger)) + } + } + } + } + } + + item(key = "conditions_card") { OutlinedCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(16.dp), @@ -203,208 +373,138 @@ fun SceneEditorScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Outlined.Schedule, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Icon(Icons.Outlined.Tune, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) Spacer(Modifier.width(8.dp)) Text( - stringResource(R.string.scene_section_triggers), + stringResource(R.string.scene_section_conditions), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) } - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.primaryContainer, - ) { - Text( - text = "${state.triggers.size}", - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - ) - } - } - - if (state.triggers.isEmpty()) { Text( - text = stringResource(R.string.scene_empty_triggers_hint), - style = MaterialTheme.typography.bodySmall, + "${state.conditions.size}", + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - state.triggers.forEach { trigger -> - TriggerItemRow(trigger = trigger, onRemove = { viewModel.removeTrigger(trigger.id) }) + if (state.conditions.isEmpty()) { + Text( + stringResource(R.string.scene_empty_conditions_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + state.conditions.forEach { condition -> + ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) + } } OutlinedButton( - onClick = { showTriggerDialog = true }, + onClick = { showConditionDialog = true }, modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), ) { - Text(stringResource(R.string.scene_add_trigger)) + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.scene_add_condition)) } } } } - } - item(key = "conditions_card") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Outlined.Thermostat, contentDescription = null, tint = MaterialTheme.colorScheme.tertiary, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(8.dp)) - Text( - stringResource(R.string.scene_section_conditions), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.tertiaryContainer, - ) { - Text( - text = "${state.conditions.size}", - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, - ) - } - } - - if (state.conditions.isEmpty()) { + item(key = "actions_card_header") { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Outlined.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(22.dp)) + Spacer(Modifier.width(8.dp)) Text( - text = stringResource(R.string.scene_empty_conditions_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(R.string.scene_section_actions), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, ) } - - state.conditions.forEach { condition -> - ConditionItemRow(condition = condition, onRemove = { viewModel.removeCondition(condition.id) }) - } - - OutlinedButton( - onClick = { showConditionDialog = true }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(R.string.scene_add_condition)) - } + Text( + stringResource(R.string.scene_action_count, state.actions.size), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } - } - item(key = "actions_header") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { - Row( + if (state.actions.isEmpty()) { + item(key = "actions_empty") { + OutlinedCard( modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Outlined.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) - Spacer(Modifier.width(8.dp)) - Text( - stringResource(R.string.scene_section_actions), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - } - Surface( - shape = RoundedCornerShape(6.dp), - color = MaterialTheme.colorScheme.secondaryContainer, + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, ) { Text( - text = when (state.concurrencyMode) { - ConcurrencyMode.SEQUENTIAL -> stringResource(R.string.scene_concurrency_sync) - ConcurrencyMode.PARALLEL -> stringResource(R.string.scene_concurrency_async) - }, - modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), - style = MaterialTheme.typography.labelSmall, + stringResource(R.string.scene_empty_actions_hint), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - - if (state.actions.isEmpty()) { - Text( - text = stringResource(R.string.scene_empty_actions_hint), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } + } + } else { + itemsIndexed( + items = state.actions, + key = { _, action -> action.id.value }, + ) { index, action -> + ActionItemRow( + stepNumber = index + 1, + action = action, + canMoveUp = index > 0, + canMoveDown = index < state.actions.size - 1, + onMoveUp = { viewModel.moveAction(index, index - 1) }, + onMoveDown = { viewModel.moveAction(index, index + 1) }, + onRemove = { viewModel.removeAction(action.id) }, + ) } } - } - itemsIndexed(state.actions, key = { _, it -> it.id.value }) { index, action -> - ActionCardRow( - action = action, - index = index, - total = state.actions.size, - onMoveUp = { i -> viewModel.moveAction(i, i - 1) }, - onMoveDown = { i -> viewModel.moveAction(i, i + 1) }, - onRemove = { viewModel.removeAction(action.id) }, - ) - } - - item(key = "add_action_btn") { - Button( - onClick = { showActionDialog = true }, - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(R.string.scene_add_action_btn), fontWeight = FontWeight.SemiBold) + item(key = "add_action_button") { + OutlinedButton( + onClick = { showActionDialog = true }, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.scene_add_action_btn), fontWeight = FontWeight.SemiBold) + } } - } - item(key = "advanced_options") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expandAdvanced = !expandAdvanced }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { + item(key = "execution_strategy_card") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { Text( - "★ " + stringResource(R.string.scene_section_advanced), + stringResource(R.string.scene_section_advanced), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) - Icon( - if (expandAdvanced) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, - contentDescription = null, - ) - } - AnimatedVisibility(visible = expandAdvanced) { - Column( - modifier = Modifier.padding(top = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.labelMedium) + 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)) { FilterChip( selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL, @@ -417,8 +517,10 @@ fun SceneEditorScreen( label = { Text(stringResource(R.string.scene_concurrency_async)) }, ) } + } - Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.labelMedium) + 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)) { FilterChip( selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR, @@ -432,115 +534,50 @@ fun SceneEditorScreen( ) } } - } - } - } - } - item(key = "other_options") { - OutlinedCard( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expandOther = !expandOther }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - "🎨 " + stringResource(R.string.scene_section_other), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - ) - Icon( - if (expandOther) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, - contentDescription = null, - ) - } - - AnimatedVisibility(visible = expandOther) { - Column( - modifier = Modifier.padding(top = 12.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - Text(stringResource(R.string.scene_appearance_icon), style = MaterialTheme.typography.labelMedium) - IconPickerRow( - selectedIcon = state.icon, - onSelect = viewModel::updateIcon, + Text(stringResource(R.string.scene_enable_logging), style = MaterialTheme.typography.bodyMedium) + Switch( + checked = state.enableLogging, + onCheckedChange = viewModel::updateLogging, ) - - Text(stringResource(R.string.scene_appearance_color), style = MaterialTheme.typography.labelMedium) - ColorPickerRow( - selectedColorHex = state.colorHex, - onSelect = viewModel::updateColor, - ) - - if (state.type == AutomationType.AUTOMATIC) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(stringResource(R.string.scene_enable_rule), style = MaterialTheme.typography.bodyMedium) - Switch( - checked = state.enabled, - onCheckedChange = viewModel::updateEnabled, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(stringResource(R.string.scene_enable_logging), style = MaterialTheme.typography.bodyMedium) - Switch( - checked = state.enableLogging, - onCheckedChange = viewModel::updateLogging, - ) - } } } } } - } - item(key = "save_section") { - state.errorMessage?.let { - Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) - Spacer(Modifier.height(8.dp)) - } - Button( - onClick = viewModel::save, - enabled = state.isValid && !state.isSaving, - modifier = Modifier - .fillMaxWidth() - .height(48.dp), - shape = RoundedCornerShape(12.dp), - ) { - Text(stringResource(R.string.scene_save_action), fontWeight = FontWeight.Bold) + item(key = "save_section") { + state.errorMessage?.let { + Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + Spacer(Modifier.height(8.dp)) + } + Button( + onClick = viewModel::save, + enabled = state.isValid && !state.isSaving && !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_save_action), fontWeight = FontWeight.Bold) + } } } } } - val defaultSensorName = stringResource(R.string.device_temperature_humidity_title) - val defaultSocketName = stringResource(R.string.scene_default_socket_name) - if (showTriggerDialog) { AddTriggerDialog( onDismiss = { showTriggerDialog = false }, - onAdd = { triggerType, time, temp, hum, op -> + onAdd = { triggerType, devName, time, temp, hum, op -> when (triggerType) { 0 -> viewModel.addTimeTrigger(time) - 1 -> viewModel.addTemperatureTrigger(defaultSensorName, op, temp) - 2 -> viewModel.addHumidityTrigger(defaultSensorName, op, hum) + 1 -> viewModel.addTemperatureTrigger(devName, op, temp) + 2 -> viewModel.addHumidityTrigger(devName, op, hum) } showTriggerDialog = false }, @@ -550,16 +587,16 @@ fun SceneEditorScreen( if (showConditionDialog) { AddConditionDialog( onDismiss = { showConditionDialog = false }, - onAddTemp = { op, temp -> - viewModel.addTemperatureCondition(defaultSensorName, op, temp) + onAddTemp = { devName, op, temp -> + viewModel.addTemperatureCondition(devName, op, temp) showConditionDialog = false }, - onAddHumidity = { op, hum -> - viewModel.addHumidityCondition(defaultSensorName, op, hum) + onAddHumidity = { devName, op, hum -> + viewModel.addHumidityCondition(devName, op, hum) showConditionDialog = false }, - onAddSocket = { powerState -> - viewModel.addSocketPowerCondition(defaultSocketName, powerState) + onAddSocket = { devName, powerState -> + viewModel.addSocketPowerCondition(devName, powerState) showConditionDialog = false }, onAddTimeRange = { start, end -> @@ -599,26 +636,18 @@ private fun TriggerItemRow(trigger: SceneTrigger, onRemove: () -> Unit) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), ) { Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - val (icon, desc) = when (trigger) { - is SceneTrigger.Time -> Pair("⏰", stringResource(R.string.scene_trigger_type_time) + ": ${trigger.timeOfDay}") - is SceneTrigger.Temperature -> Pair("🌡️", stringResource(R.string.scene_trigger_desc_temp, trigger.operator.symbol, trigger.thresholdCelsius.toInt())) - is SceneTrigger.Humidity -> Pair("💧", stringResource(R.string.scene_trigger_desc_humidity, trigger.operator.symbol, trigger.thresholdPercent.toInt())) - is SceneTrigger.DeviceState -> Pair("🔌", stringResource(R.string.scene_trigger_desc_device, trigger.deviceName.value, trigger.expectedState)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text(icon, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.width(8.dp)) - Text(desc, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - } - IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { - Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(trigger.describe(), style = MaterialTheme.typography.bodyMedium) + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_delete_confirm), modifier = Modifier.size(16.dp)) } } } @@ -629,43 +658,36 @@ private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) { Surface( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(10.dp), - color = MaterialTheme.colorScheme.surface, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), ) { Row( - modifier = Modifier.padding(12.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { - val (icon, desc) = when (condition) { - is SceneCondition.Temperature -> Pair("🌡️", stringResource(R.string.scene_condition_desc_temp, condition.operator.symbol, condition.thresholdCelsius.toInt())) - is SceneCondition.Humidity -> Pair("💧", stringResource(R.string.scene_condition_desc_humidity, condition.operator.symbol, condition.thresholdPercent.toInt())) - is SceneCondition.SocketPower -> Pair("🔌", stringResource(R.string.scene_condition_desc_socket, condition.deviceName.value, condition.expectedState.name)) - is SceneCondition.TimeRange -> Pair("🕒", stringResource(R.string.scene_condition_desc_time, condition.startTime, condition.endTime)) - } - Row(verticalAlignment = Alignment.CenterVertically) { - Text(icon, style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.width(8.dp)) - Text(desc, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - } - IconButton(onClick = onRemove, modifier = Modifier.size(24.dp)) { - Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(16.dp)) + Text(condition.describe(), style = MaterialTheme.typography.bodyMedium) + IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.scene_delete_confirm), modifier = Modifier.size(16.dp)) } } } } @Composable -private fun ActionCardRow( +private fun ActionItemRow( + stepNumber: Int, action: SceneAction, - index: Int, - total: Int, - onMoveUp: (Int) -> Unit, - onMoveDown: (Int) -> Unit, + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, onRemove: () -> Unit, ) { OutlinedCard( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(14.dp), colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), ) { @@ -681,87 +703,78 @@ private fun ActionCardRow( modifier = Modifier.size(28.dp), ) { Box(contentAlignment = Alignment.Center) { - Text("${index + 1}", style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold) + Text( + text = stepNumber.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) } } - Spacer(Modifier.width(10.dp)) + Spacer(Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { - val title = when (action) { - is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, action.deviceName.value, action.powerState.name) - is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, action.remoteDisplayName, action.buttonDisplayName) - is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, action.deviceName.value, action.key) - is SceneAction.Delay -> stringResource(R.string.scene_action_delay, action.durationSeconds) - is SceneAction.Notification -> stringResource(R.string.scene_action_notification, action.title) + Text( + text = action.describe(), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + } + Row(verticalAlignment = Alignment.CenterVertically) { + if (canMoveUp) { + IconButton(onClick = onMoveUp, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.KeyboardArrowUp, contentDescription = null, modifier = Modifier.size(18.dp)) + } } - Text(text = title, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) - } - IconButton(onClick = { onMoveUp(index) }, enabled = index > 0, modifier = Modifier.size(32.dp)) { - Text("↑", style = MaterialTheme.typography.titleMedium) - } - IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1, modifier = Modifier.size(32.dp)) { - Text("↓", style = MaterialTheme.typography.titleMedium) - } - IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { - Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun IconPickerRow(selectedIcon: SceneIcon, onSelect: (SceneIcon) -> Unit) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - SceneIcon.entries.forEach { icon -> - Surface( - shape = RoundedCornerShape(8.dp), - color = if (selectedIcon == icon) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - modifier = Modifier - .size(40.dp) - .clip(RoundedCornerShape(8.dp)) - .clickable { onSelect(icon) } - .border( - width = if (selectedIcon == icon) 2.dp else 1.dp, - color = if (selectedIcon == icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant, - shape = RoundedCornerShape(8.dp), - ), - ) { - Box(contentAlignment = Alignment.Center) { - Text(icon.displayEmoji(), style = MaterialTheme.typography.titleMedium) + if (canMoveDown) { + IconButton(onClick = onMoveDown, modifier = Modifier.size(32.dp)) { + Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null, modifier = Modifier.size(18.dp)) + } + } + IconButton(onClick = onRemove, modifier = Modifier.size(32.dp)) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.scene_delete_confirm), + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp), + ) } } } } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun ColorPickerRow(selectedColorHex: String, onSelect: (String) -> Unit) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - PRESET_COLORS.forEach { hex -> - val color = SceneColor(hex).toColorOrNull() ?: Color.Gray - Box( - modifier = Modifier - .size(34.dp) - .background(color, shape = CircleShape) - .clip(CircleShape) - .clickable { onSelect(hex) } - .border( - width = if (selectedColorHex.equals(hex, ignoreCase = true)) 3.dp else 1.dp, - color = if (selectedColorHex.equals(hex, ignoreCase = true)) MaterialTheme.colorScheme.onSurface else Color.Transparent, - shape = CircleShape, - ), - ) - } - } +private fun SceneTrigger.describe(): String = when (this) { + 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) +} + +@Composable +private fun SceneCondition.describe(): String = when (this) { + is SceneCondition.Temperature -> stringResource(R.string.scene_condition_desc_temp, operator.symbol, thresholdCelsius.toInt().toString()) + is SceneCondition.Humidity -> stringResource(R.string.scene_condition_desc_humidity, operator.symbol, thresholdPercent.toInt().toString()) + is SceneCondition.SocketPower -> stringResource(R.string.scene_condition_desc_socket, deviceName.value, expectedState.name) + is SceneCondition.TimeRange -> stringResource(R.string.scene_condition_desc_time, startTime, endTime) +} + +@Composable +private fun SceneAction.describe(): String = when (this) { + is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name) + is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName) + is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key) + is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds) + is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title) } @Composable private fun AddTriggerDialog( onDismiss: () -> Unit, - onAdd: (type: Int, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, + onAdd: (type: Int, deviceName: String, time: String, temp: Double, hum: Double, op: ComparisonOperator) -> Unit, ) { var selectedType by remember { mutableIntStateOf(0) } + var deviceName by remember { mutableStateOf(DEFAULT_TH_DEVICE_NAME) } var timeInput by remember { mutableStateOf("07:30") } var tempThreshold by remember { mutableDoubleStateOf(28.0) } var humThreshold by remember { mutableDoubleStateOf(40.0) } @@ -769,6 +782,7 @@ 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() AlertDialog( onDismissRequest = onDismiss, @@ -795,6 +809,13 @@ 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(), + ) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) }) FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) }) @@ -811,9 +832,9 @@ private fun AddTriggerDialog( } }, confirmButton = { - val canConfirm = selectedType != 0 || isTimeValid + val canConfirm = (selectedType != 0 || isTimeValid) && isDeviceValid Button( - onClick = { onAdd(selectedType, timeInput.trim(), tempThreshold, humThreshold, operator) }, + onClick = { onAdd(selectedType, deviceName.trim(), timeInput.trim(), tempThreshold, humThreshold, operator) }, enabled = canConfirm, ) { Text(stringResource(R.string.scene_add_action)) @@ -828,12 +849,15 @@ private fun AddTriggerDialog( @Composable private fun AddConditionDialog( onDismiss: () -> Unit, - onAddTemp: (ComparisonOperator, Double) -> Unit, - onAddHumidity: (ComparisonOperator, Double) -> Unit, - onAddSocket: (SocketPowerState) -> Unit, + onAddTemp: (deviceName: String, ComparisonOperator, Double) -> Unit, + onAddHumidity: (deviceName: String, ComparisonOperator, Double) -> Unit, + onAddSocket: (deviceName: String, SocketPowerState) -> Unit, 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 operator by remember { mutableStateOf(ComparisonOperator.GREATER_THAN) } var tempVal by remember { mutableDoubleStateOf(26.0) } var humVal by remember { mutableDoubleStateOf(50.0) } @@ -846,6 +870,13 @@ private fun AddConditionDialog( val isEndTimeValid = timeRegex.matches(endTime.trim()) val isTimeRangeValid = isStartTimeValid && isEndTimeValid + val isDeviceValid = when (selectedType) { + 0 -> tempDeviceName.isNotBlank() + 1 -> humDeviceName.isNotBlank() + 2 -> socketDeviceName.isNotBlank() + else -> true + } + AlertDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) }, @@ -859,6 +890,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)) { 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)) }) @@ -867,6 +905,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)) { 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)) }) @@ -875,6 +920,13 @@ 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_target_socket_state)) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) }) @@ -909,13 +961,13 @@ private fun AddConditionDialog( } }, confirmButton = { - val canConfirm = selectedType != 3 || isTimeRangeValid + val canConfirm = (selectedType != 3 || isTimeRangeValid) && isDeviceValid Button( onClick = { when (selectedType) { - 0 -> onAddTemp(operator, tempVal) - 1 -> onAddHumidity(operator, humVal) - 2 -> onAddSocket(socketPower) + 0 -> onAddTemp(tempDeviceName.trim(), operator, tempVal) + 1 -> onAddHumidity(humDeviceName.trim(), operator, humVal) + 2 -> onAddSocket(socketDeviceName.trim(), socketPower) 3 -> onAddTimeRange(startTime.trim(), endTime.trim()) } }, @@ -941,13 +993,11 @@ private fun AddActionDialog( onAddNotify: (title: String, msg: String) -> Unit, ) { var actionType by remember { mutableIntStateOf(0) } - val defaultSocketName = stringResource(R.string.scene_default_socket_name) - val defaultIrName = stringResource(R.string.scene_default_ir_name) val defaultPowerBtn = stringResource(R.string.scene_default_button_power) val defaultNotifyTitle = stringResource(R.string.scene_default_notify_title) val defaultNotifyMsg = stringResource(R.string.scene_default_notify_msg) - var socketName by remember { mutableStateOf(defaultSocketName) } + var socketName by remember { mutableStateOf(DEFAULT_SOCKET_DEVICE_NAME) } var socketPower by remember { mutableStateOf(SocketPowerState.ON) } var selectedRemoteIndex by remember { mutableIntStateOf(0) } @@ -1055,21 +1105,30 @@ private fun AddActionDialog( } }, confirmButton = { - Button(onClick = { - when (actionType) { - 0 -> onAddSocket(socketName, socketPower) - 1 -> { - val remote = availableRemotes.getOrNull(selectedRemoteIndex) - if (remote != null) { - onAddRemote(remote.id.value, remote.displayName, selectedButtonId, selectedButtonName) - } else { - onAddRemote("default-ir", defaultIrName, selectedButtonId, selectedButtonName) + val canConfirm = when (actionType) { + 0 -> socketName.isNotBlank() + 1 -> selectedButtonId.isNotBlank() + 3 -> notifyTitle.isNotBlank() + else -> true + } + Button( + onClick = { + when (actionType) { + 0 -> onAddSocket(socketName.trim(), socketPower) + 1 -> { + 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) + 3 -> onAddNotify(notifyTitle.trim(), notifyMsg.trim()) } - 2 -> onAddDelay(delaySeconds) - 3 -> onAddNotify(notifyTitle, notifyMsg) - } - }) { + }, + enabled = canConfirm, + ) { Text(stringResource(R.string.scene_add_action)) } }, diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt index 17d5dcf..e38d4ee 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -50,11 +50,12 @@ data class SceneEditorState( val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL, val enableLogging: Boolean = true, val availableRemotes: List = emptyList(), + val isLoading: Boolean = false, val isSaving: Boolean = false, val isSaved: Boolean = false, val errorMessage: String? = null, ) { - val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty()) + val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty()) val isEditing: Boolean get() = sceneId != null } @@ -100,14 +101,24 @@ class SceneEditorViewModel @Inject constructor( type = initialType ?: AutomationType.MANUAL, colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5", availableRemotes = it.availableRemotes, + isLoading = false, ) } return } + + _state.update { + it.copy( + sceneId = sceneId, + isLoading = true, + errorMessage = null, + ) + } + viewModelScope.launch { val profile = profileStore.activeProfile() if (profile == null) { - _state.update { it.copy(errorMessage = "No active profile") } + _state.update { it.copy(isLoading = false, errorMessage = "No active profile") } return@launch } val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value @@ -127,10 +138,11 @@ class SceneEditorViewModel @Inject constructor( strategy = scene.strategy, concurrencyMode = scene.concurrencyMode, enableLogging = scene.enableLogging, + isLoading = false, ) } } else { - _state.update { it.copy(errorMessage = "Scene not found") } + _state.update { it.copy(isLoading = false, errorMessage = "Scene not found") } } } } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 257617c..89d8729 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -196,8 +196,9 @@ 湿度需 %1$s %2$s%% %1$s 处于 %2$s 时间在 %1$s ~ %2$s 之间 - Zigbee插座控制器 - 红外控制器 + 设备名称(网关配对标识) + 正在加载情景配置… + 无法加载情景配置。 电源 情景完成 自动化任务执行完成 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ec69aa7..4745798 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -196,8 +196,9 @@ Humidity must be %1$s %2$s%% %1$s is %2$s Time is between %1$s ~ %2$s - Zigbee Smart Socket - Infrared Controller + Device Name (Gateway Identifier) + Loading scene... + Failed to load scene. Power Scene Completed Automation task executed successfully diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt index 863c6fa..3458c11 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -81,10 +81,18 @@ class SceneEditorViewModelTest { setupProfile() val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) viewModel.initialize(existingScene.id) + + // Immediately binds sceneId and enters loading state + assertTrue(viewModel.state.value.isEditing) + assertEquals(existingScene.id, viewModel.state.value.sceneId) + assertTrue(viewModel.state.value.isLoading) + assertFalse(viewModel.state.value.isValid) + advanceUntilIdle() val state = viewModel.state.value assertTrue(state.isEditing) + assertFalse(state.isLoading) assertEquals("Existing Scene", state.name) assertEquals(1, state.actions.size) assertTrue(state.isValid) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt index 127a5bd..d0111c9 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -4,6 +4,7 @@ import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -36,15 +37,48 @@ class DefaultSceneExecutor( return try { val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { - coroutineScope { - scene.actions.map { action -> - async { - val actionStart = System.currentTimeMillis() - val execResult = executeAction(scene.profileId, action) - val duration = System.currentTimeMillis() - actionStart - execResult.copy(executionDurationMillis = duration) + if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + coroutineScope { + lateinit var deferreds: List> + deferreds = scene.actions.mapIndexed { index, action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enriched = execResult.copy(executionDurationMillis = duration) + if (enriched.outcome == SceneActionOutcome.FAILURE) { + deferreds.forEachIndexed { otherIndex, otherDeferred -> + if (otherIndex != index) { + otherDeferred.cancel() + } + } + } + enriched + } } - }.awaitAll() + deferreds.mapIndexed { index, deferred -> + try { + deferred.await() + } catch (e: CancellationException) { + SceneActionExecutionResult( + action = scene.actions[index], + outcome = SceneActionOutcome.SKIPPED, + error = null, + ) + } + } + } + } else { + coroutineScope { + scene.actions.map { action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + execResult.copy(executionDurationMillis = duration) + } + }.awaitAll() + } } } else { val sequentialResults = mutableListOf() diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt index ae7a092..0dd2f3a 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt @@ -308,5 +308,45 @@ class DefaultSceneExecutorTest { ) executor.execute(scene) } + + @Test + fun parallelAbortOnErrorCancelsPendingActions() = runTest { + val socketGateway = TestSocketGateway( + response = DomainResult.Failure(DomainError.OperationFailed(detail = "Socket failed")), + ) + val executor = DefaultSceneExecutor( + socketGateway = socketGateway, + infraredGateway = TestInfraredGateway(), + delayDurationProvider = { + kotlinx.coroutines.delay(1000) + }, + ) + val action1 = SceneAction.SocketPower( + id = SceneActionId("a1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ) + val action2 = SceneAction.Delay( + id = SceneActionId("a2"), + profileId = profileId, + durationSeconds = 10, + ) + val scene = Scene( + id = SceneId("s-par-abort"), + profileId = profileId, + name = "Parallel Abort Scene", + concurrencyMode = ConcurrencyMode.PARALLEL, + strategy = ExecutionStrategy.ABORT_ON_ERROR, + actions = listOf(action1, action2), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome) + } } diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt index 81c5bf8..ab94b5a 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -22,6 +22,7 @@ import com.flagship.abox.manager.domain.SocketGateway import com.flagship.abox.manager.domain.SocketPowerState import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope @@ -227,15 +228,48 @@ class FakeSceneExecutor( return try { val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { - coroutineScope { - scene.actions.map { action -> - async { - val actionStart = System.currentTimeMillis() - val execResult = executeAction(scene.profileId, action) - val duration = System.currentTimeMillis() - actionStart - execResult.copy(executionDurationMillis = duration) + if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) { + coroutineScope { + lateinit var deferreds: List> + deferreds = scene.actions.mapIndexed { index, action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + val enriched = execResult.copy(executionDurationMillis = duration) + if (enriched.outcome == SceneActionOutcome.FAILURE) { + deferreds.forEachIndexed { otherIndex, otherDeferred -> + if (otherIndex != index) { + otherDeferred.cancel() + } + } + } + enriched + } } - }.awaitAll() + deferreds.mapIndexed { index, deferred -> + try { + deferred.await() + } catch (e: CancellationException) { + SceneActionExecutionResult( + action = scene.actions[index], + outcome = SceneActionOutcome.SKIPPED, + error = null, + ) + } + } + } + } else { + coroutineScope { + scene.actions.map { action -> + async { + val actionStart = System.currentTimeMillis() + val execResult = executeAction(scene.profileId, action) + val duration = System.currentTimeMillis() - actionStart + execResult.copy(executionDurationMillis = duration) + } + }.awaitAll() + } } } else { val sequentialResults = mutableListOf() diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt index b2cb7d5..4f83b35 100644 --- a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt @@ -1,8 +1,10 @@ package com.flagship.abox.manager.testing.fakes +import com.flagship.abox.manager.domain.ConcurrencyMode import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.DomainError import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ExecutionStrategy import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.Scene import com.flagship.abox.manager.domain.SceneAction @@ -156,6 +158,44 @@ class FakeSceneExecutorTest { assertEquals(listOf(notificationAction), executor.sentNotifications) } + @Test + fun parallelAbortOnErrorCancelsPendingActions() = runTest { + val socketGateway = FakeSocketGateway() + socketGateway.setError( + profileId, + DeviceName("socket-1"), + DomainError.DeviceOffline(detail = "offline"), + ) + val executor = FakeSceneExecutor( + socketGateway = socketGateway, + infraredGateway = FakeInfraredGateway(), + delayDurationProvider = { kotlinx.coroutines.delay(1000) }, + ) + + val action1 = socketAction("socket-1", SocketPowerState.ON) + val action2 = SceneAction.Delay( + id = SceneActionId("abox:delay-1"), + profileId = profileId, + durationSeconds = 10, + ) + + val scene = Scene( + id = SceneId("${profileId.value}:scene-par-abort"), + profileId = profileId, + name = "Parallel Abort Scene", + concurrencyMode = ConcurrencyMode.PARALLEL, + strategy = ExecutionStrategy.ABORT_ON_ERROR, + actions = listOf(action1, action2), + ) + + val result = executor.execute(scene) + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome) + } + private fun scene(actions: List): Scene = Scene( id = SceneId("${profileId.value}:scene-test"), profileId = profileId, -- 2.54.0