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:
@@ -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"
|
||||||
|
}
|
||||||
+136
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+221
@@ -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"
|
||||||
|
}
|
||||||
+64
@@ -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 -> %2$s</string>
|
||||||
|
<string name="scene_action_infrared">红外 %1$s -> %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>
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
+245
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+149
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user