From 1570a95f30c54f89d73d62a6b9d00c8c53213375 Mon Sep 17 00:00:00 2001 From: tzh <3091898014@qq.com> Date: Tue, 25 Aug 2026 20:20:18 +0800 Subject: [PATCH] feat(scene): add scene editor, executor and fakes (phase 1) (#5) ## Summary Adds scene integration phase 1: domain model, fakes, UI, navigation, and tests. ## Changes - **domain**: Add `Scene`, `SceneAction` (SocketPower/InfraredKey), `SceneRepository`, `SceneExecutor`, and execution result models. - **testing/fakes**: Add `FakeSceneRepository` (isolated by `ProviderProfileId`, seeded defaults for the local profile) and `FakeSceneExecutor` (sequential execution, continue-on-failure). - **android/app**: Add scene UI module - list screen, editor screen, execution progress screen - with Hilt-bound ViewModels. - **navigation**: Wire scene routes into `ABoxManagerApp` NavHost. - **strings**: Add `scene_*` entries for en and zh-rCN. - **tests**: Cover `FakeSceneRepository` CRUD/isolation and `FakeSceneExecutor` ordering/continue-on-failure. ## Test plan - [x] `./gradlew :testing:fakes:test` passes. - [x] `FakeSceneRepository` isolates scenes by provider profile. - [x] `FakeSceneExecutor` runs actions in order and continues on failure. ## Notes Phase 1 ships fakes only; real repository/executor backed by the abox gateway will land in a follow-up. --------- Co-authored-by: KremeCN <3265494849@qq.com> Co-authored-by: mxhsnd <2049295946@qq.com> Reviewed-on: https://git.kimico.de/Flagship/abox-mgr/pulls/5 Co-authored-by: tzh <3091898014@qq.com> --- .../abox/manager/di/SceneIntegrationModule.kt | 37 + .../abox/manager/ui/ABoxManagerApp.kt | 19 + .../manager/ui/scene/SceneEditorScreen.kt | 1139 +++++++++++++++++ .../manager/ui/scene/SceneEditorViewModel.kt | 385 ++++++ .../manager/ui/scene/SceneExecutionScreen.kt | 345 +++++ .../ui/scene/SceneExecutionViewModel.kt | 83 ++ .../abox/manager/ui/scene/SceneListScreen.kt | 417 ++++++ .../manager/ui/scene/SceneListViewModel.kt | 115 ++ .../abox/manager/ui/scene/SceneNavigation.kt | 119 ++ .../src/main/res/values-zh-rCN/strings.xml | 161 +++ android/app/src/main/res/values/strings.xml | 161 +++ .../ui/scene/SceneEditorViewModelTest.kt | 204 +++ .../ui/scene/SceneExecutionViewModelTest.kt | 125 ++ .../ui/scene/SceneListViewModelTest.kt | 170 +++ domain/build.gradle.kts | 1 + .../manager/domain/DefaultSceneExecutor.kt | 211 +++ .../manager/domain/InMemorySceneRepository.kt | 179 +++ .../com/flagship/abox/manager/domain/Scene.kt | 204 +++ .../abox/manager/domain/SceneExecution.kt | 36 + .../abox/manager/domain/SceneExecutor.kt | 12 + .../abox/manager/domain/SceneRepository.kt | 34 + .../domain/DefaultSceneExecutorTest.kt | 352 +++++ .../domain/InMemorySceneRepositoryTest.kt | 128 ++ gradle.properties | 2 +- settings.gradle.kts | 4 + .../testing/fakes/FakeSceneRepository.kt | 391 ++++++ .../testing/fakes/FakeSceneExecutorTest.kt | 247 ++++ .../testing/fakes/FakeSceneRepositoryTest.kt | 191 +++ 28 files changed, 5471 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.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 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/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 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 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/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..24df299 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/di/SceneIntegrationModule.kt @@ -0,0 +1,37 @@ +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 com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +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, + 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 f7bb480..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 @@ -32,6 +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.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 @@ -124,11 +128,26 @@ private fun AuthenticatedNavHost( }, ) }, + scenesSlot = { + SceneListScreen( + onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) }, + onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) }, + onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, + onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, + ) + }, ) } deviceDetailGraph( onBackClick = navController::popBackStack, ) + 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/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt new file mode 100644 index 0000000..314c4d7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -0,0 +1,1139 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.compose.foundation.BorderStroke +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.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.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.Settings +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +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 +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 +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.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.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.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 + +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 +fun SceneEditorScreen( + sceneId: SceneId?, + initialType: AutomationType? = null, + onBack: () -> Unit, + onSaved: () -> Unit, + viewModel: SceneEditorViewModel = hiltViewModel(), +) { + 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( + title = { + Text( + stringResource( + if (state.isEditing) R.string.scene_editor_edit_title + else if (state.type == AutomationType.AUTOMATIC) R.string.scene_editor_auto_title + else R.string.scene_editor_manual_title, + ), + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) + } + }, + actions = { + IconButton( + onClick = viewModel::save, + enabled = state.isValid && !state.isSaving && !state.isLoading, + ) { + Icon( + Icons.Filled.Check, + contentDescription = stringResource(R.string.scene_save_action), + tint = if (state.isValid && !state.isSaving && !state.isLoading) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + ) + } + }, + ) + }, + ) { innerPadding -> + 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), + ) + } + } + } + + 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), + 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.Tune, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text( + stringResource(R.string.scene_section_conditions), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + "${state.conditions.size}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + 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 = { showConditionDialog = 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_condition)) + } + } + } + } + + 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( + stringResource(R.string.scene_section_actions), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + Text( + stringResource(R.string.scene_action_count, state.actions.size), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (state.actions.isEmpty()) { + item(key = "actions_empty") { + 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)), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Text( + stringResource(R.string.scene_empty_actions_hint), + style = MaterialTheme.typography.bodyMedium, + 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) }, + ) + } + } + + 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 = "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), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + + 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, + 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)) }, + ) + } + } + + 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, + 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)) }, + ) + } + } + + 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 && !state.isLoading, + modifier = Modifier + .fillMaxWidth() + .height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.scene_save_action), fontWeight = FontWeight.Bold) + } + } + } + } + } + + if (showTriggerDialog) { + AddTriggerDialog( + onDismiss = { showTriggerDialog = false }, + onAdd = { triggerType, devName, time, temp, hum, op -> + when (triggerType) { + 0 -> viewModel.addTimeTrigger(time) + 1 -> viewModel.addTemperatureTrigger(devName, op, temp) + 2 -> viewModel.addHumidityTrigger(devName, op, hum) + } + showTriggerDialog = false + }, + ) + } + + if (showConditionDialog) { + AddConditionDialog( + onDismiss = { showConditionDialog = false }, + onAddTemp = { devName, op, temp -> + viewModel.addTemperatureCondition(devName, op, temp) + showConditionDialog = false + }, + onAddHumidity = { devName, op, hum -> + viewModel.addHumidityCondition(devName, op, hum) + showConditionDialog = false + }, + onAddSocket = { devName, powerState -> + viewModel.addSocketPowerCondition(devName, 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.surfaceVariant.copy(alpha = 0.4f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + 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)) + } + } + } +} + +@Composable +private fun ConditionItemRow(condition: SceneCondition, onRemove: () -> Unit) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + 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 ActionItemRow( + stepNumber: Int, + action: SceneAction, + canMoveUp: Boolean, + canMoveDown: Boolean, + onMoveUp: () -> Unit, + onMoveDown: () -> Unit, + onRemove: () -> Unit, +) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(14.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(28.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = stepNumber.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Spacer(Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + 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)) + } + } + 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), + ) + } + } + } + } +} + +@Composable +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, 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) } + 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()) + val isDeviceValid = selectedType == 0 || deviceName.isNotBlank() + + 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(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 -> { + OutlinedTextField( + value = timeInput, + 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 -> { + 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)) }) + } + if (selectedType == 1) { + Text(stringResource(R.string.scene_threshold_temp, tempThreshold.toInt())) + Slider(value = tempThreshold.toFloat(), onValueChange = { tempThreshold = it.toDouble() }, valueRange = 10f..40f) + } else { + Text(stringResource(R.string.scene_threshold_hum, humThreshold.toInt())) + Slider(value = humThreshold.toFloat(), onValueChange = { humThreshold = it.toDouble() }, valueRange = 10f..90f) + } + } + } + } + }, + confirmButton = { + val canConfirm = (selectedType != 0 || isTimeValid) && isDeviceValid + Button( + onClick = { onAdd(selectedType, deviceName.trim(), timeInput.trim(), tempThreshold, humThreshold, operator) }, + enabled = canConfirm, + ) { + 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: (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) } + var socketPower by remember { mutableStateOf(SocketPowerState.OFF) } + 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 + + 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)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + 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 -> { + 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)) }) + } + Text(stringResource(R.string.scene_threshold_temp, tempVal.toInt())) + 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)) }) + } + Text(stringResource(R.string.scene_threshold_hum, humVal.toInt())) + 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)) }) + 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, + 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 = { + val canConfirm = (selectedType != 3 || isTimeRangeValid) && isDeviceValid + Button( + onClick = { + when (selectedType) { + 0 -> onAddTemp(tempDeviceName.trim(), operator, tempVal) + 1 -> onAddHumidity(humDeviceName.trim(), operator, humVal) + 2 -> onAddSocket(socketDeviceName.trim(), socketPower) + 3 -> onAddTimeRange(startTime.trim(), endTime.trim()) + } + }, + enabled = canConfirm, + ) { + 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) } + 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(DEFAULT_SOCKET_DEVICE_NAME) } + 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(defaultPowerBtn) } + + var delaySeconds by remember { mutableIntStateOf(3) } + var notifyTitle by remember { mutableStateOf(defaultNotifyTitle) } + var notifyMsg by remember { mutableStateOf(defaultNotifyMsg) } + + LaunchedEffect(availableRemotes, selectedRemoteIndex) { + if (availableRemotes.isNotEmpty() && selectedRemoteIndex in availableRemotes.indices) { + val remote = availableRemotes[selectedRemoteIndex] + 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 = defaultPowerBtn + } + } else { + keyBindings = emptyList() + } + } + + 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(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 -> { + 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(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(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)) { + 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(stringResource(R.string.scene_remote_button_name_label)) }, singleLine = true) + } + } + } + 2 -> { + Text(stringResource(R.string.scene_delay_duration_label) + ": " + stringResource(R.string.scene_unit_seconds, 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 = { + 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()) + } + }, + enabled = canConfirm, + ) { + 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 new file mode 100644 index 0000000..e38d4ee --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -0,0 +1,385 @@ +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 = "#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 isLoading: Boolean = false, + val isSaving: Boolean = false, + val isSaved: Boolean = false, + val errorMessage: String? = null, +) { + val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty()) + val isEditing: Boolean get() = sceneId != null +} + +@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() + + private var initializedSceneId: SceneId? = null + private var isInitialized: Boolean = false + + 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 (isInitialized && initializedSceneId == sceneId) return + isInitialized = true + initializedSceneId = sceneId + + if (sceneId == null) { + _state.update { + SceneEditorState( + 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(isLoading = false, errorMessage = "No active profile") } + return@launch + } + val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value + if (scene != null) { + _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, + isLoading = false, + ) + } + } else { + _state.update { it.copy(isLoading = false, errorMessage = "Scene not found") } + } + } + } + + 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 { + 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 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 { + 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 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 }) } + + 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) } + } + + 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 + _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("${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) + } else { + sceneRepository.createScene(profile.id, 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..b288953 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -0,0 +1,345 @@ +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 +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.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.OutlinedCard +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 +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 +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: 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), fontWeight = FontWeight.SemiBold) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + } + }, + ) + }, + ) { innerPadding -> + when (val state = uiState) { + SceneExecutionUiState.Loading -> CenteredLoading(innerPadding) + 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, onRerun = viewModel::execute) + } + } +} + +@Composable +private fun CenteredLoading(paddingValues: PaddingValues) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) { + 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) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Icon(Icons.Filled.Refresh, contentDescription = null) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.action_retry)) + } + } +} + +@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, fontWeight = FontWeight.Bold) + Spacer(Modifier.height(8.dp)) + Text(stringResource(R.string.scene_exec_ready_hint), 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) + } + } +} + +@Composable +private fun RunningContent(paddingValues: PaddingValues, sceneName: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator(modifier = Modifier.size(56.dp)) + Spacer(Modifier.height(20.dp)) + Text( + stringResource(R.string.scene_execution_running, sceneName), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun CompletedContent( + paddingValues: PaddingValues, + result: SceneExecutionResult, + onBack: () -> Unit, + onRerun: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item(key = "summary") { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.outlinedCardColors( + containerColor = if (result.allSucceeded) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + 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( + 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, + ) + } + } + } + } + + itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry -> + ActionResultRow(index + 1, entry) + } + + 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(stringResource(R.string.scene_exec_rerun)) + } + Button( + onClick = onBack, + modifier = Modifier.weight(1f).height(48.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text(stringResource(R.string.action_back), fontWeight = FontWeight.SemiBold) + } + } + } + } +} + +@Composable +private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Surface( + shape = CircleShape, + color = when (entry.outcome) { + SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer + SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer + SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant + }, + 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 = stringResource(R.string.scene_step_format, 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, + color = MaterialTheme.colorScheme.error, + ) + } + } + if (entry.executionDurationMillis > 0) { + Text( + text = "${entry.executionDurationMillis}ms", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@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) +} + 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..8bc1c00 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -0,0 +1,83 @@ +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.ProviderProfileStore +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, + private val profileStore: ProviderProfileStore, +) : ViewModel() { + + 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() + 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 { + SceneExecutionUiState.Failed("Scene not found") + } + } + } + + fun execute() { + val current = _uiState.value + 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( + 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..5560346 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -0,0 +1,417 @@ +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 +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.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.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.OutlinedCard +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.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( + 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( + containerColor = Color.Transparent, + floatingActionButton = { + Box { + ExtendedFloatingActionButton( + onClick = { showFabMenu = true }, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.scene_create_action)) }, + ) + DropdownMenu( + expanded = showFabMenu, + onDismissRequest = { showFabMenu = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.scene_create_manual)) }, + leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) }, + onClick = { + showFabMenu = false + onAddManual() + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.scene_create_automatic)) }, + leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) }, + onClick = { + showFabMenu = false + onAddAutomatic() + }, + ) + } + } + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = 12.dp, + bottom = innerPadding.calculateBottomPadding() + 80.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (headerContent != null) { + item(key = "header") { + headerContent() + } + } + + item(key = "tabs") { + SecondaryTabRow( + selectedTabIndex = selectedTab.ordinal, + containerColor = Color.Transparent, + divider = {}, + 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 -> + SceneCard( + scene = scene, + 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) }, + ) + } + } + } + } + + 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), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = viewModel::cancelDelete) { + Text(stringResource(R.string.scene_delete_cancel)) + } + }, + ) + } +} + +@Composable +private fun SceneCard( + scene: Scene, + onClick: () -> Unit, + onQuickExecute: () -> Unit, + onToggleEnabled: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + 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.padding(16.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Box( + modifier = Modifier + .size(44.dp) + .background( + color = accentColor.copy(alpha = 0.12f), + 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() }, + ) + } + } + + Spacer(Modifier.height(12.dp)) + + // Trigger & Action summary and controls + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + // Summary text without nested heavy container shells + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (scene.type == AutomationType.AUTOMATIC) { + Text( + 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 = stringResource(R.string.scene_summary_actions_only, scene.actions.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (scene.type == AutomationType.MANUAL) { + FilledTonalButton( + onClick = onQuickExecute, + 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)) + 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), 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), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } +} + +@Composable +private fun TypeBadge(type: AutomationType) { + 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 = 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 = 5.dp, vertical = 1.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun EmptySceneList(modifier: Modifier = Modifier) { + Column( + 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(8.dp)) + Text( + text = stringResource(R.string.scene_empty_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +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 new file mode 100644 index 0000000..22f2e39 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -0,0 +1,115 @@ +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() { + + 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())) + } else { + sceneRepository.observeScenes(profile.id) + } + } + .map { result -> (result as? DomainResult.Success)?.value.orEmpty() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + 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 + sceneRepository.deleteScene(profile.id, 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..a94bfaf --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -0,0 +1,119 @@ +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.AutomationType +import com.flagship.abox.manager.domain.SceneId + +/** + * 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}" + + fun editorFor(sceneId: SceneId): String = "scene/editor/${sceneId.value}" + 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( + 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)) }, + ) + sceneEditorDestination( + onBack = { navController.popBackStack() }, + onSaved = { + navController.popBackStack(SceneRoutes.LIST, inclusive = false) + }, + ) + sceneExecutionDestination( + onBack = { navController.popBackStack() }, + ) + } +} + +fun NavGraphBuilder.sceneListDestination( + onAddManual: () -> Unit, + onAddAutomatic: () -> Unit, + onEditScene: (SceneId) -> Unit, + onExecuteScene: (SceneId) -> Unit, + headerContent: @Composable (() -> Unit)? = null, +) { + composable(SceneRoutes.LIST) { + SceneListScreen( + onAddManual = onAddManual, + onAddAutomatic = onAddAutomatic, + onEditScene = onEditScene, + onExecuteScene = onExecuteScene, + headerContent = headerContent, + ) + } +} + +fun NavGraphBuilder.sceneEditorDestination( + onBack: () -> Unit, + onSaved: () -> Unit, +) { + 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, + arguments = listOf(navArgument("sceneId") { type = NavType.StringType }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getString("sceneId") + SceneEditorScreen( + sceneId = id?.let { SceneId(it) }, + initialType = null, + 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 c239cde..89d8729 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -42,6 +42,167 @@ 活动记录 未记录任何事件。 + + 自动化与情景 + 全部 + 一键指令 + 自动指令 + 建立一键指令 + 建立自动指令 + 新建指令 + %1$d 个动作 + 编辑指令 + 删除指令 + 还没有自动化指令 + 点击右下角按钮,建立一键指令或带触发器的自动指令。 + 立即执行 + 指令已触发执行 + + + 删除指令 + 确定要删除该指令吗?此操作无法撤销。 + 删除 + 取消 + + + 建立一键指令 + 建立自动指令 + 编辑指令 + 返回 + 标题 + 请输入指令标题(如:回家一键开灯、高温自动降温) + 描述 + 可填写该指令的详细说明或备注 + + + 触发器 + 条件(可选) + 动作 + 进阶选项 + 其他选项与外观 + + + + 新增触发器 + + 新增条件 + + 新增动作 + 添加 + 保存指令 + + + 重复执行策略 + 继续执行新任务 + 多指令同步策略 + 同步 (串行执行) + 异步 (并发执行) + 遇到错误时 + 继续执行后续动作 + 立即中止剩余动作 + 图示 + 图示背景色 + 启用该自动指令 + 开启指令执行记录 + + + 选择触发器类型 + ⏰ 定时时间触发 + 🌡️ 环境温度触发 + 💧 环境湿度触发 + 🔌 插座状态变更触发 + 触发时间点(如: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… + %3$d 个动作中成功 %1$d 个,失败 %2$d 个 + 执行完成 + 总耗时:%1$s ms + 排队中 + 正在执行… + 正在延时等待 (%1$d s)… + 已完成 + 执行失败 + 已跳过 + + + 时间格式无效(请按 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 之间 + 设备名称(网关配对标识) + 正在加载情景配置… + 无法加载情景配置。 + 电源 + 情景完成 + 自动化任务执行完成 + 设备列表 选择需要控制或查看状态的 ABox 终端设备。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 058d617..4745798 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -42,6 +42,167 @@ Activity Log No recorded events yet. + + Automation & Scenes + All + Manual + Automatic + New Manual Command + New Automatic Rule + New Command + %1$d 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 Command + Are you sure you want to delete this command? This cannot be undone. + Delete + Cancel + + + Create Manual Command + Create Automatic Rule + Edit Command + Back + 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 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 + + + Execute Command + Run + Running: %1$s… + %1$d succeeded, %2$d failed of %3$d actions + Execution Completed + Total Elapsed: %1$s ms + Pending + Running… + Waiting (%1$d s)… + Completed + 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 + Device Name (Gateway Identifier) + Loading scene... + Failed to load scene. + Power + Scene Completed + Automation task executed successfully + Device List Select an ABox terminal device to control or check status. 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..3458c11 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModelTest.kt @@ -0,0 +1,204 @@ +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 +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 +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 virtualRemoteRepository = FakeVirtualRemoteRepository() + 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, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) + + 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, 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) + } + + @Test + fun addingAndReorderingActionsUpdatesState() = runTest { + setupProfile() + val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) + viewModel.updateName("My New Scene") + + viewModel.addSocketAction("socket-1", SocketPowerState.ON) + viewModel.addDelayAction(3) + viewModel.addNotificationAction("Done", "Completed") + advanceUntilIdle() + + assertEquals(3, viewModel.state.value.actions.size) + assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower) + 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.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(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, virtualRemoteRepository, profileStore) + viewModel.initialize(null, AutomationType.MANUAL) + 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) + } + + @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 new file mode 100644 index 0000000..0a4e444 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModelTest.kt @@ -0,0 +1,125 @@ +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) + } + + @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/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..f5b0181 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModelTest.kt @@ -0,0 +1,170 @@ +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 +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.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 +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SceneListViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + 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 manualScene = Scene( + id = SceneId("local-experience:scene-1"), + profileId = profileId, + name = "Manual Scene", + type = AutomationType.MANUAL, + 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 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(manualScene, autoScene)) + + 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(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(manualScene)) + + val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.scenes.collect {} + } + advanceUntilIdle() + + viewModel.markPendingDelete(manualScene.id) + assertEquals(manualScene.id, viewModel.pendingDelete.value) + + viewModel.deleteScene(manualScene.id) + advanceUntilIdle() + + assertNull(viewModel.pendingDelete.value) + assertTrue(viewModel.scenes.value.isEmpty()) + } + + @Test + fun cancelDeleteClearsPendingState() = runTest { + 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/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) } + 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..d0111c9 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutor.kt @@ -0,0 +1,211 @@ +package com.flagship.abox.manager.domain + +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 +import kotlinx.coroutines.delay + +/** + * Production implementation of [SceneExecutor]. + * + * 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() + + 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", + ), + ) + } + val startTime = System.currentTimeMillis() + + return try { + val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { + 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 + } + } + 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() + 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( + SceneExecutionResult( + scene = scene, + results = results, + totalDurationMillis = totalDuration, + ), + ) + } catch (e: CancellationException) { + throw e + } finally { + runningScenes.remove(scene.id) + } + } + + private suspend fun executeAction( + profileId: ProviderProfileId, + action: SceneAction, + ): SceneActionExecutionResult { + return try { + 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( + 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, + ) + } + + 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), + ) + } + } + + 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 = 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 new file mode 100644 index 0000000..c642da1 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepository.kt @@ -0,0 +1,179 @@ +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 + * seed defaults and real-time observation. + */ +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) + + 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 = 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( + DomainError.InvalidRequest( + detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}", + ), + ) + } + val updated = scenes + scene + publish(profileId, updated) + DomainResult.Success(scene) + } + + override suspend fun updateScene( + profileId: ProviderProfileId, + scene: Scene, + ): 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) + DomainResult.Success(scene) + } + + override suspend fun deleteScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): 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) + 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) + } + + 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 = "离家模式", + 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("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("红外控制器"), + key = "1", + ), + ), + ), + Scene( + id = SceneId("${profileId.value}:scene-cooling-auto"), + profileId = profileId, + 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-cooling:socket-1"), + profileId = profileId, + deviceName = DeviceName("Zigbee插座控制器"), + powerState = SocketPowerState.ON, + ), + ), + ), + ) + + companion object { + val LOCAL_EXPERIENCE_PROFILE = ProviderProfileId("local-experience") + } +} 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..aa97602 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -0,0 +1,204 @@ +package com.flagship.abox.manager.domain + +@JvmInline +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]. + */ +sealed interface SceneAction { + val id: SceneActionId + val profileId: ProviderProfileId + val type: SceneActionType + + data class SocketPower( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + 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, + 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. + */ +enum class SceneIcon { + HOME, + OFFICE, + NIGHT, + MOVIE, + AWAY, + READING, + DINING, + GAMING, + THERMOSTAT, + LIGHT, + CLEAN, + CUSTOM, +} + +/** + * Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`). + */ +@JvmInline +value class SceneColor(val hex: String) + +data class Scene( + val id: SceneId, + val profileId: ProviderProfileId, + val name: String, + 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 new file mode 100644 index 0000000..f6267ed --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -0,0 +1,36 @@ +package com.flagship.abox.manager.domain + +enum class SceneActionOutcome { + SUCCESS, + FAILURE, + SKIPPED, +} + +/** + * 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?, + val executionDurationMillis: Long = 0L, +) + +/** + * 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 && skippedCount == 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/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..0dd2f3a --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/DefaultSceneExecutorTest.kt @@ -0,0 +1,352 @@ +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) + } + + @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) + } + + @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) + } + + @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/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..448f6c9 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/InMemorySceneRepositoryTest.kt @@ -0,0 +1,128 @@ +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 +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) + } + + @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/gradle.properties b/gradle.properties index 640f2f3..dd2a6d4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,4 +1,4 @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 3e34f95..984f670 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..ab94b5a --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -0,0 +1,391 @@ +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 +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 java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Deferred +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 +import kotlinx.coroutines.sync.withLock + +/** + * 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>>, + >() + private val mutexes = ConcurrentHashMap() + + private fun mutexFor(profileId: ProviderProfileId): Mutex = + mutexes.getOrPut(profileId) { Mutex() } + + 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 = 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( + DomainError.InvalidRequest( + detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}", + ), + ) + } + val updated = scenes + scene + publish(profileId, updated) + DomainResult.Success(scene) + } + + override suspend fun updateScene( + profileId: ProviderProfileId, + scene: Scene, + ): 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) + DomainResult.Success(scene) + } + + override suspend fun deleteScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): 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) + 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" + } + 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 + } + + 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, + * 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 + if (alreadyRunning) { + return DomainResult.Failure( + DomainError.OperationFailed( + detail = "Scene ${scene.id.value} is already running", + ), + ) + } + val startTime = System.currentTimeMillis() + + return try { + val results: List = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) { + 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 + } + } + 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() + 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( + SceneExecutionResult( + scene = scene, + results = results, + totalDurationMillis = totalDuration, + ), + ) + } catch (e: CancellationException) { + throw e + } finally { + running.remove(scene.id) + } + } + + private suspend fun executeAction( + profileId: ProviderProfileId, + action: SceneAction, + ): SceneActionExecutionResult { + return try { + when (action) { + is SceneAction.SocketPower -> { + val result = socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + toExecutionResult(action, result) + } + + is SceneAction.InfraredKey -> { + val result = infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + toExecutionResult(action, result) + } + + is SceneAction.VirtualRemoteButton -> { + executedRemoteButtons.add(action) + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + } + + 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 + } catch (e: Exception) { + SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = DomainError.OperationFailed(detail = e.message), + ) + } + } + + 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 new file mode 100644 index 0000000..4f83b35 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt @@ -0,0 +1,247 @@ +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 +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) + } + + @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) + } + + @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, + 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..ee00b6d --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt @@ -0,0 +1,191 @@ +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.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +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) + } + + @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, + ): 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, + ), + ), + ) +}