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="activity_placeholder_title">活动记录</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>
@@ -42,4 +42,43 @@
<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_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>