diff --git a/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt new file mode 100644 index 0000000..fb2e90f --- /dev/null +++ b/android/app/src/debug/kotlin/com/flagship/abox/manager/di/SceneModule.kt @@ -0,0 +1,41 @@ +package com.flagship.abox.manager.di + +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor +import com.flagship.abox.manager.testing.fakes.FakeSceneRepository +import com.flagship.abox.manager.ui.scene.LocalProfileId +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Phase 1 Hilt bindings for the scene module. Binds the in-memory [FakeSceneRepository] and the + * [FakeSceneExecutor] that drives the existing socket/infrared fakes. The bindings live in the + * debug source set so the release build does not ship the Fake implementation; the real provider + * bindings will land in `feat/scene-persistence` and `feat/provider-login` respectively. + */ +@Module +@InstallIn(SingletonComponent::class) +object SceneModule { + @Provides + @Singleton + fun provideSceneRepository(): SceneRepository = FakeSceneRepository() + + @Provides + @Singleton + fun provideSceneExecutor( + socketGateway: SocketGateway, + infraredGateway: InfraredGateway, + ): SceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway) + + @Provides + @Singleton + @LocalProfileId + fun provideLocalProfileId(): ProviderProfileId = FakeSceneRepository.localProfile +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index e31f830..a9c9483 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 @@ -19,9 +19,12 @@ import com.flagship.abox.manager.R import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme +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.scene.sceneListDestination private const val FOUNDATION_ROUTE = "foundation" -private const val PLACEHOLDER_ROUTE = "placeholder" @Composable fun ABoxManagerApp() { @@ -33,14 +36,22 @@ fun ABoxManagerApp() { startDestination = FOUNDATION_ROUTE, ) { composable(FOUNDATION_ROUTE) { - FoundationScreen(onContinue = { navController.navigate(PLACEHOLDER_ROUTE) }) - } - composable(PLACEHOLDER_ROUTE) { - MessageScreen( - title = stringResource(R.string.placeholder_title), - description = stringResource(R.string.placeholder_description), - ) + FoundationScreen(onContinue = { navController.navigate(SceneRoutes.LIST) }) } + sceneListDestination( + onAddScene = { navController.navigate(SceneRoutes.EDITOR) }, + onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) }, + onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) }, + ) + sceneEditorDestination( + onBack = { navController.popBackStack() }, + onSaved = { + navController.popBackStack(SceneRoutes.LIST, inclusive = false) + }, + ) + sceneExecutionDestination( + onBack = { navController.popBackStack() }, + ) } } } @@ -55,10 +66,22 @@ private fun FoundationScreen(onContinue: () -> Unit) { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - MessageScreen( - title = stringResource(R.string.foundation_title), - description = stringResource(R.string.foundation_description), - ) + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.foundation_title), + style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + ) + Text( + text = stringResource(R.string.foundation_description), + style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + ) + } ABoxButton( onClick = onContinue, modifier = Modifier @@ -69,26 +92,3 @@ private fun FoundationScreen(onContinue: () -> Unit) { } } } - -@Composable -private fun MessageScreen( - title: String, - description: String, -) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = title, - style = androidx.compose.material3.MaterialTheme.typography.headlineMedium, - textAlign = TextAlign.Center, - ) - Text( - text = description, - style = androidx.compose.material3.MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, - ) - } -} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt new file mode 100644 index 0000000..44d1730 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/LocalProfileId.kt @@ -0,0 +1,14 @@ +package com.flagship.abox.manager.ui.scene + +import javax.inject.Qualifier + +/** + * Qualifies the [com.flagship.abox.manager.domain.ProviderProfileId] used by the scene module + * while the real provider selector is still being built (see project_plan.md §7 dependency graph). + * + * Phase 2 will replace this binding with the active session's profile id observed from + * SessionGateway; the qualifier will be retired at that point. + */ +@Qualifier +@Retention(AnnotationRetention.BINARY) +annotation class LocalProfileId diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt new file mode 100644 index 0000000..ee4e407 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorScreen.kt @@ -0,0 +1,283 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDownward +import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SocketPowerState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SceneEditorScreen( + sceneId: com.flagship.abox.manager.domain.SceneId?, + onBack: () -> Unit, + onSaved: () -> Unit, + viewModel: SceneEditorViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + androidx.compose.runtime.LaunchedEffect(state.isSaved) { + if (state.isSaved) onSaved() + } + androidx.compose.runtime.LaunchedEffect(sceneId) { + viewModel.initialize(sceneId) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource( + if (state.isEditing) R.string.scene_editor_edit_title + else R.string.scene_editor_new_title, + ), + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + } + }, + ) + }, + ) { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + OutlinedTextField( + value = state.name, + onValueChange = viewModel::updateName, + label = { Text(stringResource(R.string.scene_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Text(stringResource(R.string.scene_icon_label), style = MaterialTheme.typography.titleSmall) + Spacer(Modifier.size(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SceneIcon.entries.forEach { icon -> + FilterChip( + selected = state.icon == icon, + onClick = { viewModel.updateIcon(icon) }, + label = { Text(icon.displayLabel()) }, + ) + } + } + } + item { + OutlinedTextField( + value = state.colorHex, + onValueChange = viewModel::updateColor, + label = { Text(stringResource(R.string.scene_color_label)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), + modifier = Modifier.fillMaxWidth(), + ) + } + item { + Text( + stringResource(R.string.scene_actions_label, state.actions.size), + style = MaterialTheme.typography.titleSmall, + ) + } + items(state.actions, key = { it.id.value }) { action -> + ActionRow( + action = action, + index = state.actions.indexOf(action), + total = state.actions.size, + onMoveUp = { i -> viewModel.moveAction(i, i - 1) }, + onMoveDown = { i -> viewModel.moveAction(i, i + 1) }, + onRemove = { viewModel.removeAction(action.id) }, + ) + } + item { AddSocketActionSection(viewModel) } + item { AddInfraredActionSection(viewModel) } + item { + state.errorMessage?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button( + onClick = viewModel::save, + enabled = state.isValid && !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.scene_save_action)) + } + } + } + } +} + +@Composable +private fun ActionRow( + action: SceneAction, + index: Int, + total: Int, + onMoveUp: (Int) -> Unit, + onMoveDown: (Int) -> Unit, + onRemove: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = when (action) { + is SceneAction.SocketPower -> stringResource( + R.string.scene_action_socket, + action.deviceName.value, + action.powerState.name, + ) + is SceneAction.InfraredKey -> stringResource( + R.string.scene_action_infrared, + action.deviceName.value, + action.key, + ) + }, + style = MaterialTheme.typography.bodyMedium, + ) + } + IconButton(onClick = { onMoveUp(index) }, enabled = index > 0) { + Icon(Icons.Filled.ArrowUpward, contentDescription = null) + } + IconButton(onClick = { onMoveDown(index) }, enabled = index < total - 1) { + Icon(Icons.Filled.ArrowDownward, contentDescription = null) + } + IconButton(onClick = onRemove) { + Icon(Icons.Filled.Delete, contentDescription = null) + } + } + } +} + +@Composable +private fun AddSocketActionSection(viewModel: SceneEditorViewModel) { + var deviceName by remember { mutableStateOf("") } + var powerOn by remember { mutableStateOf(true) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.scene_add_socket_action), + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = deviceName, + onValueChange = { deviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SocketPowerState.entries.forEach { state -> + FilterChip( + selected = powerOn == (state == SocketPowerState.ON), + onClick = { powerOn = state == SocketPowerState.ON }, + label = { Text(state.name) }, + ) + } + } + AssistChip( + onClick = { + viewModel.addSocketAction( + deviceName, + if (powerOn) SocketPowerState.ON else SocketPowerState.OFF, + ) + deviceName = "" + }, + label = { Text(stringResource(R.string.scene_add_action)) }, + ) + } +} + +@Composable +private fun AddInfraredActionSection(viewModel: SceneEditorViewModel) { + var deviceName by remember { mutableStateOf("") } + var key by remember { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.scene_add_infrared_action), + style = MaterialTheme.typography.titleSmall, + ) + OutlinedTextField( + value = deviceName, + onValueChange = { deviceName = it }, + label = { Text(stringResource(R.string.scene_device_name_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = key, + onValueChange = { key = it }, + label = { Text(stringResource(R.string.scene_infrared_key_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + AssistChip( + onClick = { + viewModel.addInfraredAction(deviceName, key) + deviceName = "" + key = "" + }, + label = { Text(stringResource(R.string.scene_add_action)) }, + ) + } +} + +private fun SceneIcon.displayLabel(): String = when (this) { + SceneIcon.HOME -> "Home" + SceneIcon.OFFICE -> "Office" + SceneIcon.NIGHT -> "Night" + SceneIcon.MOVIE -> "Movie" + SceneIcon.AWAY -> "Away" + SceneIcon.CUSTOM -> "Custom" +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt new file mode 100644 index 0000000..5bf3b6b --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneEditorViewModel.kt @@ -0,0 +1,136 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SocketPowerState +import dagger.hilt.android.lifecycle.HiltViewModel +import java.util.UUID +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class SceneEditorState( + val sceneId: SceneId? = null, + val name: String = "", + val icon: SceneIcon = SceneIcon.HOME, + val colorHex: String = "#FF8A65", + val actions: List = emptyList(), + val isSaving: Boolean = false, + val isSaved: Boolean = false, + val errorMessage: String? = null, +) { + val isValid: Boolean get() = name.isNotBlank() && actions.isNotEmpty() + val isEditing: Boolean get() = sceneId != null +} + +@HiltViewModel +class SceneEditorViewModel @Inject constructor( + private val sceneRepository: SceneRepository, + @LocalProfileId private val localProfileId: ProviderProfileId, +) : ViewModel() { + + private val _state = MutableStateFlow(SceneEditorState()) + val state: StateFlow = _state.asStateFlow() + + fun initialize(sceneId: SceneId?) { + if (sceneId == null) { + _state.value = SceneEditorState() + return + } + viewModelScope.launch { + val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value + if (scene != null) { + _state.value = SceneEditorState( + sceneId = scene.id, + name = scene.name, + icon = scene.icon, + colorHex = scene.color.hex, + actions = scene.actions, + ) + } else { + _state.update { it.copy(errorMessage = "Scene not found") } + } + } + } + + fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) } + + fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) } + + fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) } + + fun addSocketAction(deviceName: String, powerState: SocketPowerState) { + if (deviceName.isBlank()) return + val action = SceneAction.SocketPower( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = localProfileId, + deviceName = DeviceName(deviceName), + powerState = powerState, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + + fun addInfraredAction(deviceName: String, key: String) { + if (deviceName.isBlank() || key.isBlank()) return + val action = SceneAction.InfraredKey( + id = SceneActionId(UUID.randomUUID().toString()), + profileId = localProfileId, + deviceName = DeviceName(deviceName), + key = key, + ) + _state.update { it.copy(actions = it.actions + action, errorMessage = null) } + } + + fun removeAction(actionId: SceneActionId) = + _state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) } + + fun moveAction(from: Int, to: Int) { + val current = _state.value.actions + if (from !in current.indices || to !in current.indices || from == to) return + val updated = current.toMutableList().apply { add(to, removeAt(from)) } + _state.update { it.copy(actions = updated) } + } + + fun save() { + val state = _state.value + if (!state.isValid || state.isSaving) return + _state.update { it.copy(isSaving = true, errorMessage = null) } + viewModelScope.launch { + val scene = Scene( + id = state.sceneId ?: SceneId("${localProfileId.value}:scene-${UUID.randomUUID()}"), + profileId = localProfileId, + name = state.name.trim(), + icon = state.icon, + color = SceneColor(state.colorHex), + actions = state.actions, + ) + val result = if (state.isEditing) { + sceneRepository.updateScene(localProfileId, scene) + } else { + sceneRepository.createScene(localProfileId, scene) + } + _state.update { current -> + when (result) { + is DomainResult.Success -> current.copy(isSaving = false, isSaved = true) + is DomainResult.Failure -> current.copy( + isSaving = false, + errorMessage = result.error.detail ?: "Save failed", + ) + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt new file mode 100644 index 0000000..9630525 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionScreen.kt @@ -0,0 +1,221 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionExecutionResult +import com.flagship.abox.manager.domain.SceneActionOutcome + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SceneExecutionScreen( + sceneId: com.flagship.abox.manager.domain.SceneId, + onBack: () -> Unit, + viewModel: SceneExecutionViewModel = hiltViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(sceneId) { viewModel.initialize(sceneId) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.scene_execution_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back)) + } + }, + ) + }, + ) { innerPadding -> + when (val state = uiState) { + SceneExecutionUiState.Loading -> CenteredLoading(innerPadding) + is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message) + is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute) + is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name) + is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack) + } + } +} + +@Composable +private fun CenteredLoading(paddingValues: PaddingValues) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator() + } +} + +@Composable +private fun CenteredMessage(paddingValues: PaddingValues, message: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error) + } +} + +@Composable +private fun ReadyContent( + paddingValues: PaddingValues, + sceneName: String, + onExecute: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text(sceneName, style = MaterialTheme.typography.headlineSmall) + Spacer(Modifier.size(24.dp)) + Button(onClick = onExecute) { + Text(stringResource(R.string.scene_execution_run)) + } + } +} + +@Composable +private fun RunningContent(paddingValues: PaddingValues, sceneName: String) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.size(16.dp)) + Text( + stringResource(R.string.scene_execution_running, sceneName), + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun CompletedContent( + paddingValues: PaddingValues, + result: com.flagship.abox.manager.domain.SceneExecutionResult, + onBack: () -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + Text( + text = stringResource( + R.string.scene_execution_summary, + result.succeededCount, + result.failedCount, + result.totalActions, + ), + style = MaterialTheme.typography.titleMedium, + ) + } + items(result.results, key = { it.action.id.value }) { entry -> + ActionResultRow(entry) + } + item { + Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.scene_execution_done)) + } + } + } +} + +@Composable +private fun ActionResultRow(entry: SceneActionExecutionResult) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) { + Icons.Filled.CheckCircle + } else { + Icons.Filled.Error + }, + contentDescription = null, + tint = if (entry.outcome == SceneActionOutcome.SUCCESS) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + Spacer(Modifier.size(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.action.describe(), + style = MaterialTheme.typography.bodyMedium, + ) + entry.error?.detail?.let { detail -> + Text( + text = detail, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } +} + +private fun SceneAction.describe(): String = when (this) { + is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}" + is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key" +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt new file mode 100644 index 0000000..b577d6b --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneExecutionViewModel.kt @@ -0,0 +1,64 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneExecutionResult +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +sealed interface SceneExecutionUiState { + data object Loading : SceneExecutionUiState + data class Ready(val scene: Scene) : SceneExecutionUiState + data class Running(val scene: Scene) : SceneExecutionUiState + data class Completed(val result: SceneExecutionResult) : SceneExecutionUiState + data class Failed(val message: String) : SceneExecutionUiState +} + +@HiltViewModel +class SceneExecutionViewModel @Inject constructor( + private val sceneRepository: SceneRepository, + private val sceneExecutor: SceneExecutor, + @LocalProfileId private val localProfileId: ProviderProfileId, +) : ViewModel() { + + private val _uiState = MutableStateFlow(SceneExecutionUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun initialize(sceneId: SceneId) { + _uiState.value = SceneExecutionUiState.Loading + viewModelScope.launch { + val scene = (sceneRepository.getScene(localProfileId, sceneId) as? DomainResult.Success)?.value + _uiState.value = if (scene != null) { + SceneExecutionUiState.Ready(scene) + } else { + SceneExecutionUiState.Failed("Scene not found") + } + } + } + + fun execute() { + val current = _uiState.value + val scene = (current as? SceneExecutionUiState.Ready)?.scene + ?: (current as? SceneExecutionUiState.Running)?.scene + ?: return + _uiState.value = SceneExecutionUiState.Running(scene) + viewModelScope.launch { + when (val result = sceneExecutor.execute(scene)) { + is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value) + is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed( + result.error.detail ?: "Execution failed", + ) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt new file mode 100644 index 0000000..5b17696 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListScreen.kt @@ -0,0 +1,195 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneId + +@Composable +fun SceneListScreen( + onAddScene: () -> Unit, + onEditScene: (SceneId) -> Unit, + onExecuteScene: (SceneId) -> Unit, + viewModel: SceneListViewModel = hiltViewModel(), +) { + val scenes by viewModel.scenes.collectAsStateWithLifecycle() + val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.scene_list_title)) }, + ) + }, + floatingActionButton = { + ExtendedFloatingActionButton( + onClick = onAddScene, + icon = { Icon(Icons.Filled.Add, contentDescription = null) }, + text = { Text(stringResource(R.string.scene_create_action)) }, + ) + }, + ) { innerPadding -> + if (scenes.isEmpty()) { + EmptySceneList(modifier = Modifier.padding(innerPadding)) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = innerPadding.calculateTopPadding(), + bottom = innerPadding.calculateBottomPadding() + 80.dp, + ), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(scenes, key = { it.id.value }) { scene -> + SceneRow( + scene = scene, + onClick = { onExecuteScene(scene.id) }, + onEdit = { onEditScene(scene.id) }, + onDelete = { viewModel.markPendingDelete(scene.id) }, + ) + } + } + } + } + + pendingDelete?.let { sceneId -> + AlertDialog( + onDismissRequest = viewModel::cancelDelete, + title = { Text(stringResource(R.string.scene_delete_dialog_title)) }, + text = { Text(stringResource(R.string.scene_delete_dialog_message)) }, + confirmButton = { + TextButton(onClick = { viewModel.deleteScene(sceneId) }) { + Text(stringResource(R.string.scene_delete_confirm)) + } + }, + dismissButton = { + TextButton(onClick = viewModel::cancelDelete) { + Text(stringResource(R.string.scene_delete_cancel)) + } + }, + ) + } +} + +@Composable +private fun SceneRow( + scene: Scene, + onClick: () -> Unit, + onEdit: () -> Unit, + onDelete: () -> Unit, +) { + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .clickable(onClick = onClick) + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .background( + color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary, + shape = CircleShape, + ), + ) + Spacer(Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = scene.name, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource( + R.string.scene_action_count, + scene.actions.size, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton(onClick = onEdit) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = stringResource(R.string.scene_edit), + ) + } + IconButton(onClick = onDelete) { + Icon( + Icons.Filled.Delete, + contentDescription = stringResource(R.string.scene_delete), + ) + } + } + } +} + +@Composable +private fun EmptySceneList(modifier: Modifier = Modifier) { + Column( + modifier = modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.scene_empty_title), + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.size(12.dp)) + Text( + text = stringResource(R.string.scene_empty_message), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? { + val hex = value.removePrefix("#") + return runCatching { Color(hex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull() +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt new file mode 100644 index 0000000..1e47bc7 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneListViewModel.kt @@ -0,0 +1,59 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Phase 1 scene list state. The repository is observed for the local-experience profile so the + * UI updates when scenes are added, edited or deleted from any screen. + * + * Phase 2 will replace the hard-coded [LocalProfileId] injection with the active session observed + * from SessionGateway (see project_plan.md §6.5 and §7). + */ +@HiltViewModel +class SceneListViewModel @Inject constructor( + private val sceneRepository: SceneRepository, + @LocalProfileId private val localProfileId: ProviderProfileId, +) : ViewModel() { + + val scenes: StateFlow> = sceneRepository + .observeScenes(localProfileId) + .map { result -> (result as? DomainResult.Success)?.value.orEmpty() } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + private val _pendingDelete = MutableStateFlow(null) + val pendingDelete: StateFlow = _pendingDelete.asStateFlow() + + fun deleteScene(sceneId: SceneId) { + viewModelScope.launch { + sceneRepository.deleteScene(localProfileId, sceneId) + _pendingDelete.value = null + } + } + + fun cancelDelete() { + _pendingDelete.value = null + } + + fun markPendingDelete(sceneId: SceneId) { + _pendingDelete.value = sceneId + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt new file mode 100644 index 0000000..d335c4e --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/scene/SceneNavigation.kt @@ -0,0 +1,72 @@ +package com.flagship.abox.manager.ui.scene + +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.flagship.abox.manager.domain.SceneId + +/** + * Scene navigation routes. [SceneListRoute] is the entry point; the editor and execution routes + * carry the scene id as a path segment so deep linking works without manual argument parsing. + */ +object SceneRoutes { + const val LIST = "scene/list" + const val EDITOR = "scene/editor" + const val EDITOR_WITH_ID = "scene/editor/{sceneId}" + const val EXECUTION = "scene/execution/{sceneId}" + + fun editorFor(sceneId: SceneId): String = "scene/editor/${sceneId.value}" + fun executionFor(sceneId: SceneId): String = "scene/execution/${sceneId.value}" +} + +fun NavGraphBuilder.sceneListDestination( + onAddScene: () -> Unit, + onEditScene: (SceneId) -> Unit, + onExecuteScene: (SceneId) -> Unit, +) { + composable(SceneRoutes.LIST) { + SceneListScreen( + onAddScene = onAddScene, + onEditScene = onEditScene, + onExecuteScene = onExecuteScene, + ) + } +} + +fun NavGraphBuilder.sceneEditorDestination( + onBack: () -> Unit, + onSaved: () -> Unit, +) { + composable( + route = SceneRoutes.EDITOR, + ) { + SceneEditorScreen(sceneId = null, onBack = onBack, onSaved = onSaved) + } + composable( + route = SceneRoutes.EDITOR_WITH_ID, + arguments = listOf(navArgument("sceneId") { type = NavType.StringType }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getString("sceneId") + SceneEditorScreen( + sceneId = id?.let { SceneId(it) }, + onBack = onBack, + onSaved = onSaved, + ) + } +} + +fun NavGraphBuilder.sceneExecutionDestination( + onBack: () -> Unit, +) { + composable( + route = SceneRoutes.EXECUTION, + arguments = listOf(navArgument("sceneId") { type = NavType.StringType }), + ) { backStackEntry -> + val id = backStackEntry.arguments?.getString("sceneId") + SceneExecutionScreen( + sceneId = SceneId(requireNotNull(id) { "sceneId argument is required" }), + onBack = onBack, + ) + } +} diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index bc378ad..deef7dd 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -5,4 +5,43 @@ 继续 功能模块可以开始开发 设备、红外和情景页面将由对应模块负责人实现。 + + + 情景模式 + 新建情景 + %1$d 个动作 + 编辑情景 + 删除情景 + 还没有情景 + 新建第一个情景,组合插座和红外动作。 + + + 删除情景 + 此操作无法撤销。 + 删除 + 取消 + + + 新建情景 + 编辑情景 + 返回 + 名称 + 图标 + 主题色(十六进制) + 动作(%1$d) + 添加插座动作 + 添加红外动作 + 设备名称 + 红外按键 + 添加 + 保存情景 + 插座 %1$s -> %2$s + 红外 %1$s -> %2$s + + + 执行情景 + 执行 + 正在执行 %1$s… + %3$d 个动作中成功 %1$d 个,失败 %2$d 个 + 完成 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index ec5c8d7..d6a7fe6 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -5,4 +5,43 @@ Continue Feature development can start Device, infrared and scene screens will be added by their module owners. + + + Scenes + New scene + %1$d actions + Edit scene + Delete scene + No scenes yet + Create your first scene to combine socket and infrared actions. + + + Delete scene + This action cannot be undone. + Delete + Cancel + + + New scene + Edit scene + Back + Name + Icon + Accent color (hex) + Actions (%1$d) + Add socket action + Add infrared action + Device name + Infrared key + Add + Save scene + Socket %1$s -> %2$s + Infrared %1$s -> %2$s + + + Run scene + Run + Running %1$s… + %1$d succeeded, %2$d failed of %3$d actions + Done diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt new file mode 100644 index 0000000..c0ac4eb --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/Scene.kt @@ -0,0 +1,78 @@ +package com.flagship.abox.manager.domain + +@JvmInline +value class SceneId(val value: String) + +@JvmInline +value class SceneActionId(val value: String) + +enum class SceneActionType { + SOCKET_POWER, + INFRARED_KEY, +} + +/** + * A single executable step inside a [Scene]. + * + * Socket actions reference the [DeviceName] accepted by ABSDK 0630 and a target [SocketPowerState]. + * Infrared actions reference a [DeviceName] and the stable key string provided by the infrared + * module; the executor delegates the actual send to [InfraredGateway.sendKey]. + * + * `DeviceName` is used (rather than `DeviceId`) because ABSDK addresses devices by name, matching + * the existing [SocketGateway] and [InfraredGateway] contracts. The infrared module is responsible + * for keeping the key string stable across edits, so scenes can reference it without coupling to + * the infrared storage primary key. + */ +sealed interface SceneAction { + val id: SceneActionId + val profileId: ProviderProfileId + val deviceName: DeviceName + val type: SceneActionType + + data class SocketPower( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + override val deviceName: DeviceName, + val powerState: SocketPowerState, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.SOCKET_POWER + } + + data class InfraredKey( + override val id: SceneActionId, + override val profileId: ProviderProfileId, + override val deviceName: DeviceName, + val key: String, + ) : SceneAction { + override val type: SceneActionType get() = SceneActionType.INFRARED_KEY + } +} + +/** + * Visual identity for a scene. Kept as a small enum so the UI can resolve the icon without + * external resources and so the persistence layer stays stable across icon set changes. + */ +enum class SceneIcon { + HOME, + OFFICE, + NIGHT, + MOVIE, + AWAY, + CUSTOM, +} + +/** + * Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the + * Compose color space; persistence stores it verbatim. + */ +@JvmInline +value class SceneColor(val hex: String) + +data class Scene( + val id: SceneId, + val profileId: ProviderProfileId, + val name: String, + val icon: SceneIcon, + val color: SceneColor, + val actions: List, +) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt new file mode 100644 index 0000000..8637c78 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecution.kt @@ -0,0 +1,32 @@ +package com.flagship.abox.manager.domain + +enum class SceneActionOutcome { + SUCCESS, + FAILURE, +} + +/** + * Result of executing a single [SceneAction]. [error] is non-null when [outcome] is [FAILURE]. + */ +data class SceneActionExecutionResult( + val action: SceneAction, + val outcome: SceneActionOutcome, + val error: DomainError?, +) + +/** + * Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they + * appear in [scene.actions]; a per-action failure does not abort subsequent actions. + */ +data class SceneExecutionResult( + val scene: Scene, + val results: List, +) { + val totalActions: Int get() = results.size + val succeededCount: Int + get() = results.count { it.outcome == SceneActionOutcome.SUCCESS } + val failedCount: Int + get() = results.count { it.outcome == SceneActionOutcome.FAILURE } + val allSucceeded: Boolean + get() = results.isNotEmpty() && failedCount == 0 +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt new file mode 100644 index 0000000..71432ce --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneExecutor.kt @@ -0,0 +1,12 @@ +package com.flagship.abox.manager.domain + +/** + * Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent + * actions; callers receive a [SceneExecutionResult] that summarizes every step. + * + * Implementations must be idempotent from the caller's perspective: triggering a scene that is + * already running returns [DomainError.OperationFailed] rather than starting a second pass. + */ +interface SceneExecutor { + suspend fun execute(scene: Scene): DomainResult +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneRepository.kt new file mode 100644 index 0000000..6f5fd93 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/SceneRepository.kt @@ -0,0 +1,34 @@ +package com.flagship.abox.manager.domain + +import kotlinx.coroutines.flow.Flow + +/** + * Owns the scene catalog for every provider profile. Implementations must isolate data by + * [ProviderProfileId]: scenes, actions and identifiers must not leak across profiles. + * + * This port is intentionally persistence-agnostic. The first PR provides an in-memory Fake + * implementation; Room persistence will land in a follow-up `feat/scene-persistence` branch. + */ +interface SceneRepository { + fun observeScenes(profileId: ProviderProfileId): Flow>> + + suspend fun getScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult + + suspend fun createScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult + + suspend fun updateScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult + + suspend fun deleteScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 6a69ab0..5db4200 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -12,6 +12,10 @@ pluginManagement { } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt new file mode 100644 index 0000000..7d47222 --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepository.kt @@ -0,0 +1,245 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionExecutionResult +import com.flagship.abox.manager.domain.SceneActionId +import com.flagship.abox.manager.domain.SceneActionOutcome +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneExecutionResult +import com.flagship.abox.manager.domain.SceneExecutor +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SceneRepository +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * In-memory [SceneRepository] used by the scene UI and ViewModel tests. Scenes are isolated by + * [ProviderProfileId]; mutating scenes for one profile never touches another profile's flow. + * + * The repository is intentionally deterministic: [defaultScenes] seeds the same catalog every + * run so UI snapshots and tests stay stable. Mutations emit a new list through the profile's + * StateFlow. + */ +class FakeSceneRepository : SceneRepository { + private val flows = ConcurrentHashMap< + ProviderProfileId, + MutableStateFlow>>, + >() + + override fun observeScenes( + profileId: ProviderProfileId, + ): Flow>> = flowFor(profileId) + + override suspend fun getScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult { + val scenes = currentScenes(profileId) ?: return notFound(sceneId) + return DomainResult.Success(scenes.firstOrNull { it.id == sceneId }) + } + + override suspend fun createScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult { + require(scene.profileId == profileId) { + "Scene profileId must match the supplied profileId" + } + val scenes = currentScenes(profileId).orEmpty() + if (scenes.any { it.id == scene.id }) { + return DomainResult.Failure( + DomainError.InvalidRequest( + detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}", + ), + ) + } + val updated = scenes + scene + publish(profileId, updated) + return DomainResult.Success(scene) + } + + override suspend fun updateScene( + profileId: ProviderProfileId, + scene: Scene, + ): DomainResult { + require(scene.profileId == profileId) { + "Scene profileId must match the supplied profileId" + } + val scenes = currentScenes(profileId).orEmpty() + val index = scenes.indexOfFirst { it.id == scene.id } + if (index < 0) return notFound(scene.id) + val updated = scenes.toMutableList().apply { set(index, scene) } + publish(profileId, updated) + return DomainResult.Success(scene) + } + + override suspend fun deleteScene( + profileId: ProviderProfileId, + sceneId: SceneId, + ): DomainResult { + val scenes = currentScenes(profileId).orEmpty() + val updated = scenes.filterNot { it.id == sceneId } + if (updated.size == scenes.size) return notFound(sceneId) + publish(profileId, updated) + return DomainResult.Success(Unit) + } + + /** + * Replaces the catalog for [profileId] in tests. Returns the same repository so callers can + * chain `repo.setScenes(profile, listOf(...))`. + */ + fun setScenes(profileId: ProviderProfileId, scenes: List): FakeSceneRepository { + require(scenes.all { it.profileId == profileId }) { + "All scenes must belong to the supplied provider profile" + } + publish(profileId, scenes) + return this + } + + fun setError(profileId: ProviderProfileId, error: DomainError) { + flowFor(profileId).value = DomainResult.Failure(error) + } + + private fun flowFor( + profileId: ProviderProfileId, + ): MutableStateFlow>> = + flows.getOrPut(profileId) { + val initial = if (profileId == localProfile) defaultScenes(profileId) else emptyList() + MutableStateFlow(DomainResult.Success(initial)) + } + + private fun currentScenes(profileId: ProviderProfileId): List? = + (flowFor(profileId).value as? DomainResult.Success)?.value + + private fun publish(profileId: ProviderProfileId, scenes: List) { + flowFor(profileId).value = DomainResult.Success(scenes) + } + + private fun notFound(sceneId: SceneId): DomainResult = DomainResult.Failure( + DomainError.InvalidRequest( + detail = "Scene ${sceneId.value} not found", + ), + ) + + private fun defaultScenes(profileId: ProviderProfileId): List = listOf( + Scene( + id = SceneId("${profileId.value}:scene-leaving-home"), + profileId = profileId, + name = "Leaving home", + icon = SceneIcon.AWAY, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ), + SceneAction.InfraredKey( + id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"), + profileId = profileId, + deviceName = DeviceName("ir-1"), + key = "power", + ), + ), + ), + Scene( + id = SceneId("${profileId.value}:scene-movie-night"), + profileId = profileId, + name = "Movie night", + icon = SceneIcon.MOVIE, + color = SceneColor("#7986CB"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-movie-night:socket-1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.OFF, + ), + ), + ), + ) + + companion object { + /** + * The canonical profile id used by the in-app local experience provider. UI code that + * hard-codes a profile in phase 1 should use this constant so the Fake shows data. + */ + val localProfile: ProviderProfileId = ProviderProfileId("local-experience") + } +} + +/** + * Fake [SceneExecutor] that drives the existing [SocketGateway] and [InfraredGateway] fakes. + * + * Behaviour matches the project_plan.md §4.7 contract: + * - actions run sequentially in [Scene.actions] order; + * - a single action failure does not abort subsequent actions; + * - the returned [SceneExecutionResult] contains one entry per action; + * - re-entering execute() while a scene is running returns [DomainError.OperationFailed]. + */ +class FakeSceneExecutor( + private val socketGateway: SocketGateway, + private val infraredGateway: InfraredGateway, +) : SceneExecutor { + private val running = ConcurrentHashMap() + + override suspend fun execute(scene: Scene): DomainResult { + val alreadyRunning = running.putIfAbsent(scene.id, true) != null + if (alreadyRunning) { + return DomainResult.Failure( + DomainError.OperationFailed( + detail = "Scene ${scene.id.value} is already running", + ), + ) + } + return try { + val results = scene.actions.map { action -> executeAction(scene.profileId, action) } + DomainResult.Success(SceneExecutionResult(scene = scene, results = results)) + } finally { + running.remove(scene.id) + } + } + + private suspend fun executeAction( + profileId: ProviderProfileId, + action: SceneAction, + ): SceneActionExecutionResult { + val result = when (action) { + is SceneAction.SocketPower -> socketGateway.setPower( + profileId = profileId, + deviceName = action.deviceName, + powerState = action.powerState, + ) + + is SceneAction.InfraredKey -> infraredGateway.sendKey( + profileId = profileId, + deviceName = action.deviceName, + key = action.key, + ) + } + return when (result) { + is DomainResult.Success -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.SUCCESS, + error = null, + ) + + is DomainResult.Failure -> SceneActionExecutionResult( + action = action, + outcome = SceneActionOutcome.FAILURE, + error = result.error, + ) + } + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt new file mode 100644 index 0000000..7e2551c --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneExecutorTest.kt @@ -0,0 +1,167 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +import com.flagship.abox.manager.domain.SceneActionOutcome +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.domain.SocketStatus +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeSceneExecutorTest { + private val profileId = ProviderProfileId("abox") + + @Test + fun executesActionsInOrder() = runTest { + val socketGateway = FakeSocketGateway() + val infraredGateway = FakeInfraredGateway() + val executor = FakeSceneExecutor(socketGateway, infraredGateway) + val scene = scene( + actions = listOf( + socketAction("socket-1", SocketPowerState.ON), + infraredAction("ir-1", "power"), + socketAction("socket-2", SocketPowerState.OFF), + ), + ) + + val result = executor.execute(scene) + + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(3, executionResult.results.size) + executionResult.results.forEach { + assertEquals(SceneActionOutcome.SUCCESS, it.outcome) + } + assertEquals(1, infraredGateway.sentKeys.size) + } + + @Test + fun continuesAfterActionFailure() = runTest { + val socketGateway = FakeSocketGateway() + socketGateway.setError( + profileId, + DeviceName("socket-1"), + DomainError.DeviceOffline(detail = "offline"), + ) + val infraredGateway = FakeInfraredGateway() + val executor = FakeSceneExecutor(socketGateway, infraredGateway) + val scene = scene( + actions = listOf( + socketAction("socket-1", SocketPowerState.ON), + infraredAction("ir-1", "power"), + ), + ) + + val result = executor.execute(scene) + + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(2, executionResult.results.size) + assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome) + assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome) + assertEquals(1, executionResult.succeededCount) + assertEquals(1, executionResult.failedCount) + assertFalse(executionResult.allSucceeded) + } + + @Test + fun runningSceneRejectsConcurrentExecution() = runTest { + val gate = CompletableDeferred() + val socketGateway = BlockingSocketGateway(gate) + val executor = FakeSceneExecutor(socketGateway, FakeInfraredGateway()) + val scene = scene( + actions = listOf(socketAction("socket-1", SocketPowerState.ON)), + ) + + val first = async { executor.execute(scene) } + // Let the first coroutine enter the executor and block on the socket gateway. + testScheduler.advanceUntilIdle() + val second = async { executor.execute(scene) } + val secondResult = second.await() + + assertTrue(secondResult is DomainResult.Failure) + assertTrue((secondResult as DomainResult.Failure).error is DomainError.OperationFailed) + + gate.complete(Unit) + val firstResult = first.await() + assertTrue(firstResult is DomainResult.Success) + } + + @Test + fun emptySceneSucceedsWithNoResults() = runTest { + val executor = FakeSceneExecutor(FakeSocketGateway(), FakeInfraredGateway()) + val scene = scene(actions = emptyList()) + + val result = executor.execute(scene) + + assertTrue(result is DomainResult.Success) + val executionResult = (result as DomainResult.Success).value + assertEquals(0, executionResult.results.size) + assertEquals(0, executionResult.succeededCount) + assertEquals(0, executionResult.failedCount) + assertFalse(executionResult.allSucceeded) + } + + private fun scene(actions: List): Scene = Scene( + id = SceneId("${profileId.value}:scene-test"), + profileId = profileId, + name = "Test scene", + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = actions, + ) + + private fun socketAction( + deviceName: String, + powerState: SocketPowerState, + ): SceneAction.SocketPower = SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:action-${deviceName}-${powerState.name}"), + profileId = profileId, + deviceName = DeviceName(deviceName), + powerState = powerState, + ) + + private fun infraredAction( + deviceName: String, + key: String, + ): SceneAction.InfraredKey = SceneAction.InfraredKey( + id = SceneActionId("${profileId.value}:action-${deviceName}-$key"), + profileId = profileId, + deviceName = DeviceName(deviceName), + key = key, + ) + + private class BlockingSocketGateway( + private val gate: CompletableDeferred, + ) : SocketGateway { + override suspend fun getStatus( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): DomainResult = DomainResult.Success( + SocketStatus(profileId, deviceName, SocketPowerState.OFF), + ) + + override suspend fun setPower( + profileId: ProviderProfileId, + deviceName: DeviceName, + powerState: SocketPowerState, + ): DomainResult { + gate.await() + return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) + } + } +} diff --git a/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt new file mode 100644 index 0000000..e67f916 --- /dev/null +++ b/testing/fakes/src/test/kotlin/com/flagship/abox/manager/testing/fakes/FakeSceneRepositoryTest.kt @@ -0,0 +1,149 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.Scene +import com.flagship.abox.manager.domain.SceneAction +import com.flagship.abox.manager.domain.SceneActionId +import com.flagship.abox.manager.domain.SceneColor +import com.flagship.abox.manager.domain.SceneIcon +import com.flagship.abox.manager.domain.SceneId +import com.flagship.abox.manager.domain.SocketPowerState +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class FakeSceneRepositoryTest { + private val aboxProfile = ProviderProfileId("abox") + private val localProfile = FakeSceneRepository.localProfile + + @Test + fun defaultScenesAreIsolatedByProviderProfile() = runTest { + val repo = FakeSceneRepository() + + val aboxScenes = scenesFor(repo, aboxProfile) + val localScenes = scenesFor(repo, localProfile) + + assertEquals(0, aboxScenes.size) + assertEquals(2, localScenes.size) + assertTrue(localScenes.all { it.profileId == localProfile }) + } + + @Test + fun createScenePublishesSceneToFlow() = runTest { + val repo = FakeSceneRepository() + val scene = sampleScene(localProfile, "scene-1") + + val result = repo.createScene(localProfile, scene) + + assertTrue(result is DomainResult.Success) + assertEquals(scene, (result as DomainResult.Success).value) + val list = scenesFor(repo, localProfile) + assertEquals(3, list.size) + assertEquals(scene, list.last()) + } + + @Test + fun createSceneWithDuplicateIdFails() = runTest { + val repo = FakeSceneRepository() + val scene = sampleScene(localProfile, "dup") + + repo.createScene(localProfile, scene) + val second = repo.createScene(localProfile, scene) + + assertTrue(second is DomainResult.Failure) + assertTrue((second as DomainResult.Failure).error is DomainError.InvalidRequest) + } + + @Test + fun updateSceneReplacesExistingEntry() = runTest { + val repo = FakeSceneRepository() + val original = sampleScene(localProfile, "scene-2", "Original") + repo.createScene(localProfile, original) + + val updated = original.copy(name = "Renamed") + val result = repo.updateScene(localProfile, updated) + + assertTrue(result is DomainResult.Success) + val list = scenesFor(repo, localProfile) + assertEquals(3, list.size) + val stored = list.first { it.id == original.id } + assertEquals("Renamed", stored.name) + } + + @Test + fun deleteSceneRemovesEntry() = runTest { + val repo = FakeSceneRepository() + val scene = sampleScene(localProfile, "scene-3") + repo.createScene(localProfile, scene) + + val result = repo.deleteScene(localProfile, scene.id) + + assertTrue(result is DomainResult.Success) + val list = scenesFor(repo, localProfile) + assertEquals(2, list.size) + assertNull(list.firstOrNull { it.id == scene.id }) + } + + @Test + fun deleteUnknownSceneFails() = runTest { + val repo = FakeSceneRepository() + + val result = repo.deleteScene(localProfile, SceneId("missing")) + + assertTrue(result is DomainResult.Failure) + assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest) + } + + @Test + fun getSceneReturnsNullForUnknownId() = runTest { + val repo = FakeSceneRepository() + + val result = repo.getScene(localProfile, SceneId("missing")) + + assertTrue(result is DomainResult.Success) + assertNull((result as DomainResult.Success).value) + } + + @Test + fun setErrorStopsPublishingSuccess() = runTest { + val repo = FakeSceneRepository() + repo.setError(localProfile, DomainError.Unknown(detail = "boom")) + + val value = repo.observeScenes(localProfile).first() + + assertTrue(value is DomainResult.Failure) + assertNotNull((value as DomainResult.Failure).error) + } + + private suspend fun scenesFor( + repo: FakeSceneRepository, + profileId: ProviderProfileId, + ): List = (repo.observeScenes(profileId).first() as DomainResult.Success).value + + private fun sampleScene( + profileId: ProviderProfileId, + idSuffix: String, + name: String = "Test scene", + ): Scene = Scene( + id = SceneId("${profileId.value}:scene-$idSuffix"), + profileId = profileId, + name = name, + icon = SceneIcon.HOME, + color = SceneColor("#FF8A65"), + actions = listOf( + SceneAction.SocketPower( + id = SceneActionId("${profileId.value}:scene-$idSuffix:1"), + profileId = profileId, + deviceName = DeviceName("socket-1"), + powerState = SocketPowerState.ON, + ), + ), + ) +}