feat(scene): add scene editor, executor and fakes (phase 1)

Introduce the scene module skeleton that satisfies phase 1 exit conditions for
tzh's responsibility in project_plan.md section 6.5: scene list, editor and Fake-driven
execution that can be tested independently.

Domain layer (domain/):
- Scene, SceneAction (SocketPower / InfraredKey), SceneIcon, SceneColor
- SceneExecutionResult with per-action outcome aggregation
- SceneRepository port (observe/get/create/update/delete, provider-isolated)
- SceneExecutor port (sequential execution, per-action failure continues)

Fakes (testing/fakes/):
- FakeSceneRepository: in-memory, provider-isolated, seeds local-experience catalog
- FakeSceneExecutor: drives existing SocketGateway/InfraredGateway fakes,
  rejects concurrent execution of the same scene

Android app (android/app/):
- SceneListScreen / SceneListViewModel: list, empty state, delete confirm
- SceneEditorScreen / SceneEditorViewModel: name/icon/color, action add/remove/reorder
- SceneExecutionScreen / SceneExecutionViewModel: run, progress, per-action summary
- SceneRoutes + NavGraphBuilder extensions wired into ABoxManagerApp
- SceneModule (debug) binds FakeSceneRepository, FakeSceneExecutor and a
  LocalProfileId qualifier that feat/provider-login will retire later

Build (settings.gradle.kts):
- Apply foojay-resolver-convention so the JVM 17 toolchain is auto-provisioned

Resources: zh-rCN / en strings for all scene surfaces
Tests: 20 unit tests covering repository isolation, CRUD, executor ordering,
failure continuation and concurrent-execution rejection
This commit is contained in:
tzh
2026-08-25 11:25:35 +08:00
committed by Moler
parent ed98c2be98
commit 533ce46de6
19 changed files with 1884 additions and 0 deletions
@@ -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
}
@@ -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
@@ -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"
}
@@ -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<SceneAction> = 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<SceneEditorState> = _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",
)
}
}
}
}
}
@@ -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"
}
@@ -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>(SceneExecutionUiState.Loading)
val uiState: StateFlow<SceneExecutionUiState> = _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",
)
}
}
}
}
@@ -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()
}
@@ -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<List<Scene>> = 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<SceneId?>(null)
val pendingDelete: StateFlow<SceneId?> = _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
}
}
@@ -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,
)
}
}
@@ -42,4 +42,43 @@
<string name="scenes_placeholder_description">情景动作与执行插槽(由 tzh 模块实现)。</string> <string name="scenes_placeholder_description">情景动作与执行插槽(由 tzh 模块实现)。</string>
<string name="activity_placeholder_title">活动记录</string> <string name="activity_placeholder_title">活动记录</string>
<string name="activity_placeholder_description">未记录任何事件。</string> <string name="activity_placeholder_description">未记录任何事件。</string>
<!-- Scene: list -->
<string name="scene_list_title">情景模式</string>
<string name="scene_create_action">新建情景</string>
<string name="scene_action_count">%1$d 个动作</string>
<string name="scene_edit">编辑情景</string>
<string name="scene_delete">删除情景</string>
<string name="scene_empty_title">还没有情景</string>
<string name="scene_empty_message">新建第一个情景,组合插座和红外动作。</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">删除情景</string>
<string name="scene_delete_dialog_message">此操作无法撤销。</string>
<string name="scene_delete_confirm">删除</string>
<string name="scene_delete_cancel">取消</string>
<!-- Scene: editor -->
<string name="scene_editor_new_title">新建情景</string>
<string name="scene_editor_edit_title">编辑情景</string>
<string name="scene_back">返回</string>
<string name="scene_name_label">名称</string>
<string name="scene_icon_label">图标</string>
<string name="scene_color_label">主题色(十六进制)</string>
<string name="scene_actions_label">动作(%1$d)</string>
<string name="scene_add_socket_action">添加插座动作</string>
<string name="scene_add_infrared_action">添加红外动作</string>
<string name="scene_device_name_label">设备名称</string>
<string name="scene_infrared_key_label">红外按键</string>
<string name="scene_add_action">添加</string>
<string name="scene_save_action">保存情景</string>
<string name="scene_action_socket">插座 %1$s -&gt; %2$s</string>
<string name="scene_action_infrared">红外 %1$s -&gt; %2$s</string>
<!-- Scene: execution -->
<string name="scene_execution_title">执行情景</string>
<string name="scene_execution_run">执行</string>
<string name="scene_execution_running">正在执行 %1$s…</string>
<string name="scene_execution_summary">%3$d 个动作中成功 %1$d 个,失败 %2$d 个</string>
<string name="scene_execution_done">完成</string>
</resources> </resources>
@@ -42,4 +42,43 @@
<string name="scenes_placeholder_description">Scene actions and execution slot for tzh.</string> <string name="scenes_placeholder_description">Scene actions and execution slot for tzh.</string>
<string name="activity_placeholder_title">Activity Log</string> <string name="activity_placeholder_title">Activity Log</string>
<string name="activity_placeholder_description">No recorded events yet.</string> <string name="activity_placeholder_description">No recorded events yet.</string>
<!-- Scene: list -->
<string name="scene_list_title">Scenes</string>
<string name="scene_create_action">New scene</string>
<string name="scene_action_count">%1$d actions</string>
<string name="scene_edit">Edit scene</string>
<string name="scene_delete">Delete scene</string>
<string name="scene_empty_title">No scenes yet</string>
<string name="scene_empty_message">Create your first scene to combine socket and infrared actions.</string>
<!-- Scene: delete dialog -->
<string name="scene_delete_dialog_title">Delete scene</string>
<string name="scene_delete_dialog_message">This action cannot be undone.</string>
<string name="scene_delete_confirm">Delete</string>
<string name="scene_delete_cancel">Cancel</string>
<!-- Scene: editor -->
<string name="scene_editor_new_title">New scene</string>
<string name="scene_editor_edit_title">Edit scene</string>
<string name="scene_back">Back</string>
<string name="scene_name_label">Name</string>
<string name="scene_icon_label">Icon</string>
<string name="scene_color_label">Accent color (hex)</string>
<string name="scene_actions_label">Actions (%1$d)</string>
<string name="scene_add_socket_action">Add socket action</string>
<string name="scene_add_infrared_action">Add infrared action</string>
<string name="scene_device_name_label">Device name</string>
<string name="scene_infrared_key_label">Infrared key</string>
<string name="scene_add_action">Add</string>
<string name="scene_save_action">Save scene</string>
<string name="scene_action_socket">Socket %1$s -> %2$s</string>
<string name="scene_action_infrared">Infrared %1$s -> %2$s</string>
<!-- Scene: execution -->
<string name="scene_execution_title">Run scene</string>
<string name="scene_execution_run">Run</string>
<string name="scene_execution_running">Running %1$s…</string>
<string name="scene_execution_summary">%1$d succeeded, %2$d failed of %3$d actions</string>
<string name="scene_execution_done">Done</string>
</resources> </resources>
@@ -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<SceneAction>,
)
@@ -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<SceneActionExecutionResult>,
) {
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
}
@@ -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<SceneExecutionResult>
}
@@ -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<DomainResult<List<Scene>>>
suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?>
suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene>
suspend fun updateScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene>
suspend fun deleteScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Unit>
}
+4
View File
@@ -12,6 +12,10 @@ pluginManagement {
} }
} }
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
dependencyResolutionManagement { dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories { repositories {
@@ -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<DomainResult<List<Scene>>>,
>()
override fun observeScenes(
profileId: ProviderProfileId,
): Flow<DomainResult<List<Scene>>> = flowFor(profileId)
override suspend fun getScene(
profileId: ProviderProfileId,
sceneId: SceneId,
): DomainResult<Scene?> {
val scenes = currentScenes(profileId) ?: return notFound(sceneId)
return DomainResult.Success(scenes.firstOrNull { it.id == sceneId })
}
override suspend fun createScene(
profileId: ProviderProfileId,
scene: Scene,
): DomainResult<Scene> {
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<Scene> {
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<Unit> {
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<Scene>): 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<DomainResult<List<Scene>>> =
flows.getOrPut(profileId) {
val initial = if (profileId == localProfile) defaultScenes(profileId) else emptyList()
MutableStateFlow(DomainResult.Success(initial))
}
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
(flowFor(profileId).value as? DomainResult.Success)?.value
private fun publish(profileId: ProviderProfileId, scenes: List<Scene>) {
flowFor(profileId).value = DomainResult.Success(scenes)
}
private fun notFound(sceneId: SceneId): DomainResult<Nothing> = DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Scene ${sceneId.value} not found",
),
)
private fun defaultScenes(profileId: ProviderProfileId): List<Scene> = 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<SceneId, Boolean>()
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
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,
)
}
}
}
@@ -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<Unit>()
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<SceneAction>): 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<Unit>,
) : SocketGateway {
override suspend fun getStatus(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<SocketStatus> = DomainResult.Success(
SocketStatus(profileId, deviceName, SocketPowerState.OFF),
)
override suspend fun setPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<SocketStatus> {
gate.await()
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
}
}
}
@@ -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<Scene> = (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,
),
),
)
}