feat(scene): add scene editor, executor and fakes (phase 1) #5
@@ -0,0 +1,37 @@
|
|||||||
|
package com.flagship.abox.manager.di
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DefaultSceneExecutor
|
||||||
|
import com.flagship.abox.manager.domain.InMemorySceneRepository
|
||||||
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production dependency injection module for Scene management and execution.
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object SceneIntegrationModule {
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSceneRepository(): SceneRepository = InMemorySceneRepository()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideSceneExecutor(
|
||||||
|
socketGateway: SocketGateway,
|
||||||
|
infraredGateway: InfraredGateway,
|
||||||
|
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||||
|
): SceneExecutor = DefaultSceneExecutor(
|
||||||
|
socketGateway = socketGateway,
|
||||||
|
infraredGateway = infraredGateway,
|
||||||
|
sendVirtualRemoteAction = sendVirtualRemoteAction,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,10 @@ import com.flagship.abox.manager.ui.navigation.deviceDetailGraph
|
|||||||
import com.flagship.abox.manager.ui.navigation.navigateToSocket
|
import com.flagship.abox.manager.ui.navigation.navigateToSocket
|
||||||
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
||||||
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
||||||
|
import com.flagship.abox.manager.ui.scene.SceneListScreen
|
||||||
|
import com.flagship.abox.manager.ui.scene.SceneRoutes
|
||||||
|
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
|
||||||
|
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||||
@@ -124,11 +128,26 @@ private fun AuthenticatedNavHost(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
scenesSlot = {
|
||||||
|
SceneListScreen(
|
||||||
|
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
|
||||||
|
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
|
||||||
|
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
|
||||||
|
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
|
||||||
|
)
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
deviceDetailGraph(
|
deviceDetailGraph(
|
||||||
onBackClick = navController::popBackStack,
|
onBackClick = navController::popBackStack,
|
||||||
)
|
)
|
||||||
|
sceneEditorDestination(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onSaved = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
sceneExecutionDestination(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
composable(
|
composable(
|
||||||
route = "infrared_hub/{profileId}",
|
route = "infrared_hub/{profileId}",
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+385
@@ -0,0 +1,385 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||||
|
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneCondition
|
||||||
|
import com.flagship.abox.manager.domain.SceneConditionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
|
import com.flagship.abox.manager.domain.SceneTriggerId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import java.util.UUID
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
data class SceneEditorState(
|
||||||
|
val sceneId: SceneId? = null,
|
||||||
|
val type: AutomationType = AutomationType.MANUAL,
|
||||||
|
val name: String = "",
|
||||||
|
val description: String = "",
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val icon: SceneIcon = SceneIcon.HOME,
|
||||||
|
val colorHex: String = "#3F51B5",
|
||||||
|
val triggers: List<SceneTrigger> = emptyList(),
|
||||||
|
val conditions: List<SceneCondition> = emptyList(),
|
||||||
|
val actions: List<SceneAction> = emptyList(),
|
||||||
|
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
|
||||||
|
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
||||||
|
val enableLogging: Boolean = true,
|
||||||
|
val availableRemotes: List<VirtualRemote> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val isSaving: Boolean = false,
|
||||||
|
val isSaved: Boolean = false,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
) {
|
||||||
|
val isValid: Boolean get() = !isLoading && name.isNotBlank() && actions.isNotEmpty() && (type != AutomationType.AUTOMATIC || triggers.isNotEmpty())
|
||||||
|
val isEditing: Boolean get() = sceneId != null
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SceneEditorViewModel @Inject constructor(
|
||||||
|
private val sceneRepository: SceneRepository,
|
||||||
|
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||||
|
private val profileStore: ProviderProfileStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(SceneEditorState())
|
||||||
|
val state: StateFlow<SceneEditorState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private var initializedSceneId: SceneId? = null
|
||||||
|
private var isInitialized: Boolean = false
|
||||||
|
|
||||||
|
init {
|
||||||
|
observeAvailableRemotes()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeAvailableRemotes() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
profileStore.observeActiveProfile().collectLatest { profile ->
|
||||||
|
if (profile != null) {
|
||||||
|
virtualRemoteRepository.observeRemotes(profile.id, DeviceName("红外控制器")).collectLatest { res ->
|
||||||
|
if (res is DomainResult.Success) {
|
||||||
|
_state.update { it.copy(availableRemotes = res.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun initialize(sceneId: SceneId?, initialType: AutomationType? = null) {
|
||||||
|
if (isInitialized && initializedSceneId == sceneId) return
|
||||||
|
isInitialized = true
|
||||||
|
initializedSceneId = sceneId
|
||||||
|
|
||||||
|
if (sceneId == null) {
|
||||||
|
_state.update {
|
||||||
|
SceneEditorState(
|
||||||
|
type = initialType ?: AutomationType.MANUAL,
|
||||||
|
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
|
||||||
|
availableRemotes = it.availableRemotes,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
sceneId = sceneId,
|
||||||
|
isLoading = true,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
if (profile == null) {
|
||||||
|
_state.update { it.copy(isLoading = false, errorMessage = "No active profile") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
|
||||||
|
if (scene != null) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
sceneId = scene.id,
|
||||||
|
type = scene.type,
|
||||||
|
name = scene.name,
|
||||||
|
description = scene.description,
|
||||||
|
enabled = scene.enabled,
|
||||||
|
icon = scene.icon,
|
||||||
|
colorHex = scene.color.hex,
|
||||||
|
triggers = scene.triggers,
|
||||||
|
conditions = scene.conditions,
|
||||||
|
actions = scene.actions,
|
||||||
|
strategy = scene.strategy,
|
||||||
|
concurrencyMode = scene.concurrencyMode,
|
||||||
|
enableLogging = scene.enableLogging,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_state.update { it.copy(isLoading = false, errorMessage = "Scene not found") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateName(name: String) = _state.update { it.copy(name = name, errorMessage = null) }
|
||||||
|
fun updateDescription(desc: String) = _state.update { it.copy(description = desc) }
|
||||||
|
fun updateType(type: AutomationType) = _state.update { it.copy(type = type) }
|
||||||
|
fun updateEnabled(enabled: Boolean) = _state.update { it.copy(enabled = enabled) }
|
||||||
|
fun updateIcon(icon: SceneIcon) = _state.update { it.copy(icon = icon) }
|
||||||
|
fun updateColor(hex: String) = _state.update { it.copy(colorHex = hex) }
|
||||||
|
fun updateStrategy(strategy: ExecutionStrategy) = _state.update { it.copy(strategy = strategy) }
|
||||||
|
fun updateConcurrencyMode(mode: ConcurrencyMode) = _state.update { it.copy(concurrencyMode = mode) }
|
||||||
|
fun updateLogging(enabled: Boolean) = _state.update { it.copy(enableLogging = enabled) }
|
||||||
|
|
||||||
|
// --- Triggers ---
|
||||||
|
fun addTimeTrigger(timeOfDay: String, daysOfWeek: Set<Int> = emptySet()) {
|
||||||
|
val trigger = SceneTrigger.Time(
|
||||||
|
id = SceneTriggerId(UUID.randomUUID().toString()),
|
||||||
|
timeOfDay = timeOfDay,
|
||||||
|
daysOfWeek = daysOfWeek,
|
||||||
|
label = "定时 $timeOfDay",
|
||||||
|
)
|
||||||
|
_state.update { it.copy(triggers = it.triggers + trigger) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addTemperatureTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) {
|
||||||
|
val trigger = SceneTrigger.Temperature(
|
||||||
|
id = SceneTriggerId(UUID.randomUUID().toString()),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
operator = operator,
|
||||||
|
thresholdCelsius = threshold,
|
||||||
|
label = "$deviceName 温度 ${operator.symbol} $threshold°C",
|
||||||
|
)
|
||||||
|
_state.update { it.copy(triggers = it.triggers + trigger) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addHumidityTrigger(deviceName: String, operator: ComparisonOperator, threshold: Double) {
|
||||||
|
val trigger = SceneTrigger.Humidity(
|
||||||
|
id = SceneTriggerId(UUID.randomUUID().toString()),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
operator = operator,
|
||||||
|
thresholdPercent = threshold,
|
||||||
|
label = "$deviceName 湿度 ${operator.symbol} $threshold%",
|
||||||
|
)
|
||||||
|
_state.update { it.copy(triggers = it.triggers + trigger) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDeviceStateTrigger(deviceName: String, expectedState: String) {
|
||||||
|
val trigger = SceneTrigger.DeviceState(
|
||||||
|
id = SceneTriggerId(UUID.randomUUID().toString()),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
expectedState = expectedState,
|
||||||
|
label = "$deviceName 状态变为 $expectedState",
|
||||||
|
)
|
||||||
|
_state.update { it.copy(triggers = it.triggers + trigger) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeTrigger(triggerId: SceneTriggerId) =
|
||||||
|
_state.update { it.copy(triggers = it.triggers.filterNot { t -> t.id == triggerId }) }
|
||||||
|
|
||||||
|
// --- Conditions ---
|
||||||
|
fun addTemperatureCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) {
|
||||||
|
val cond = SceneCondition.Temperature(
|
||||||
|
id = SceneConditionId(UUID.randomUUID().toString()),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
operator = operator,
|
||||||
|
thresholdCelsius = threshold,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(conditions = it.conditions + cond) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addHumidityCondition(deviceName: String, operator: ComparisonOperator, threshold: Double) {
|
||||||
|
val cond = SceneCondition.Humidity(
|
||||||
|
id = SceneConditionId(UUID.randomUUID().toString()),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
operator = operator,
|
||||||
|
thresholdPercent = threshold,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(conditions = it.conditions + cond) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSocketPowerCondition(deviceName: String, powerState: SocketPowerState) {
|
||||||
|
val cond = SceneCondition.SocketPower(
|
||||||
|
id = SceneConditionId(UUID.randomUUID().toString()),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
expectedState = powerState,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(conditions = it.conditions + cond) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addTimeRangeCondition(startTime: String, endTime: String) {
|
||||||
|
val cond = SceneCondition.TimeRange(
|
||||||
|
id = SceneConditionId(UUID.randomUUID().toString()),
|
||||||
|
startTime = startTime,
|
||||||
|
endTime = endTime,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(conditions = it.conditions + cond) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCondition(conditionId: SceneConditionId) =
|
||||||
|
_state.update { it.copy(conditions = it.conditions.filterNot { c -> c.id == conditionId }) }
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
fun addSocketAction(deviceName: String, powerState: SocketPowerState) {
|
||||||
|
if (deviceName.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
|
val action = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId(UUID.randomUUID().toString()),
|
||||||
|
profileId = profile.id,
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
powerState = powerState,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addVirtualRemoteAction(
|
||||||
|
remoteId: String,
|
||||||
|
remoteDisplayName: String,
|
||||||
|
buttonId: String,
|
||||||
|
buttonDisplayName: String,
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
|
val action = SceneAction.VirtualRemoteButton(
|
||||||
|
id = SceneActionId(UUID.randomUUID().toString()),
|
||||||
|
profileId = profile.id,
|
||||||
|
remoteId = remoteId,
|
||||||
|
remoteDisplayName = remoteDisplayName,
|
||||||
|
buttonId = buttonId,
|
||||||
|
buttonDisplayName = buttonDisplayName,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addInfraredAction(deviceName: String, key: String) {
|
||||||
|
if (deviceName.isBlank() || key.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
|
val action = SceneAction.InfraredKey(
|
||||||
|
id = SceneActionId(UUID.randomUUID().toString()),
|
||||||
|
profileId = profile.id,
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
key = key,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDelayAction(seconds: Int) {
|
||||||
|
if (seconds <= 0) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
|
val action = SceneAction.Delay(
|
||||||
|
id = SceneActionId(UUID.randomUUID().toString()),
|
||||||
|
profileId = profile.id,
|
||||||
|
durationSeconds = seconds,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addNotificationAction(title: String, message: String) {
|
||||||
|
if (title.isBlank()) return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
|
val action = SceneAction.Notification(
|
||||||
|
id = SceneActionId(UUID.randomUUID().toString()),
|
||||||
|
profileId = profile.id,
|
||||||
|
title = title,
|
||||||
|
message = message,
|
||||||
|
)
|
||||||
|
_state.update { it.copy(actions = it.actions + action, errorMessage = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAction(actionId: SceneActionId) =
|
||||||
|
_state.update { it.copy(actions = it.actions.filterNot { a -> a.id == actionId }) }
|
||||||
|
|
||||||
|
fun moveAction(from: Int, to: Int) {
|
||||||
|
val current = _state.value.actions
|
||||||
|
if (from !in current.indices || to !in current.indices || from == to) return
|
||||||
|
val updated = current.toMutableList().apply { add(to, removeAt(from)) }
|
||||||
|
_state.update { it.copy(actions = updated) }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getKeyBindings(remoteId: VirtualRemoteId): List<RemoteKeyBinding> {
|
||||||
|
return when (val res = virtualRemoteRepository.getKeyBindings(remoteId)) {
|
||||||
|
is DomainResult.Success -> res.value
|
||||||
|
is DomainResult.Failure -> emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save() {
|
||||||
|
val state = _state.value
|
||||||
|
if (!state.isValid || state.isSaving) return
|
||||||
|
_state.update { it.copy(isSaving = true, errorMessage = null) }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
if (profile == null) {
|
||||||
|
_state.update { it.copy(isSaving = false, errorMessage = "No active profile") }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val scene = Scene(
|
||||||
|
id = state.sceneId ?: SceneId("${profile.id.value}:scene-${UUID.randomUUID()}"),
|
||||||
|
profileId = profile.id,
|
||||||
|
name = state.name.trim(),
|
||||||
|
description = state.description.trim(),
|
||||||
|
type = state.type,
|
||||||
|
enabled = state.enabled,
|
||||||
|
icon = state.icon,
|
||||||
|
color = SceneColor(state.colorHex),
|
||||||
|
triggers = state.triggers,
|
||||||
|
conditions = state.conditions,
|
||||||
|
actions = state.actions,
|
||||||
|
strategy = state.strategy,
|
||||||
|
concurrencyMode = state.concurrencyMode,
|
||||||
|
enableLogging = state.enableLogging,
|
||||||
|
)
|
||||||
|
val result = if (state.isEditing) {
|
||||||
|
sceneRepository.updateScene(profile.id, scene)
|
||||||
|
} else {
|
||||||
|
sceneRepository.createScene(profile.id, scene)
|
||||||
|
}
|
||||||
|
_state.update { current ->
|
||||||
|
when (result) {
|
||||||
|
is DomainResult.Success -> current.copy(isSaving = false, isSaved = true)
|
||||||
|
is DomainResult.Failure -> current.copy(
|
||||||
|
isSaving = false,
|
||||||
|
errorMessage = result.error.detail ?: "Save failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
+345
@@ -0,0 +1,345 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material.icons.outlined.HourglassDisabled
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionExecutionResult
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionOutcome
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionResult
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SceneExecutionScreen(
|
||||||
|
sceneId: SceneId,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
viewModel: SceneExecutionViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
LaunchedEffect(sceneId) {
|
||||||
|
viewModel.initialize(sceneId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.scene_execution_title), fontWeight = FontWeight.SemiBold) },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.scene_back))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
when (val state = uiState) {
|
||||||
|
SceneExecutionUiState.Loading -> CenteredLoading(innerPadding)
|
||||||
|
is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message, onRetry = { viewModel.initialize(sceneId) })
|
||||||
|
is SceneExecutionUiState.Ready -> ReadyContent(innerPadding, state.scene.name, viewModel::execute)
|
||||||
|
is SceneExecutionUiState.Running -> RunningContent(innerPadding, state.scene.name)
|
||||||
|
is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack, onRerun = viewModel::execute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredLoading(paddingValues: PaddingValues) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(message, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error)
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.action_retry))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ReadyContent(
|
||||||
|
paddingValues: PaddingValues,
|
||||||
|
sceneName: String,
|
||||||
|
onExecute: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(stringResource(R.string.scene_exec_ready_hint), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onExecute,
|
||||||
|
modifier = Modifier.fillMaxWidth(0.6f).height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PlayArrow, contentDescription = null)
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.scene_execution_run), fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
.padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(56.dp))
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
stringResource(R.string.scene_execution_running, sceneName),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun CompletedContent(
|
||||||
|
paddingValues: PaddingValues,
|
||||||
|
result: SceneExecutionResult,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onRerun: () -> Unit,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item(key = "summary") {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.outlinedCardColors(
|
||||||
|
containerColor = if (result.allSucceeded) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
border = BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
if (result.allSucceeded) MaterialTheme.colorScheme.primary.copy(alpha = 0.4f)
|
||||||
|
else MaterialTheme.colorScheme.error.copy(alpha = 0.4f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scene_execution_done),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
R.string.scene_execution_summary,
|
||||||
|
result.succeededCount,
|
||||||
|
result.failedCount,
|
||||||
|
result.totalActions,
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
if (result.totalDurationMillis > 0) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scene_execution_duration, result.totalDurationMillis),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry ->
|
||||||
|
ActionResultRow(index + 1, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "buttons") {
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onRerun,
|
||||||
|
modifier = Modifier.weight(1f).height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.Refresh, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(stringResource(R.string.scene_exec_rerun))
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
onClick = onBack,
|
||||||
|
modifier = Modifier.weight(1f).height(48.dp),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_back), fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) {
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = CardDefaults.outlinedCardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Surface(
|
||||||
|
shape = CircleShape,
|
||||||
|
color = when (entry.outcome) {
|
||||||
|
SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer
|
||||||
|
SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer
|
||||||
|
SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
when (entry.outcome) {
|
||||||
|
SceneActionOutcome.SUCCESS -> Icon(
|
||||||
|
Icons.Filled.Check,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
SceneActionOutcome.FAILURE -> Icon(
|
||||||
|
Icons.Filled.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
SceneActionOutcome.SKIPPED -> Icon(
|
||||||
|
Icons.Outlined.HourglassDisabled,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scene_step_format, stepNumber, entry.action.describe()),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
entry.error?.detail?.let { detail ->
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = detail,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.executionDurationMillis > 0) {
|
||||||
|
Text(
|
||||||
|
text = "${entry.executionDurationMillis}ms",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SceneAction.describe(): String = when (this) {
|
||||||
|
is SceneAction.SocketPower -> stringResource(R.string.scene_action_socket, deviceName.value, powerState.name)
|
||||||
|
is SceneAction.VirtualRemoteButton -> stringResource(R.string.scene_action_remote, remoteDisplayName, buttonDisplayName)
|
||||||
|
is SceneAction.InfraredKey -> stringResource(R.string.scene_action_infrared, deviceName.value, key)
|
||||||
|
is SceneAction.Delay -> stringResource(R.string.scene_action_delay, durationSeconds)
|
||||||
|
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
|
||||||
|
}
|
||||||
|
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionResult
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
sealed interface SceneExecutionUiState {
|
||||||
|
data object Loading : SceneExecutionUiState
|
||||||
|
data class Ready(val scene: Scene) : SceneExecutionUiState
|
||||||
|
data class Running(val scene: Scene) : SceneExecutionUiState
|
||||||
|
data class Completed(val result: SceneExecutionResult) : SceneExecutionUiState
|
||||||
|
data class Failed(val message: String) : SceneExecutionUiState
|
||||||
|
}
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SceneExecutionViewModel @Inject constructor(
|
||||||
|
private val sceneRepository: SceneRepository,
|
||||||
|
private val sceneExecutor: SceneExecutor,
|
||||||
|
private val profileStore: ProviderProfileStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _uiState = MutableStateFlow<SceneExecutionUiState>(SceneExecutionUiState.Loading)
|
||||||
|
val uiState: StateFlow<SceneExecutionUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
private var initializedSceneId: SceneId? = null
|
||||||
|
|
||||||
|
fun initialize(sceneId: SceneId) {
|
||||||
|
if (initializedSceneId == sceneId && _uiState.value !is SceneExecutionUiState.Loading) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
initializedSceneId = sceneId
|
||||||
|
_uiState.value = SceneExecutionUiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
if (profile == null) {
|
||||||
|
_uiState.value = SceneExecutionUiState.Failed("No active profile")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
|
||||||
|
_uiState.value = if (scene != null) {
|
||||||
|
SceneExecutionUiState.Ready(scene)
|
||||||
|
} else {
|
||||||
|
SceneExecutionUiState.Failed("Scene not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun execute() {
|
||||||
|
val current = _uiState.value
|
||||||
|
val scene = when (current) {
|
||||||
|
is SceneExecutionUiState.Ready -> current.scene
|
||||||
|
is SceneExecutionUiState.Completed -> current.result.scene
|
||||||
|
else -> return
|
||||||
|
}
|
||||||
|
_uiState.value = SceneExecutionUiState.Running(scene)
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile()
|
||||||
|
if (profile == null || profile.id != scene.profileId) {
|
||||||
|
_uiState.value = SceneExecutionUiState.Failed("Active profile mismatch")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
when (val result = sceneExecutor.execute(scene)) {
|
||||||
|
is DomainResult.Success -> _uiState.value = SceneExecutionUiState.Completed(result.value)
|
||||||
|
is DomainResult.Failure -> _uiState.value = SceneExecutionUiState.Failed(
|
||||||
|
result.error.detail ?: "Execution failed",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,417 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.Edit
|
||||||
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
|
import androidx.compose.material.icons.outlined.Bolt
|
||||||
|
import androidx.compose.material.icons.outlined.Schedule
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExtendedFloatingActionButton
|
||||||
|
import androidx.compose.material3.FilledTonalButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedCard
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SecondaryTabRow
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Switch
|
||||||
|
import androidx.compose.material3.Tab
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.flagship.abox.manager.R
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SceneListScreen(
|
||||||
|
onAddManual: () -> Unit,
|
||||||
|
onAddAutomatic: () -> Unit,
|
||||||
|
onEditScene: (SceneId) -> Unit,
|
||||||
|
onExecuteScene: (SceneId) -> Unit,
|
||||||
|
headerContent: @Composable (() -> Unit)? = null,
|
||||||
|
viewModel: SceneListViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val scenes by viewModel.scenes.collectAsStateWithLifecycle()
|
||||||
|
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
|
||||||
|
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
|
||||||
|
var showFabMenu by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
viewModel.userMessage.collectLatest { msg ->
|
||||||
|
if (msg == "executed") {
|
||||||
|
Toast.makeText(context, context.getString(R.string.scene_toast_executed), Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
floatingActionButton = {
|
||||||
|
Box {
|
||||||
|
ExtendedFloatingActionButton(
|
||||||
|
onClick = { showFabMenu = true },
|
||||||
|
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||||
|
text = { Text(stringResource(R.string.scene_create_action)) },
|
||||||
|
)
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showFabMenu,
|
||||||
|
onDismissRequest = { showFabMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.scene_create_manual)) },
|
||||||
|
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
showFabMenu = false
|
||||||
|
onAddManual()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.scene_create_automatic)) },
|
||||||
|
leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
showFabMenu = false
|
||||||
|
onAddAutomatic()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) { innerPadding ->
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(
|
||||||
|
start = 16.dp,
|
||||||
|
end = 16.dp,
|
||||||
|
top = 12.dp,
|
||||||
|
bottom = innerPadding.calculateBottomPadding() + 80.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (headerContent != null) {
|
||||||
|
item(key = "header") {
|
||||||
|
headerContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item(key = "tabs") {
|
||||||
|
SecondaryTabRow(
|
||||||
|
selectedTabIndex = selectedTab.ordinal,
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
divider = {},
|
||||||
|
modifier = Modifier.fillMaxWidth().clip(RoundedCornerShape(12.dp)),
|
||||||
|
) {
|
||||||
|
SceneFilterTab.entries.forEach { tab ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == tab,
|
||||||
|
onClick = { viewModel.selectTab(tab) },
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = when (tab) {
|
||||||
|
SceneFilterTab.ALL -> stringResource(R.string.scene_tab_all)
|
||||||
|
SceneFilterTab.MANUAL -> stringResource(R.string.scene_tab_manual)
|
||||||
|
SceneFilterTab.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
|
||||||
|
},
|
||||||
|
fontWeight = if (selectedTab == tab) FontWeight.Bold else FontWeight.Normal,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scenes.isEmpty()) {
|
||||||
|
item(key = "empty") {
|
||||||
|
EmptySceneList(modifier = Modifier.fillMaxWidth().padding(vertical = 48.dp))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(scenes, key = { it.id.value }) { scene ->
|
||||||
|
SceneCard(
|
||||||
|
scene = scene,
|
||||||
|
onClick = {
|
||||||
|
if (scene.type == AutomationType.MANUAL) {
|
||||||
|
onExecuteScene(scene.id)
|
||||||
|
} else {
|
||||||
|
onEditScene(scene.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onQuickExecute = { viewModel.executeQuick(scene) },
|
||||||
|
onToggleEnabled = { viewModel.toggleEnabled(scene) },
|
||||||
|
onEdit = { onEditScene(scene.id) },
|
||||||
|
onDelete = { viewModel.markPendingDelete(scene.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingDelete?.let { sceneId ->
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = viewModel::cancelDelete,
|
||||||
|
title = { Text(stringResource(R.string.scene_delete_dialog_title)) },
|
||||||
|
text = { Text(stringResource(R.string.scene_delete_dialog_message)) },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = { viewModel.deleteScene(sceneId) }) {
|
||||||
|
Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = viewModel::cancelDelete) {
|
||||||
|
Text(stringResource(R.string.scene_delete_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SceneCard(
|
||||||
|
scene: Scene,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onQuickExecute: () -> Unit,
|
||||||
|
onToggleEnabled: () -> Unit,
|
||||||
|
onEdit: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
val accentColor = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary
|
||||||
|
|
||||||
|
OutlinedCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.outlinedCardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
),
|
||||||
|
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.background(
|
||||||
|
color = accentColor.copy(alpha = 0.12f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = scene.icon.displayEmoji(),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = scene.name,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
TypeBadge(type = scene.type)
|
||||||
|
}
|
||||||
|
if (scene.description.isNotBlank()) {
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = scene.description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scene.type == AutomationType.AUTOMATIC) {
|
||||||
|
Switch(
|
||||||
|
checked = scene.enabled,
|
||||||
|
onCheckedChange = { onToggleEnabled() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Trigger & Action summary and controls
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
// Summary text without nested heavy container shells
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
if (scene.type == AutomationType.AUTOMATIC) {
|
||||||
|
Text(
|
||||||
|
text = if (scene.triggers.isNotEmpty()) {
|
||||||
|
stringResource(R.string.scene_summary_triggers_and_actions, scene.triggers.size, scene.actions.size)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.scene_summary_actions_only, scene.actions.size)
|
||||||
|
},
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scene_summary_actions_only, scene.actions.size),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (scene.type == AutomationType.MANUAL) {
|
||||||
|
FilledTonalButton(
|
||||||
|
onClick = onQuickExecute,
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
modifier = Modifier.height(32.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
) {
|
||||||
|
Icon(Icons.Filled.PlayArrow, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Text(stringResource(R.string.scene_action_run_now), style = MaterialTheme.typography.labelMedium)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
IconButton(onClick = onEdit, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(Icons.Filled.Edit, contentDescription = stringResource(R.string.scene_edit), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||||
|
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TypeBadge(type: AutomationType) {
|
||||||
|
val label = when (type) {
|
||||||
|
AutomationType.MANUAL -> stringResource(R.string.scene_tab_manual)
|
||||||
|
AutomationType.AUTOMATIC -> stringResource(R.string.scene_tab_automatic)
|
||||||
|
}
|
||||||
|
Surface(
|
||||||
|
shape = RoundedCornerShape(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
border = BorderStroke(0.5.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EmptySceneList(modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⚡",
|
||||||
|
style = MaterialTheme.typography.displayMedium,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scene_empty_title),
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Spacer(Modifier.size(8.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.scene_empty_message),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun SceneIcon.displayEmoji(): String = when (this) {
|
||||||
|
SceneIcon.HOME -> "🏠"
|
||||||
|
SceneIcon.OFFICE -> "💼"
|
||||||
|
SceneIcon.NIGHT -> "🌙"
|
||||||
|
SceneIcon.MOVIE -> "🎬"
|
||||||
|
SceneIcon.AWAY -> "🚶"
|
||||||
|
SceneIcon.READING -> "📖"
|
||||||
|
SceneIcon.DINING -> "🍽️"
|
||||||
|
SceneIcon.GAMING -> "🎮"
|
||||||
|
SceneIcon.THERMOSTAT -> "🌡️"
|
||||||
|
SceneIcon.LIGHT -> "💡"
|
||||||
|
SceneIcon.CLEAN -> "🧹"
|
||||||
|
SceneIcon.CUSTOM -> "✨"
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun SceneColor.toColorOrNull(): Color? {
|
||||||
|
val cleanHex = hex.removePrefix("#")
|
||||||
|
return runCatching { Color(cleanHex.toLong(16) or 0xFF000000.toInt().toLong()) }.getOrNull()
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import javax.inject.Inject
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
enum class SceneFilterTab {
|
||||||
|
ALL,
|
||||||
|
MANUAL,
|
||||||
|
AUTOMATIC,
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@HiltViewModel
|
||||||
|
class SceneListViewModel @Inject constructor(
|
||||||
|
private val sceneRepository: SceneRepository,
|
||||||
|
private val sceneExecutor: SceneExecutor,
|
||||||
|
private val profileStore: ProviderProfileStore,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _selectedTab = MutableStateFlow(SceneFilterTab.ALL)
|
||||||
|
val selectedTab: StateFlow<SceneFilterTab> = _selectedTab.asStateFlow()
|
||||||
|
|
||||||
|
private val allScenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
|
||||||
|
.flatMapLatest { profile ->
|
||||||
|
if (profile == null) {
|
||||||
|
flowOf(DomainResult.Success(emptyList()))
|
||||||
|
} else {
|
||||||
|
sceneRepository.observeScenes(profile.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map { result -> (result as? DomainResult.Success)?.value.orEmpty() }
|
||||||
|
.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val scenes: StateFlow<List<Scene>> = combine(allScenes, _selectedTab) { list, tab ->
|
||||||
|
when (tab) {
|
||||||
|
SceneFilterTab.ALL -> list
|
||||||
|
SceneFilterTab.MANUAL -> list.filter { it.type == AutomationType.MANUAL }
|
||||||
|
SceneFilterTab.AUTOMATIC -> list.filter { it.type == AutomationType.AUTOMATIC }
|
||||||
|
}
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _pendingDelete = MutableStateFlow<SceneId?>(null)
|
||||||
|
val pendingDelete: StateFlow<SceneId?> = _pendingDelete.asStateFlow()
|
||||||
|
|
||||||
|
private val _userMessage = MutableSharedFlow<String>()
|
||||||
|
val userMessage: SharedFlow<String> = _userMessage.asSharedFlow()
|
||||||
|
|
||||||
|
fun selectTab(tab: SceneFilterTab) {
|
||||||
|
_selectedTab.value = tab
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleEnabled(scene: Scene) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
|
val updated = scene.copy(enabled = !scene.enabled)
|
||||||
|
sceneRepository.updateScene(profile.id, updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun executeQuick(scene: Scene) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sceneExecutor.execute(scene)
|
||||||
|
_userMessage.emit("executed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteScene(sceneId: SceneId) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val profile = profileStore.activeProfile() ?: return@launch
|
||||||
|
sceneRepository.deleteScene(profile.id, sceneId)
|
||||||
|
_pendingDelete.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelDelete() {
|
||||||
|
_pendingDelete.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markPendingDelete(sceneId: SceneId) {
|
||||||
|
_pendingDelete.value = sceneId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.navigation.NavGraphBuilder
|
||||||
|
import androidx.navigation.NavHostController
|
||||||
|
import androidx.navigation.NavType
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
|
import androidx.navigation.navArgument
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene navigation routes.
|
||||||
|
*/
|
||||||
|
object SceneRoutes {
|
||||||
|
const val LIST = "scene/list"
|
||||||
|
const val EDITOR = "scene/editor"
|
||||||
|
const val EDITOR_MANUAL = "scene/editor/manual"
|
||||||
|
const val EDITOR_AUTOMATIC = "scene/editor/automatic"
|
||||||
|
const val EDITOR_WITH_ID = "scene/editor/{sceneId}"
|
||||||
|
const val EXECUTION = "scene/execution/{sceneId}"
|
||||||
|
|
||||||
|
fun editorFor(sceneId: SceneId): String = "scene/editor/${sceneId.value}"
|
||||||
|
fun executionFor(sceneId: SceneId): String = "scene/execution/${sceneId.value}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedded navigation host for the scenes tab in [MainAppShell].
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun SceneNavHost(
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
navController: NavHostController = rememberNavController(),
|
||||||
|
) {
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = SceneRoutes.LIST,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
sceneListDestination(
|
||||||
|
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
|
||||||
|
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
|
||||||
|
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
|
||||||
|
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
|
||||||
|
)
|
||||||
|
sceneEditorDestination(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
onSaved = {
|
||||||
|
navController.popBackStack(SceneRoutes.LIST, inclusive = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sceneExecutionDestination(
|
||||||
|
onBack = { navController.popBackStack() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavGraphBuilder.sceneListDestination(
|
||||||
|
onAddManual: () -> Unit,
|
||||||
|
onAddAutomatic: () -> Unit,
|
||||||
|
onEditScene: (SceneId) -> Unit,
|
||||||
|
onExecuteScene: (SceneId) -> Unit,
|
||||||
|
headerContent: @Composable (() -> Unit)? = null,
|
||||||
|
) {
|
||||||
|
composable(SceneRoutes.LIST) {
|
||||||
|
SceneListScreen(
|
||||||
|
onAddManual = onAddManual,
|
||||||
|
onAddAutomatic = onAddAutomatic,
|
||||||
|
onEditScene = onEditScene,
|
||||||
|
onExecuteScene = onExecuteScene,
|
||||||
|
headerContent = headerContent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavGraphBuilder.sceneEditorDestination(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onSaved: () -> Unit,
|
||||||
|
) {
|
||||||
|
composable(SceneRoutes.EDITOR) {
|
||||||
|
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
|
||||||
|
}
|
||||||
|
composable(SceneRoutes.EDITOR_MANUAL) {
|
||||||
|
SceneEditorScreen(sceneId = null, initialType = AutomationType.MANUAL, onBack = onBack, onSaved = onSaved)
|
||||||
|
}
|
||||||
|
composable(SceneRoutes.EDITOR_AUTOMATIC) {
|
||||||
|
SceneEditorScreen(sceneId = null, initialType = AutomationType.AUTOMATIC, onBack = onBack, onSaved = onSaved)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = SceneRoutes.EDITOR_WITH_ID,
|
||||||
|
arguments = listOf(navArgument("sceneId") { type = NavType.StringType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val id = backStackEntry.arguments?.getString("sceneId")
|
||||||
|
SceneEditorScreen(
|
||||||
|
sceneId = id?.let { SceneId(it) },
|
||||||
|
initialType = null,
|
||||||
|
onBack = onBack,
|
||||||
|
onSaved = onSaved,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun NavGraphBuilder.sceneExecutionDestination(
|
||||||
|
onBack: () -> Unit,
|
||||||
|
) {
|
||||||
|
composable(
|
||||||
|
route = SceneRoutes.EXECUTION,
|
||||||
|
arguments = listOf(navArgument("sceneId") { type = NavType.StringType }),
|
||||||
|
) { backStackEntry ->
|
||||||
|
val id = backStackEntry.arguments?.getString("sceneId")
|
||||||
|
SceneExecutionScreen(
|
||||||
|
sceneId = SceneId(requireNotNull(id) { "sceneId argument is required" }),
|
||||||
|
onBack = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,167 @@
|
|||||||
<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 & Automation strings -->
|
||||||
|
<string name="scene_list_title">自动化与情景</string>
|
||||||
|
<string name="scene_tab_all">全部</string>
|
||||||
|
<string name="scene_tab_manual">一键指令</string>
|
||||||
|
<string name="scene_tab_automatic">自动指令</string>
|
||||||
|
<string name="scene_create_manual">建立一键指令</string>
|
||||||
|
<string name="scene_create_automatic">建立自动指令</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>
|
||||||
|
<string name="scene_action_run_now">立即执行</string>
|
||||||
|
<string name="scene_toast_executed">指令已触发执行</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_manual_title">建立一键指令</string>
|
||||||
|
<string name="scene_editor_auto_title">建立自动指令</string>
|
||||||
|
<string name="scene_editor_edit_title">编辑指令</string>
|
||||||
|
<string name="scene_back">返回</string>
|
||||||
|
<string name="scene_name_label">标题</string>
|
||||||
|
<string name="scene_name_hint">请输入指令标题(如:回家一键开灯、高温自动降温)</string>
|
||||||
|
<string name="scene_description_label">描述</string>
|
||||||
|
<string name="scene_description_hint">可填写该指令的详细说明或备注</string>
|
||||||
|
|
||||||
|
<!-- Sections -->
|
||||||
|
<string name="scene_section_triggers">触发器</string>
|
||||||
|
<string name="scene_section_conditions">条件(可选)</string>
|
||||||
|
<string name="scene_section_actions">动作</string>
|
||||||
|
<string name="scene_section_advanced">进阶选项</string>
|
||||||
|
<string name="scene_section_other">其他选项与外观</string>
|
||||||
|
|
||||||
|
<!-- Add Buttons -->
|
||||||
|
<string name="scene_add_trigger">+ 新增触发器</string>
|
||||||
|
<string name="scene_add_condition">+ 新增条件</string>
|
||||||
|
<string name="scene_add_action_btn">+ 新增动作</string>
|
||||||
|
<string name="scene_add_action">添加</string>
|
||||||
|
<string name="scene_save_action">保存指令</string>
|
||||||
|
|
||||||
|
<!-- Strategies & Toggles -->
|
||||||
|
<string name="scene_repeat_strategy">重复执行策略</string>
|
||||||
|
<string name="scene_strategy_continue">继续执行新任务</string>
|
||||||
|
<string name="scene_concurrency_mode">多指令同步策略</string>
|
||||||
|
<string name="scene_concurrency_sync">同步 (串行执行)</string>
|
||||||
|
<string name="scene_concurrency_async">异步 (并发执行)</string>
|
||||||
|
<string name="scene_error_policy">遇到错误时</string>
|
||||||
|
<string name="scene_policy_continue">继续执行后续动作</string>
|
||||||
|
<string name="scene_policy_abort">立即中止剩余动作</string>
|
||||||
|
<string name="scene_appearance_icon">图示</string>
|
||||||
|
<string name="scene_appearance_color">图示背景色</string>
|
||||||
|
<string name="scene_enable_rule">启用该自动指令</string>
|
||||||
|
<string name="scene_enable_logging">开启指令执行记录</string>
|
||||||
|
|
||||||
|
<!-- Trigger Types & Dialog -->
|
||||||
|
<string name="scene_dialog_add_trigger_title">选择触发器类型</string>
|
||||||
|
<string name="scene_trigger_type_time">⏰ 定时时间触发</string>
|
||||||
|
<string name="scene_trigger_type_temp">🌡️ 环境温度触发</string>
|
||||||
|
<string name="scene_trigger_type_humidity">💧 环境湿度触发</string>
|
||||||
|
<string name="scene_trigger_type_device">🔌 插座状态变更触发</string>
|
||||||
|
<string name="scene_trigger_time_label">触发时间点(如:07:30)</string>
|
||||||
|
<string name="scene_trigger_temp_threshold">温度阈值 (°C)</string>
|
||||||
|
<string name="scene_trigger_humidity_threshold">湿度阈值 (%)</string>
|
||||||
|
<string name="scene_operator_label">比较条件</string>
|
||||||
|
<string name="scene_operator_gt">高于 (>)</string>
|
||||||
|
<string name="scene_operator_lt">低于 (<)</string>
|
||||||
|
<string name="scene_operator_eq">等于 (=)</string>
|
||||||
|
|
||||||
|
<!-- Condition Types & Dialog -->
|
||||||
|
<string name="scene_dialog_add_condition_title">选择限制条件</string>
|
||||||
|
<string name="scene_condition_type_temp">🌡️ 环境温度范围</string>
|
||||||
|
<string name="scene_condition_type_humidity">💧 环境湿度范围</string>
|
||||||
|
<string name="scene_condition_type_socket">🔌 插座当前电源状态</string>
|
||||||
|
<string name="scene_condition_type_time_range">🕒 生效时间区间</string>
|
||||||
|
<string name="scene_condition_start_time">起始时间(如:22:00)</string>
|
||||||
|
<string name="scene_condition_end_time">结束时间(如:07:00)</string>
|
||||||
|
|
||||||
|
<!-- Action Types & Dialog -->
|
||||||
|
<string name="scene_dialog_add_action_title">选择执行动作</string>
|
||||||
|
<string name="scene_action_type_socket">🔌 智能插座控制</string>
|
||||||
|
<string name="scene_action_type_virtual_remote">📺 红外遥控按键</string>
|
||||||
|
<string name="scene_action_type_delay">⏱️ 延时等待 (缓冲)</string>
|
||||||
|
<string name="scene_action_type_notification">🔔 发送系统提示</string>
|
||||||
|
<string name="scene_select_socket">选择目标插座</string>
|
||||||
|
<string name="scene_socket_power_state">设定电源状态</string>
|
||||||
|
<string name="scene_select_virtual_remote">选择虚拟遥控器</string>
|
||||||
|
<string name="scene_select_remote_button">选择遥控器功能按键</string>
|
||||||
|
<string name="scene_delay_duration_label">等待时长 (秒)</string>
|
||||||
|
<string name="scene_notification_title_label">提示标题</string>
|
||||||
|
<string name="scene_notification_msg_label">提示消息内容</string>
|
||||||
|
<string name="scene_unit_seconds">%1$d 秒</string>
|
||||||
|
|
||||||
|
<!-- Action Card Displays -->
|
||||||
|
<string name="scene_action_socket">插座【%1$s】: 设为 %2$s</string>
|
||||||
|
<string name="scene_action_remote">遥控【%1$s】: 发送 %2$s</string>
|
||||||
|
<string name="scene_action_infrared">红外【%1$s】: 槽位 %2$s</string>
|
||||||
|
<string name="scene_action_delay">⏱️ 等待 %1$d 秒</string>
|
||||||
|
<string name="scene_action_notification">🔔 提示: %1$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>
|
||||||
|
<string name="scene_execution_duration">总耗时:%1$s ms</string>
|
||||||
|
<string name="scene_step_pending">排队中</string>
|
||||||
|
<string name="scene_step_running">正在执行…</string>
|
||||||
|
<string name="scene_step_delaying">正在延时等待 (%1$d s)…</string>
|
||||||
|
<string name="scene_step_success">已完成</string>
|
||||||
|
<string name="scene_step_failed">执行失败</string>
|
||||||
|
<string name="scene_step_skipped">已跳过</string>
|
||||||
|
|
||||||
|
<!-- 校验与弹窗选项/占位文案 -->
|
||||||
|
<string name="scene_trigger_time_error">时间格式无效(请按 HH:mm 格式输入,如 07:30)</string>
|
||||||
|
<string name="scene_condition_time_error">时间格式无效(请按 HH:mm 格式输入,如 22:00)</string>
|
||||||
|
<string name="scene_chip_time">⏰ 定时</string>
|
||||||
|
<string name="scene_chip_temp">🌡️ 温度</string>
|
||||||
|
<string name="scene_chip_humidity">💧 湿度</string>
|
||||||
|
<string name="scene_chip_socket">🔌 插座</string>
|
||||||
|
<string name="scene_chip_remote">📺 遥控</string>
|
||||||
|
<string name="scene_chip_delay">⏱️ 延时</string>
|
||||||
|
<string name="scene_chip_notify">🔔 提示</string>
|
||||||
|
<string name="scene_chip_above">> 高于</string>
|
||||||
|
<string name="scene_chip_below">< 低于</string>
|
||||||
|
<string name="scene_chip_power_on">开启</string>
|
||||||
|
<string name="scene_chip_power_off">关闭</string>
|
||||||
|
<string name="scene_threshold_temp">设定阈值:%1$d °C</string>
|
||||||
|
<string name="scene_threshold_hum">设定阈值:%1$d %%</string>
|
||||||
|
<string name="scene_target_socket_state">要求插座当前状态为:</string>
|
||||||
|
<string name="scene_no_virtual_remotes">未找到虚拟遥控器,将默认使用红外控制器</string>
|
||||||
|
<string name="scene_remote_button_slot_label">按键名称/槽位</string>
|
||||||
|
<string name="scene_remote_button_name_label">按键名称</string>
|
||||||
|
<string name="scene_empty_triggers_hint">尚未添加触发器,请点击下方按钮添加</string>
|
||||||
|
<string name="scene_empty_conditions_hint">尚未添加限制条件,请点击下方按钮添加</string>
|
||||||
|
<string name="scene_empty_actions_hint">尚未添加执行动作,请点击下方按钮添加</string>
|
||||||
|
<string name="scene_summary_triggers_and_actions">⚡ %1$d 个触发器 • %2$d 个动作</string>
|
||||||
|
<string name="scene_summary_actions_only">%1$d 个执行动作</string>
|
||||||
|
<string name="scene_exec_ready_hint">点击下方按钮立即开始执行</string>
|
||||||
|
<string name="scene_exec_rerun">重新执行</string>
|
||||||
|
<string name="scene_step_format">步骤 %1$d:%2$s</string>
|
||||||
|
<string name="scene_trigger_desc_temp">温度 %1$s %2$s°C</string>
|
||||||
|
<string name="scene_trigger_desc_humidity">湿度 %1$s %2$s%%</string>
|
||||||
|
<string name="scene_trigger_desc_device">%1$s 状态变为 %2$s</string>
|
||||||
|
<string name="scene_condition_desc_temp">温度需 %1$s %2$s°C</string>
|
||||||
|
<string name="scene_condition_desc_humidity">湿度需 %1$s %2$s%%</string>
|
||||||
|
<string name="scene_condition_desc_socket">%1$s 处于 %2$s</string>
|
||||||
|
<string name="scene_condition_desc_time">时间在 %1$s ~ %2$s 之间</string>
|
||||||
|
<string name="scene_device_name_label">设备名称(网关配对标识)</string>
|
||||||
|
<string name="scene_loading_scene">正在加载情景配置…</string>
|
||||||
|
<string name="scene_load_failed">无法加载情景配置。</string>
|
||||||
|
<string name="scene_default_button_power">电源</string>
|
||||||
|
<string name="scene_default_notify_title">情景完成</string>
|
||||||
|
<string name="scene_default_notify_msg">自动化任务执行完成</string>
|
||||||
|
|
||||||
<!-- 设备列表 -->
|
<!-- 设备列表 -->
|
||||||
<string name="device_list_title">设备列表</string>
|
<string name="device_list_title">设备列表</string>
|
||||||
<string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string>
|
<string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string>
|
||||||
|
|||||||
@@ -42,6 +42,167 @@
|
|||||||
<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 & Automation strings -->
|
||||||
|
<string name="scene_list_title">Automation & Scenes</string>
|
||||||
|
<string name="scene_tab_all">All</string>
|
||||||
|
<string name="scene_tab_manual">Manual</string>
|
||||||
|
<string name="scene_tab_automatic">Automatic</string>
|
||||||
|
<string name="scene_create_manual">New Manual Command</string>
|
||||||
|
<string name="scene_create_automatic">New Automatic Rule</string>
|
||||||
|
<string name="scene_create_action">New Command</string>
|
||||||
|
<string name="scene_action_count">%1$d actions</string>
|
||||||
|
<string name="scene_edit">Edit Command</string>
|
||||||
|
<string name="scene_delete">Delete Command</string>
|
||||||
|
<string name="scene_empty_title">No automation commands yet</string>
|
||||||
|
<string name="scene_empty_message">Tap the button below to create instant commands or trigger-based automatic rules.</string>
|
||||||
|
<string name="scene_action_run_now">Run Now</string>
|
||||||
|
<string name="scene_toast_executed">Command triggered</string>
|
||||||
|
|
||||||
|
<!-- Scene: delete dialog -->
|
||||||
|
<string name="scene_delete_dialog_title">Delete Command</string>
|
||||||
|
<string name="scene_delete_dialog_message">Are you sure you want to delete this command? This cannot be undone.</string>
|
||||||
|
<string name="scene_delete_confirm">Delete</string>
|
||||||
|
<string name="scene_delete_cancel">Cancel</string>
|
||||||
|
|
||||||
|
<!-- Scene: editor -->
|
||||||
|
<string name="scene_editor_manual_title">Create Manual Command</string>
|
||||||
|
<string name="scene_editor_auto_title">Create Automatic Rule</string>
|
||||||
|
<string name="scene_editor_edit_title">Edit Command</string>
|
||||||
|
<string name="scene_back">Back</string>
|
||||||
|
<string name="scene_name_label">Title</string>
|
||||||
|
<string name="scene_name_hint">Enter title (e.g. Movie Night, High Temp Auto Cooling)</string>
|
||||||
|
<string name="scene_description_label">Description</string>
|
||||||
|
<string name="scene_description_hint">Optional notes or details about this rule</string>
|
||||||
|
|
||||||
|
<!-- Sections -->
|
||||||
|
<string name="scene_section_triggers">Triggers</string>
|
||||||
|
<string name="scene_section_conditions">Conditions (Optional)</string>
|
||||||
|
<string name="scene_section_actions">Actions</string>
|
||||||
|
<string name="scene_section_advanced">Advanced Options</string>
|
||||||
|
<string name="scene_section_other">Other Options & Appearance</string>
|
||||||
|
|
||||||
|
<!-- Add Buttons -->
|
||||||
|
<string name="scene_add_trigger">+ Add Trigger</string>
|
||||||
|
<string name="scene_add_condition">+ Add Condition</string>
|
||||||
|
<string name="scene_add_action_btn">+ Add Action</string>
|
||||||
|
<string name="scene_add_action">Add</string>
|
||||||
|
<string name="scene_save_action">Save Command</string>
|
||||||
|
|
||||||
|
<!-- Strategies & Toggles -->
|
||||||
|
<string name="scene_repeat_strategy">Repeat Execution Strategy</string>
|
||||||
|
<string name="scene_strategy_continue">Continue new task</string>
|
||||||
|
<string name="scene_concurrency_mode">Concurrency Mode</string>
|
||||||
|
<string name="scene_concurrency_sync">Synchronous (Sequential)</string>
|
||||||
|
<string name="scene_concurrency_async">Asynchronous (Parallel)</string>
|
||||||
|
<string name="scene_error_policy">On Error Policy</string>
|
||||||
|
<string name="scene_policy_continue">Continue subsequent actions</string>
|
||||||
|
<string name="scene_policy_abort">Abort remaining actions</string>
|
||||||
|
<string name="scene_appearance_icon">Icon</string>
|
||||||
|
<string name="scene_appearance_color">Icon Background Color</string>
|
||||||
|
<string name="scene_enable_rule">Enable this automatic rule</string>
|
||||||
|
<string name="scene_enable_logging">Enable command execution logs</string>
|
||||||
|
|
||||||
|
<!-- Trigger Types & Dialog -->
|
||||||
|
<string name="scene_dialog_add_trigger_title">Select Trigger Type</string>
|
||||||
|
<string name="scene_trigger_type_time">⏰ Time / Schedule Trigger</string>
|
||||||
|
<string name="scene_trigger_type_temp">🌡️ Temperature Threshold</string>
|
||||||
|
<string name="scene_trigger_type_humidity">💧 Humidity Threshold</string>
|
||||||
|
<string name="scene_trigger_type_device">🔌 Socket State Changed</string>
|
||||||
|
<string name="scene_trigger_time_label">Trigger Time (e.g. 07:30)</string>
|
||||||
|
<string name="scene_trigger_temp_threshold">Temperature Threshold (°C)</string>
|
||||||
|
<string name="scene_trigger_humidity_threshold">Humidity Threshold (%)</string>
|
||||||
|
<string name="scene_operator_label">Condition</string>
|
||||||
|
<string name="scene_operator_gt">Greater than (>)</string>
|
||||||
|
<string name="scene_operator_lt">Less than (<)</string>
|
||||||
|
<string name="scene_operator_eq">Equals (=)</string>
|
||||||
|
|
||||||
|
<!-- Condition Types & Dialog -->
|
||||||
|
<string name="scene_dialog_add_condition_title">Select Condition</string>
|
||||||
|
<string name="scene_condition_type_temp">🌡️ Temperature Range</string>
|
||||||
|
<string name="scene_condition_type_humidity">💧 Humidity Range</string>
|
||||||
|
<string name="scene_condition_type_socket">🔌 Current Socket Power</string>
|
||||||
|
<string name="scene_condition_type_time_range">🕒 Active Time Range</string>
|
||||||
|
<string name="scene_condition_start_time">Start Time (e.g. 22:00)</string>
|
||||||
|
<string name="scene_condition_end_time">End Time (e.g. 07:00)</string>
|
||||||
|
|
||||||
|
<!-- Action Types & Dialog -->
|
||||||
|
<string name="scene_dialog_add_action_title">Select Action</string>
|
||||||
|
<string name="scene_action_type_socket">🔌 Smart Socket Control</string>
|
||||||
|
<string name="scene_action_type_virtual_remote">📺 Infrared Remote Button</string>
|
||||||
|
<string name="scene_action_type_delay">⏱️ Delay / Buffer Timer</string>
|
||||||
|
<string name="scene_action_type_notification">🔔 System Notification</string>
|
||||||
|
<string name="scene_select_socket">Select Target Socket</string>
|
||||||
|
<string name="scene_socket_power_state">Target Power State</string>
|
||||||
|
<string name="scene_select_virtual_remote">Select Virtual Remote</string>
|
||||||
|
<string name="scene_select_remote_button">Select Remote Key</string>
|
||||||
|
<string name="scene_delay_duration_label">Delay Duration (seconds)</string>
|
||||||
|
<string name="scene_notification_title_label">Notification Title</string>
|
||||||
|
<string name="scene_notification_msg_label">Notification Message</string>
|
||||||
|
<string name="scene_unit_seconds">%1$d sec</string>
|
||||||
|
|
||||||
|
<!-- Action Card Displays -->
|
||||||
|
<string name="scene_action_socket">Socket [%1$s]: Set %2$s</string>
|
||||||
|
<string name="scene_action_remote">Remote [%1$s]: Send %2$s</string>
|
||||||
|
<string name="scene_action_infrared">Infrared [%1$s]: Slot %2$s</string>
|
||||||
|
<string name="scene_action_delay">⏱️ Delay %1$d seconds</string>
|
||||||
|
<string name="scene_action_notification">🔔 Notify: %1$s</string>
|
||||||
|
|
||||||
|
<!-- Scene: execution -->
|
||||||
|
<string name="scene_execution_title">Execute Command</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">Execution Completed</string>
|
||||||
|
<string name="scene_execution_duration">Total Elapsed: %1$s ms</string>
|
||||||
|
<string name="scene_step_pending">Pending</string>
|
||||||
|
<string name="scene_step_running">Running…</string>
|
||||||
|
<string name="scene_step_delaying">Waiting (%1$d s)…</string>
|
||||||
|
<string name="scene_step_success">Completed</string>
|
||||||
|
<string name="scene_step_failed">Failed</string>
|
||||||
|
<string name="scene_step_skipped">Skipped</string>
|
||||||
|
|
||||||
|
<!-- Validation & dialog chips / placeholders -->
|
||||||
|
<string name="scene_trigger_time_error">Invalid time format (use HH:mm, e.g. 07:30)</string>
|
||||||
|
<string name="scene_condition_time_error">Invalid time format (use HH:mm, e.g. 22:00)</string>
|
||||||
|
<string name="scene_chip_time">⏰ Schedule</string>
|
||||||
|
<string name="scene_chip_temp">🌡️ Temperature</string>
|
||||||
|
<string name="scene_chip_humidity">💧 Humidity</string>
|
||||||
|
<string name="scene_chip_socket">🔌 Socket</string>
|
||||||
|
<string name="scene_chip_remote">📺 Remote</string>
|
||||||
|
<string name="scene_chip_delay">⏱️ Delay</string>
|
||||||
|
<string name="scene_chip_notify">🔔 Notify</string>
|
||||||
|
<string name="scene_chip_above">> Above</string>
|
||||||
|
<string name="scene_chip_below">< Below</string>
|
||||||
|
<string name="scene_chip_power_on">Turn ON</string>
|
||||||
|
<string name="scene_chip_power_off">Turn OFF</string>
|
||||||
|
<string name="scene_threshold_temp">Target threshold: %1$d °C</string>
|
||||||
|
<string name="scene_threshold_hum">Target threshold: %1$d %%</string>
|
||||||
|
<string name="scene_target_socket_state">Require socket state to be:</string>
|
||||||
|
<string name="scene_no_virtual_remotes">No virtual remotes found; using infrared controller by default.</string>
|
||||||
|
<string name="scene_remote_button_slot_label">Button Name / Slot</string>
|
||||||
|
<string name="scene_remote_button_name_label">Button Name</string>
|
||||||
|
<string name="scene_empty_triggers_hint">No triggers configured. Tap + below to add.</string>
|
||||||
|
<string name="scene_empty_conditions_hint">No conditions configured. Tap + below to add.</string>
|
||||||
|
<string name="scene_empty_actions_hint">No actions configured. Tap + below to add.</string>
|
||||||
|
<string name="scene_summary_triggers_and_actions">⚡ %1$d triggers • %2$d actions</string>
|
||||||
|
<string name="scene_summary_actions_only">%1$d actions</string>
|
||||||
|
<string name="scene_exec_ready_hint">Tap the button below to run immediately</string>
|
||||||
|
<string name="scene_exec_rerun">Re-run</string>
|
||||||
|
<string name="scene_step_format">Step %1$d: %2$s</string>
|
||||||
|
<string name="scene_trigger_desc_temp">Temp %1$s %2$s°C</string>
|
||||||
|
<string name="scene_trigger_desc_humidity">Humidity %1$s %2$s%%</string>
|
||||||
|
<string name="scene_trigger_desc_device">%1$s state changed to %2$s</string>
|
||||||
|
<string name="scene_condition_desc_temp">Temp must be %1$s %2$s°C</string>
|
||||||
|
<string name="scene_condition_desc_humidity">Humidity must be %1$s %2$s%%</string>
|
||||||
|
<string name="scene_condition_desc_socket">%1$s is %2$s</string>
|
||||||
|
<string name="scene_condition_desc_time">Time is between %1$s ~ %2$s</string>
|
||||||
|
<string name="scene_device_name_label">Device Name (Gateway Identifier)</string>
|
||||||
|
<string name="scene_loading_scene">Loading scene...</string>
|
||||||
|
<string name="scene_load_failed">Failed to load scene.</string>
|
||||||
|
<string name="scene_default_button_power">Power</string>
|
||||||
|
<string name="scene_default_notify_title">Scene Completed</string>
|
||||||
|
<string name="scene_default_notify_msg">Automation task executed successfully</string>
|
||||||
|
|
||||||
<!-- Device list -->
|
<!-- Device list -->
|
||||||
<string name="device_list_title">Device List</string>
|
<string name="device_list_title">Device List</string>
|
||||||
<string name="device_list_description">Select an ABox terminal device to control or check status.</string>
|
<string name="device_list_description">Select an ABox terminal device to control or check status.</string>
|
||||||
|
|||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.ComparisonOperator
|
||||||
|
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneCondition
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneTrigger
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SceneEditorViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val profileStore = FakeProviderProfileStore()
|
||||||
|
private val sceneRepository = FakeSceneRepository()
|
||||||
|
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
||||||
|
private val profileId = ProviderProfileId("local-experience")
|
||||||
|
|
||||||
|
private val existingScene = Scene(
|
||||||
|
id = SceneId("local-experience:scene-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Existing Scene",
|
||||||
|
icon = SceneIcon.HOME,
|
||||||
|
color = SceneColor("#FF8A65"),
|
||||||
|
actions = listOf(
|
||||||
|
SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("local-experience:action-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun setupProfile() {
|
||||||
|
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
|
||||||
|
profileStore.setActiveProfile(profileId)
|
||||||
|
sceneRepository.setScenes(profileId, listOf(existingScene))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initializeWithNullPreparesNewScene() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
|
|
||||||
|
val state = viewModel.state.value
|
||||||
|
assertFalse(state.isEditing)
|
||||||
|
assertEquals("", state.name)
|
||||||
|
assertTrue(state.actions.isEmpty())
|
||||||
|
assertFalse(state.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initializeWithExistingSceneLoadsState() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
|
viewModel.initialize(existingScene.id)
|
||||||
|
|
||||||
|
// Immediately binds sceneId and enters loading state
|
||||||
|
assertTrue(viewModel.state.value.isEditing)
|
||||||
|
assertEquals(existingScene.id, viewModel.state.value.sceneId)
|
||||||
|
assertTrue(viewModel.state.value.isLoading)
|
||||||
|
assertFalse(viewModel.state.value.isValid)
|
||||||
|
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.state.value
|
||||||
|
assertTrue(state.isEditing)
|
||||||
|
assertFalse(state.isLoading)
|
||||||
|
assertEquals("Existing Scene", state.name)
|
||||||
|
assertEquals(1, state.actions.size)
|
||||||
|
assertTrue(state.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addingAndReorderingActionsUpdatesState() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
|
viewModel.updateName("My New Scene")
|
||||||
|
|
||||||
|
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||||
|
viewModel.addDelayAction(3)
|
||||||
|
viewModel.addNotificationAction("Done", "Completed")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(3, viewModel.state.value.actions.size)
|
||||||
|
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
|
||||||
|
assertTrue(viewModel.state.value.actions[1] is SceneAction.Delay)
|
||||||
|
assertTrue(viewModel.state.value.actions[2] is SceneAction.Notification)
|
||||||
|
|
||||||
|
// Move second to first
|
||||||
|
viewModel.moveAction(1, 0)
|
||||||
|
assertTrue(viewModel.state.value.actions[0] is SceneAction.Delay)
|
||||||
|
assertTrue(viewModel.state.value.actions[1] is SceneAction.SocketPower)
|
||||||
|
|
||||||
|
// Remove first action
|
||||||
|
val firstId = viewModel.state.value.actions[0].id
|
||||||
|
viewModel.removeAction(firstId)
|
||||||
|
assertEquals(2, viewModel.state.value.actions.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addingTriggersAndConditionsForAutomaticRule() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
|
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||||
|
viewModel.updateName("Auto Rule")
|
||||||
|
|
||||||
|
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
|
||||||
|
viewModel.addSocketPowerCondition("socket-1", SocketPowerState.OFF)
|
||||||
|
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||||
|
viewModel.updateConcurrencyMode(ConcurrencyMode.PARALLEL)
|
||||||
|
viewModel.updateStrategy(ExecutionStrategy.ABORT_ON_ERROR)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val state = viewModel.state.value
|
||||||
|
assertEquals(1, state.triggers.size)
|
||||||
|
assertTrue(state.triggers[0] is SceneTrigger.Temperature)
|
||||||
|
assertEquals(1, state.conditions.size)
|
||||||
|
assertTrue(state.conditions[0] is SceneCondition.SocketPower)
|
||||||
|
assertEquals(ConcurrencyMode.PARALLEL, state.concurrencyMode)
|
||||||
|
assertEquals(ExecutionStrategy.ABORT_ON_ERROR, state.strategy)
|
||||||
|
assertTrue(state.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun saveNewScenePersistsToRepository() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
|
viewModel.initialize(null, AutomationType.MANUAL)
|
||||||
|
viewModel.updateName("Brand New Scene")
|
||||||
|
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(viewModel.state.value.isValid)
|
||||||
|
viewModel.save()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(viewModel.state.value.isSaved)
|
||||||
|
assertFalse(viewModel.state.value.isSaving)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun automaticSceneWithoutTriggersIsInvalid() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
|
viewModel.initialize(null, AutomationType.AUTOMATIC)
|
||||||
|
viewModel.updateName("Auto Without Trigger")
|
||||||
|
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Missing triggers -> invalid
|
||||||
|
assertFalse(viewModel.state.value.isValid)
|
||||||
|
|
||||||
|
// Adding trigger makes it valid
|
||||||
|
viewModel.addTemperatureTrigger("sensor-1", ComparisonOperator.GREATER_THAN, 28.0)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.state.value.isValid)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initializeIsIdempotentAndPreservesDraftState() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||||
|
viewModel.initialize(existingScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.updateName("Modified Unsaved Name")
|
||||||
|
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
|
||||||
|
|
||||||
|
// Re-invoking initialize with same sceneId should NOT overwrite draft
|
||||||
|
viewModel.initialize(existingScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals("Modified Unsaved Name", viewModel.state.value.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+125
@@ -0,0 +1,125 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SceneExecutionViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val profileStore = FakeProviderProfileStore()
|
||||||
|
private val sceneRepository = FakeSceneRepository()
|
||||||
|
private val fakeSocket = FakeSocketGateway()
|
||||||
|
private val fakeInfrared = FakeInfraredGateway()
|
||||||
|
private val sceneExecutor = FakeSceneExecutor(fakeSocket, fakeInfrared)
|
||||||
|
|
||||||
|
private val profileId = ProviderProfileId("local-experience")
|
||||||
|
private val testScene = Scene(
|
||||||
|
id = SceneId("local-experience:scene-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Execution Test Scene",
|
||||||
|
icon = SceneIcon.HOME,
|
||||||
|
color = SceneColor("#FF8A65"),
|
||||||
|
actions = listOf(
|
||||||
|
SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("local-experience:action-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun setupProfile() {
|
||||||
|
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
|
||||||
|
profileStore.setActiveProfile(profileId)
|
||||||
|
sceneRepository.setScenes(profileId, listOf(testScene))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initializeLoadsSceneStateReady() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
viewModel.initialize(testScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Ready)
|
||||||
|
val ready = viewModel.uiState.value as SceneExecutionUiState.Ready
|
||||||
|
assertEquals(testScene.id, ready.scene.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun executeRunsSceneAndTransitionsToCompleted() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
viewModel.initialize(testScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.execute()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
|
||||||
|
val completed = viewModel.uiState.value as SceneExecutionUiState.Completed
|
||||||
|
assertEquals(testScene.id, completed.result.scene.id)
|
||||||
|
assertEquals(1, completed.result.results.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun initializeIsIdempotentAndPreservesCompletedState() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
viewModel.initialize(testScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.execute()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
|
||||||
|
|
||||||
|
// Re-initializing with same sceneId should preserve Completed state
|
||||||
|
viewModel.initialize(testScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun executeFailsWhenActiveProfileMismatches() = runTest {
|
||||||
|
setupProfile()
|
||||||
|
val viewModel = SceneExecutionViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
viewModel.initialize(testScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// Switch to a different profile
|
||||||
|
profileStore.setActiveProfile(ProviderProfileId("different-profile"))
|
||||||
|
|
||||||
|
viewModel.execute()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(viewModel.uiState.value is SceneExecutionUiState.Failed)
|
||||||
|
val failed = viewModel.uiState.value as SceneExecutionUiState.Failed
|
||||||
|
assertEquals("Active profile mismatch", failed.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
package com.flagship.abox.manager.ui.scene
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.AutomationType
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSceneExecutor
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSceneRepository
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class SceneListViewModelTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val mainDispatcherRule = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val profileStore = FakeProviderProfileStore()
|
||||||
|
private val sceneRepository = FakeSceneRepository()
|
||||||
|
private val socketGateway = FakeSocketGateway()
|
||||||
|
private val infraredGateway = FakeInfraredGateway()
|
||||||
|
private val sceneExecutor = FakeSceneExecutor(socketGateway, infraredGateway)
|
||||||
|
|
||||||
|
private val profileId = ProviderProfileId("local-experience")
|
||||||
|
private val manualScene = Scene(
|
||||||
|
id = SceneId("local-experience:scene-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Manual Scene",
|
||||||
|
type = AutomationType.MANUAL,
|
||||||
|
icon = SceneIcon.HOME,
|
||||||
|
color = SceneColor("#FF8A65"),
|
||||||
|
actions = listOf(
|
||||||
|
SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("local-experience:action-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
private val autoScene = Scene(
|
||||||
|
id = SceneId("local-experience:scene-2"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Auto Rule",
|
||||||
|
type = AutomationType.AUTOMATIC,
|
||||||
|
enabled = true,
|
||||||
|
icon = SceneIcon.THERMOSTAT,
|
||||||
|
color = SceneColor("#26A69A"),
|
||||||
|
actions = listOf(
|
||||||
|
SceneAction.Delay(
|
||||||
|
id = SceneActionId("local-experience:action-2"),
|
||||||
|
profileId = profileId,
|
||||||
|
durationSeconds = 5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun loadsScenesForActiveProfile() = runTest {
|
||||||
|
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
|
||||||
|
profileStore.setActiveProfile(profileId)
|
||||||
|
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
|
||||||
|
|
||||||
|
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.scenes.collect {}
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(2, viewModel.scenes.value.size)
|
||||||
|
assertEquals("Manual Scene", viewModel.scenes.value[0].name)
|
||||||
|
assertEquals("Auto Rule", viewModel.scenes.value[1].name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun filterTabFiltersScenes() = runTest {
|
||||||
|
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
|
||||||
|
profileStore.setActiveProfile(profileId)
|
||||||
|
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
|
||||||
|
|
||||||
|
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.scenes.collect {}
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.selectTab(SceneFilterTab.MANUAL)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, viewModel.scenes.value.size)
|
||||||
|
assertEquals(AutomationType.MANUAL, viewModel.scenes.value[0].type)
|
||||||
|
|
||||||
|
viewModel.selectTab(SceneFilterTab.AUTOMATIC)
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(1, viewModel.scenes.value.size)
|
||||||
|
assertEquals(AutomationType.AUTOMATIC, viewModel.scenes.value[0].type)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun toggleEnabledUpdatesSceneInRepository() = runTest {
|
||||||
|
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
|
||||||
|
profileStore.setActiveProfile(profileId)
|
||||||
|
sceneRepository.setScenes(profileId, listOf(autoScene))
|
||||||
|
|
||||||
|
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.scenes.collect {}
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.toggleEnabled(autoScene)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val updated = viewModel.scenes.value.first()
|
||||||
|
assertFalse(updated.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteSceneRemovesFromRepositoryAndClearsPending() = runTest {
|
||||||
|
profileStore.saveProfile(ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local"))
|
||||||
|
profileStore.setActiveProfile(profileId)
|
||||||
|
sceneRepository.setScenes(profileId, listOf(manualScene))
|
||||||
|
|
||||||
|
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||||
|
viewModel.scenes.collect {}
|
||||||
|
}
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
viewModel.markPendingDelete(manualScene.id)
|
||||||
|
assertEquals(manualScene.id, viewModel.pendingDelete.value)
|
||||||
|
|
||||||
|
viewModel.deleteScene(manualScene.id)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(viewModel.pendingDelete.value)
|
||||||
|
assertTrue(viewModel.scenes.value.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelDeleteClearsPendingState() = runTest {
|
||||||
|
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||||
|
viewModel.markPendingDelete(manualScene.id)
|
||||||
|
assertEquals(manualScene.id, viewModel.pendingDelete.value)
|
||||||
|
|
||||||
|
viewModel.cancelDelete()
|
||||||
|
assertNull(viewModel.pendingDelete.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,3 +11,4 @@ dependencies {
|
|||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Production implementation of [SceneExecutor].
|
||||||
|
*
|
||||||
|
* Executes scene actions via [SocketGateway], [InfraredGateway], [SendVirtualRemoteAction],
|
||||||
|
* and built-in timers.
|
||||||
|
*/
|
||||||
|
class DefaultSceneExecutor(
|
||||||
|
private val socketGateway: SocketGateway,
|
||||||
|
private val infraredGateway: InfraredGateway,
|
||||||
|
private val sendVirtualRemoteAction: SendVirtualRemoteAction? = null,
|
||||||
|
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
|
||||||
|
) : SceneExecutor {
|
||||||
|
private val runningScenes = ConcurrentHashMap<SceneId, Boolean>()
|
||||||
|
|
||||||
|
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
|
||||||
|
val alreadyRunning = runningScenes.putIfAbsent(scene.id, true) != null
|
||||||
|
if (alreadyRunning) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.OperationFailed(
|
||||||
|
detail = "Scene ${scene.id.value} is already running",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
|
||||||
|
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
|
||||||
|
coroutineScope {
|
||||||
|
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
|
||||||
|
deferreds = scene.actions.mapIndexed { index, action ->
|
||||||
|
async {
|
||||||
|
val actionStart = System.currentTimeMillis()
|
||||||
|
val execResult = executeAction(scene.profileId, action)
|
||||||
|
val duration = System.currentTimeMillis() - actionStart
|
||||||
|
val enriched = execResult.copy(executionDurationMillis = duration)
|
||||||
|
if (enriched.outcome == SceneActionOutcome.FAILURE) {
|
||||||
|
deferreds.forEachIndexed { otherIndex, otherDeferred ->
|
||||||
|
if (otherIndex != index) {
|
||||||
|
otherDeferred.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enriched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deferreds.mapIndexed { index, deferred ->
|
||||||
|
try {
|
||||||
|
deferred.await()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = scene.actions[index],
|
||||||
|
outcome = SceneActionOutcome.SKIPPED,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
coroutineScope {
|
||||||
|
scene.actions.map { action ->
|
||||||
|
async {
|
||||||
|
val actionStart = System.currentTimeMillis()
|
||||||
|
val execResult = executeAction(scene.profileId, action)
|
||||||
|
val duration = System.currentTimeMillis() - actionStart
|
||||||
|
execResult.copy(executionDurationMillis = duration)
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
|
||||||
|
var aborted = false
|
||||||
|
for (action in scene.actions) {
|
||||||
|
if (aborted) {
|
||||||
|
sequentialResults.add(
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SKIPPED,
|
||||||
|
error = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
val actionStart = System.currentTimeMillis()
|
||||||
|
val execResult = executeAction(scene.profileId, action)
|
||||||
|
val duration = System.currentTimeMillis() - actionStart
|
||||||
|
val enrichedResult = execResult.copy(executionDurationMillis = duration)
|
||||||
|
sequentialResults.add(enrichedResult)
|
||||||
|
|
||||||
|
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
|
||||||
|
aborted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sequentialResults
|
||||||
|
}
|
||||||
|
|
||||||
|
val totalDuration = System.currentTimeMillis() - startTime
|
||||||
|
DomainResult.Success(
|
||||||
|
SceneExecutionResult(
|
||||||
|
scene = scene,
|
||||||
|
results = results,
|
||||||
|
totalDurationMillis = totalDuration,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
runningScenes.remove(scene.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeAction(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
action: SceneAction,
|
||||||
|
): SceneActionExecutionResult {
|
||||||
|
return try {
|
||||||
|
when (action) {
|
||||||
|
is SceneAction.SocketPower -> {
|
||||||
|
val res = socketGateway.setPower(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = action.deviceName,
|
||||||
|
powerState = action.powerState,
|
||||||
|
)
|
||||||
|
toExecutionResult(action, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.InfraredKey -> {
|
||||||
|
val res = infraredGateway.sendKey(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = action.deviceName,
|
||||||
|
key = action.key,
|
||||||
|
)
|
||||||
|
toExecutionResult(action, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.VirtualRemoteButton -> {
|
||||||
|
if (sendVirtualRemoteAction != null) {
|
||||||
|
val res = sendVirtualRemoteAction.sendButton(
|
||||||
|
profileId = profileId,
|
||||||
|
remoteId = VirtualRemoteId(action.remoteId),
|
||||||
|
buttonId = action.buttonId,
|
||||||
|
)
|
||||||
|
toExecutionResult(action, res)
|
||||||
|
} else {
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.FAILURE,
|
||||||
|
error = DomainError.OperationFailed(
|
||||||
|
detail = "Virtual remote action executor is not available",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.Delay -> {
|
||||||
|
delayDurationProvider(action.durationSeconds * 1000L)
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SUCCESS,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.Notification -> {
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SUCCESS,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.FAILURE,
|
||||||
|
error = DomainError.OperationFailed(detail = e.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toExecutionResult(action: SceneAction, res: DomainResult<*>): SceneActionExecutionResult {
|
||||||
|
return when (res) {
|
||||||
|
is DomainResult.Success -> SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SUCCESS,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
is DomainResult.Failure -> SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.FAILURE,
|
||||||
|
error = res.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider-isolated in-memory [SceneRepository] implementation that supports
|
||||||
|
* seed defaults and real-time observation.
|
||||||
|
*/
|
||||||
|
class InMemorySceneRepository : SceneRepository {
|
||||||
|
private val flows = ConcurrentHashMap<ProviderProfileId, MutableStateFlow<DomainResult<List<Scene>>>>()
|
||||||
|
private val mutexes = ConcurrentHashMap<ProviderProfileId, Mutex>()
|
||||||
|
|
||||||
|
private fun mutexFor(profileId: ProviderProfileId): Mutex =
|
||||||
|
mutexes.getOrPut(profileId) { Mutex() }
|
||||||
|
|
||||||
|
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> = mutexFor(profileId).withLock {
|
||||||
|
require(scene.profileId == profileId) {
|
||||||
|
"Scene profileId must match the supplied profileId"
|
||||||
|
}
|
||||||
|
require(scene.actions.all { it.profileId == profileId }) {
|
||||||
|
"All scene actions must belong to the supplied profileId"
|
||||||
|
}
|
||||||
|
val scenes = currentScenes(profileId).orEmpty()
|
||||||
|
if (scenes.any { it.id == scene.id }) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(
|
||||||
|
detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val updated = scenes + scene
|
||||||
|
publish(profileId, updated)
|
||||||
|
DomainResult.Success(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateScene(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
scene: Scene,
|
||||||
|
): DomainResult<Scene> = mutexFor(profileId).withLock {
|
||||||
|
require(scene.profileId == profileId) {
|
||||||
|
"Scene profileId must match the supplied profileId"
|
||||||
|
}
|
||||||
|
require(scene.actions.all { it.profileId == profileId }) {
|
||||||
|
"All scene actions must belong to the supplied profileId"
|
||||||
|
}
|
||||||
|
val scenes = currentScenes(profileId).orEmpty()
|
||||||
|
val index = scenes.indexOfFirst { it.id == scene.id }
|
||||||
|
if (index < 0) return notFound(scene.id)
|
||||||
|
val updated = scenes.toMutableList().apply { set(index, scene) }
|
||||||
|
publish(profileId, updated)
|
||||||
|
DomainResult.Success(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteScene(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
sceneId: SceneId,
|
||||||
|
): DomainResult<Unit> = mutexFor(profileId).withLock {
|
||||||
|
val scenes = currentScenes(profileId).orEmpty()
|
||||||
|
val updated = scenes.filterNot { it.id == sceneId }
|
||||||
|
if (updated.size == scenes.size) return notFound(sceneId)
|
||||||
|
publish(profileId, updated)
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seedDefaults(profileId: ProviderProfileId, scenes: List<Scene>) {
|
||||||
|
require(scenes.all { it.profileId == profileId }) {
|
||||||
|
"All scenes must belong to the supplied profileId"
|
||||||
|
}
|
||||||
|
require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) {
|
||||||
|
"All scene actions must belong to the supplied profileId"
|
||||||
|
}
|
||||||
|
publish(profileId, scenes)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flowFor(profileId: ProviderProfileId): MutableStateFlow<DomainResult<List<Scene>>> =
|
||||||
|
flows.getOrPut(profileId) {
|
||||||
|
val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(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 defaultLocalScenes(profileId: ProviderProfileId): List<Scene> = listOf(
|
||||||
|
Scene(
|
||||||
|
id = SceneId("${profileId.value}:scene-leaving-home"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "离家模式",
|
||||||
|
description = "关闭插座并延时关闭电视",
|
||||||
|
type = AutomationType.MANUAL,
|
||||||
|
enabled = true,
|
||||||
|
icon = SceneIcon.AWAY,
|
||||||
|
color = SceneColor("#FF8A65"),
|
||||||
|
actions = listOf(
|
||||||
|
SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("${profileId.value}:scene-leaving-home:socket-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("Zigbee插座控制器"),
|
||||||
|
powerState = SocketPowerState.OFF,
|
||||||
|
),
|
||||||
|
SceneAction.Delay(
|
||||||
|
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
durationSeconds = 1,
|
||||||
|
),
|
||||||
|
SceneAction.InfraredKey(
|
||||||
|
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("红外控制器"),
|
||||||
|
key = "1",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Scene(
|
||||||
|
id = SceneId("${profileId.value}:scene-cooling-auto"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "高温自动降温",
|
||||||
|
description = "当室温高于 28°C 时开启插座",
|
||||||
|
type = AutomationType.AUTOMATIC,
|
||||||
|
enabled = true,
|
||||||
|
icon = SceneIcon.HOME,
|
||||||
|
color = SceneColor("#26A69A"),
|
||||||
|
triggers = listOf(
|
||||||
|
SceneTrigger.Temperature(
|
||||||
|
id = SceneTriggerId("${profileId.value}:trigger-temp"),
|
||||||
|
deviceName = DeviceName("温湿度传感器"),
|
||||||
|
operator = ComparisonOperator.GREATER_THAN,
|
||||||
|
thresholdCelsius = 28.0,
|
||||||
|
label = "温度高于 28°C",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
conditions = listOf(
|
||||||
|
SceneCondition.SocketPower(
|
||||||
|
id = SceneConditionId("${profileId.value}:cond-socket"),
|
||||||
|
deviceName = DeviceName("Zigbee插座控制器"),
|
||||||
|
expectedState = SocketPowerState.OFF,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions = listOf(
|
||||||
|
SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("${profileId.value}:scene-cooling:socket-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("Zigbee插座控制器"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val LOCAL_EXPERIENCE_PROFILE = ProviderProfileId("local-experience")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class SceneId(val value: String)
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class SceneActionId(val value: String)
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class SceneTriggerId(val value: String)
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class SceneConditionId(val value: String)
|
||||||
|
|
||||||
|
enum class AutomationType {
|
||||||
|
MANUAL, // 一键指令 (One-Tap / Instant Command)
|
||||||
|
AUTOMATIC, // 自动指令 (Trigger & Condition Based Automation)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SceneActionType {
|
||||||
|
SOCKET_POWER,
|
||||||
|
VIRTUAL_REMOTE_BUTTON,
|
||||||
|
INFRARED_KEY,
|
||||||
|
DELAY,
|
||||||
|
NOTIFICATION,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ComparisonOperator(val symbol: String) {
|
||||||
|
GREATER_THAN(">"),
|
||||||
|
LESS_THAN("<"),
|
||||||
|
EQUALS("="),
|
||||||
|
GREATER_OR_EQUAL(">="),
|
||||||
|
LESS_OR_EQUAL("<="),
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface SceneTrigger {
|
||||||
|
val id: SceneTriggerId
|
||||||
|
|
||||||
|
data class Time(
|
||||||
|
override val id: SceneTriggerId,
|
||||||
|
val timeOfDay: String, // e.g. "08:30"
|
||||||
|
val daysOfWeek: Set<Int> = emptySet(), // 1..7 (Monday..Sunday), empty means everyday
|
||||||
|
val label: String = "",
|
||||||
|
) : SceneTrigger
|
||||||
|
|
||||||
|
data class Temperature(
|
||||||
|
override val id: SceneTriggerId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val operator: ComparisonOperator,
|
||||||
|
val thresholdCelsius: Double,
|
||||||
|
val label: String = "",
|
||||||
|
) : SceneTrigger
|
||||||
|
|
||||||
|
data class Humidity(
|
||||||
|
override val id: SceneTriggerId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val operator: ComparisonOperator,
|
||||||
|
val thresholdPercent: Double,
|
||||||
|
val label: String = "",
|
||||||
|
) : SceneTrigger
|
||||||
|
|
||||||
|
data class DeviceState(
|
||||||
|
override val id: SceneTriggerId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val expectedState: String, // e.g. "ON" / "OFF"
|
||||||
|
val label: String = "",
|
||||||
|
) : SceneTrigger
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface SceneCondition {
|
||||||
|
val id: SceneConditionId
|
||||||
|
|
||||||
|
data class Temperature(
|
||||||
|
override val id: SceneConditionId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val operator: ComparisonOperator,
|
||||||
|
val thresholdCelsius: Double,
|
||||||
|
) : SceneCondition
|
||||||
|
|
||||||
|
data class Humidity(
|
||||||
|
override val id: SceneConditionId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val operator: ComparisonOperator,
|
||||||
|
val thresholdPercent: Double,
|
||||||
|
) : SceneCondition
|
||||||
|
|
||||||
|
data class SocketPower(
|
||||||
|
override val id: SceneConditionId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val expectedState: SocketPowerState,
|
||||||
|
) : SceneCondition
|
||||||
|
|
||||||
|
data class TimeRange(
|
||||||
|
override val id: SceneConditionId,
|
||||||
|
val startTime: String, // "22:00"
|
||||||
|
val endTime: String, // "07:00"
|
||||||
|
) : SceneCondition
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single executable step inside a [Scene].
|
||||||
|
*/
|
||||||
|
sealed interface SceneAction {
|
||||||
|
val id: SceneActionId
|
||||||
|
val profileId: ProviderProfileId
|
||||||
|
val type: SceneActionType
|
||||||
|
|
||||||
|
data class SocketPower(
|
||||||
|
override val id: SceneActionId,
|
||||||
|
override val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val powerState: SocketPowerState,
|
||||||
|
) : SceneAction {
|
||||||
|
override val type: SceneActionType get() = SceneActionType.SOCKET_POWER
|
||||||
|
}
|
||||||
|
|
||||||
|
data class VirtualRemoteButton(
|
||||||
|
override val id: SceneActionId,
|
||||||
|
override val profileId: ProviderProfileId,
|
||||||
|
val remoteId: String,
|
||||||
|
val remoteDisplayName: String,
|
||||||
|
val buttonId: String,
|
||||||
|
val buttonDisplayName: String,
|
||||||
|
) : SceneAction {
|
||||||
|
override val type: SceneActionType get() = SceneActionType.VIRTUAL_REMOTE_BUTTON
|
||||||
|
}
|
||||||
|
|
||||||
|
data class InfraredKey(
|
||||||
|
override val id: SceneActionId,
|
||||||
|
override val profileId: ProviderProfileId,
|
||||||
|
val deviceName: DeviceName,
|
||||||
|
val key: String,
|
||||||
|
) : SceneAction {
|
||||||
|
override val type: SceneActionType get() = SceneActionType.INFRARED_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Delay(
|
||||||
|
override val id: SceneActionId,
|
||||||
|
override val profileId: ProviderProfileId,
|
||||||
|
val durationSeconds: Int,
|
||||||
|
) : SceneAction {
|
||||||
|
override val type: SceneActionType get() = SceneActionType.DELAY
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Notification(
|
||||||
|
override val id: SceneActionId,
|
||||||
|
override val profileId: ProviderProfileId,
|
||||||
|
val title: String,
|
||||||
|
val message: String,
|
||||||
|
) : SceneAction {
|
||||||
|
override val type: SceneActionType get() = SceneActionType.NOTIFICATION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ExecutionStrategy {
|
||||||
|
CONTINUE_ON_ERROR, // 遇错继续
|
||||||
|
ABORT_ON_ERROR, // 遇错中止
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ConcurrencyMode {
|
||||||
|
SEQUENTIAL, // 同步 / 串行
|
||||||
|
PARALLEL, // 异步 / 并行
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Visual identity for a scene.
|
||||||
|
*/
|
||||||
|
enum class SceneIcon {
|
||||||
|
HOME,
|
||||||
|
OFFICE,
|
||||||
|
NIGHT,
|
||||||
|
MOVIE,
|
||||||
|
AWAY,
|
||||||
|
READING,
|
||||||
|
DINING,
|
||||||
|
GAMING,
|
||||||
|
THERMOSTAT,
|
||||||
|
LIGHT,
|
||||||
|
CLEAN,
|
||||||
|
CUSTOM,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scene accent color stored as an uppercase hex string (e.g. `#3F51B5`).
|
||||||
|
*/
|
||||||
|
@JvmInline
|
||||||
|
value class SceneColor(val hex: String)
|
||||||
|
|
||||||
|
data class Scene(
|
||||||
|
val id: SceneId,
|
||||||
|
val profileId: ProviderProfileId,
|
||||||
|
val name: String,
|
||||||
|
val description: String = "",
|
||||||
|
val type: AutomationType = AutomationType.MANUAL,
|
||||||
|
val enabled: Boolean = true,
|
||||||
|
val icon: SceneIcon = SceneIcon.HOME,
|
||||||
|
val color: SceneColor = SceneColor("#3F51B5"),
|
||||||
|
val triggers: List<SceneTrigger> = emptyList(),
|
||||||
|
val conditions: List<SceneCondition> = emptyList(),
|
||||||
|
val actions: List<SceneAction> = emptyList(),
|
||||||
|
val strategy: ExecutionStrategy = ExecutionStrategy.CONTINUE_ON_ERROR,
|
||||||
|
val concurrencyMode: ConcurrencyMode = ConcurrencyMode.SEQUENTIAL,
|
||||||
|
val enableLogging: Boolean = true,
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
enum class SceneActionOutcome {
|
||||||
|
SUCCESS,
|
||||||
|
FAILURE,
|
||||||
|
SKIPPED,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of executing a single [SceneAction]. [error] is non-null when [outcome] is [FAILURE].
|
||||||
|
*/
|
||||||
|
data class SceneActionExecutionResult(
|
||||||
|
val action: SceneAction,
|
||||||
|
val outcome: SceneActionOutcome,
|
||||||
|
val error: DomainError?,
|
||||||
|
val executionDurationMillis: Long = 0L,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregated result of executing a [Scene].
|
||||||
|
*/
|
||||||
|
data class SceneExecutionResult(
|
||||||
|
val scene: Scene,
|
||||||
|
val results: List<SceneActionExecutionResult>,
|
||||||
|
val totalDurationMillis: Long = 0L,
|
||||||
|
) {
|
||||||
|
val totalActions: Int get() = results.size
|
||||||
|
val succeededCount: Int
|
||||||
|
get() = results.count { it.outcome == SceneActionOutcome.SUCCESS }
|
||||||
|
val failedCount: Int
|
||||||
|
get() = results.count { it.outcome == SceneActionOutcome.FAILURE }
|
||||||
|
val skippedCount: Int
|
||||||
|
get() = results.count { it.outcome == SceneActionOutcome.SKIPPED }
|
||||||
|
val allSucceeded: Boolean
|
||||||
|
get() = results.isNotEmpty() && failedCount == 0 && skippedCount == 0
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class DefaultSceneExecutorTest {
|
||||||
|
|
||||||
|
private val profileId = ProviderProfileId("local-experience")
|
||||||
|
|
||||||
|
private class TestSocketGateway(
|
||||||
|
var response: DomainResult<SocketStatus> = DomainResult.Success(
|
||||||
|
SocketStatus(
|
||||||
|
profileId = ProviderProfileId("local-experience"),
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
) : SocketGateway {
|
||||||
|
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, SocketPowerState>>()
|
||||||
|
|
||||||
|
override suspend fun getStatus(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
): DomainResult<SocketStatus> = response
|
||||||
|
|
||||||
|
override suspend fun setPower(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
powerState: SocketPowerState,
|
||||||
|
): DomainResult<SocketStatus> {
|
||||||
|
calls.add(Triple(profileId, deviceName, powerState))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class TestInfraredGateway(
|
||||||
|
var response: DomainResult<Unit> = DomainResult.Success(Unit),
|
||||||
|
) : InfraredGateway {
|
||||||
|
val calls = mutableListOf<Triple<ProviderProfileId, DeviceName, String>>()
|
||||||
|
|
||||||
|
override suspend fun sendKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
calls.add(Triple(profileId, deviceName, key))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun learnKey(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
key: String,
|
||||||
|
): DomainResult<Unit> = DomainResult.Success(Unit)
|
||||||
|
|
||||||
|
override suspend fun downloadCodes(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
codes: List<InfraredCode>,
|
||||||
|
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun executesActionsSequentiallyAndAggregatesResults() = runTest {
|
||||||
|
val socketGateway = TestSocketGateway()
|
||||||
|
val infraredGateway = TestInfraredGateway()
|
||||||
|
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
|
||||||
|
|
||||||
|
val action1 = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("a1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
)
|
||||||
|
val action2 = SceneAction.InfraredKey(
|
||||||
|
id = SceneActionId("a2"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("ir-1"),
|
||||||
|
key = "power",
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Test Scene",
|
||||||
|
icon = SceneIcon.HOME,
|
||||||
|
color = SceneColor("#FF8A65"),
|
||||||
|
actions = listOf(action1, action2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
|
||||||
|
assertEquals(scene, executionResult.scene)
|
||||||
|
assertEquals(2, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
|
||||||
|
|
||||||
|
assertEquals(1, socketGateway.calls.size)
|
||||||
|
assertEquals(Triple(profileId, DeviceName("socket-1"), SocketPowerState.ON), socketGateway.calls[0])
|
||||||
|
|
||||||
|
assertEquals(1, infraredGateway.calls.size)
|
||||||
|
assertEquals(Triple(profileId, DeviceName("ir-1"), "power"), infraredGateway.calls[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun continuesExecutionWhenAnActionFails() = runTest {
|
||||||
|
val socketGateway = TestSocketGateway(
|
||||||
|
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
|
||||||
|
)
|
||||||
|
val infraredGateway = TestInfraredGateway(
|
||||||
|
response = DomainResult.Success(Unit),
|
||||||
|
)
|
||||||
|
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
|
||||||
|
|
||||||
|
val action1 = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("a1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.OFF,
|
||||||
|
)
|
||||||
|
val action2 = SceneAction.InfraredKey(
|
||||||
|
id = SceneActionId("a2"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("ir-1"),
|
||||||
|
key = "vol_up",
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Test Scene",
|
||||||
|
icon = SceneIcon.MOVIE,
|
||||||
|
color = SceneColor("#7986CB"),
|
||||||
|
actions = listOf(action1, action2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
|
||||||
|
assertEquals(2, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
|
||||||
|
assertTrue(executionResult.results[0].error is DomainError.DeviceOffline)
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
|
||||||
|
|
||||||
|
assertEquals(1, socketGateway.calls.size)
|
||||||
|
assertEquals(1, infraredGateway.calls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun abortsExecutionOnFailureWhenStrategyIsAbortOnError() = runTest {
|
||||||
|
val socketGateway = TestSocketGateway(
|
||||||
|
response = DomainResult.Failure(DomainError.DeviceOffline("socket-1 unreachable")),
|
||||||
|
)
|
||||||
|
val infraredGateway = TestInfraredGateway(
|
||||||
|
response = DomainResult.Success(Unit),
|
||||||
|
)
|
||||||
|
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
|
||||||
|
|
||||||
|
val action1 = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("a1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.OFF,
|
||||||
|
)
|
||||||
|
val action2 = SceneAction.InfraredKey(
|
||||||
|
id = SceneActionId("a2"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("ir-1"),
|
||||||
|
key = "vol_up",
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Test Scene",
|
||||||
|
icon = SceneIcon.MOVIE,
|
||||||
|
color = SceneColor("#7986CB"),
|
||||||
|
actions = listOf(action1, action2),
|
||||||
|
strategy = ExecutionStrategy.ABORT_ON_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
|
||||||
|
assertEquals(2, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
|
||||||
|
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
|
||||||
|
assertEquals(0, infraredGateway.calls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun executesDelayActionSuccessfully() = runTest {
|
||||||
|
var delayCalled = 0L
|
||||||
|
val executor = DefaultSceneExecutor(
|
||||||
|
socketGateway = TestSocketGateway(),
|
||||||
|
infraredGateway = TestInfraredGateway(),
|
||||||
|
delayDurationProvider = { delayCalled = it },
|
||||||
|
)
|
||||||
|
|
||||||
|
val action = SceneAction.Delay(
|
||||||
|
id = SceneActionId("d1"),
|
||||||
|
profileId = profileId,
|
||||||
|
durationSeconds = 5,
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Delay Scene",
|
||||||
|
actions = listOf(action),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
|
||||||
|
assertEquals(1, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
|
||||||
|
assertEquals(5000L, delayCalled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun executesActionsConcurrentlyWhenParallelMode() = runTest {
|
||||||
|
val socketGateway = TestSocketGateway()
|
||||||
|
val infraredGateway = TestInfraredGateway()
|
||||||
|
val executor = DefaultSceneExecutor(socketGateway, infraredGateway)
|
||||||
|
|
||||||
|
val action1 = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("a1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
)
|
||||||
|
val action2 = SceneAction.InfraredKey(
|
||||||
|
id = SceneActionId("a2"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("ir-1"),
|
||||||
|
key = "power",
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s1"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Parallel Scene",
|
||||||
|
concurrencyMode = ConcurrencyMode.PARALLEL,
|
||||||
|
actions = listOf(action1, action2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
assertEquals(2, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[0].outcome)
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
|
||||||
|
assertEquals(1, socketGateway.calls.size)
|
||||||
|
assertEquals(1, infraredGateway.calls.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun failsVirtualRemoteButtonActionWhenSenderNotConfigured() = runTest {
|
||||||
|
val executor = DefaultSceneExecutor(
|
||||||
|
socketGateway = TestSocketGateway(),
|
||||||
|
infraredGateway = TestInfraredGateway(),
|
||||||
|
sendVirtualRemoteAction = null,
|
||||||
|
)
|
||||||
|
val action = SceneAction.VirtualRemoteButton(
|
||||||
|
id = SceneActionId("vr1"),
|
||||||
|
profileId = profileId,
|
||||||
|
remoteId = "tv-remote",
|
||||||
|
remoteDisplayName = "TV",
|
||||||
|
buttonId = "power",
|
||||||
|
buttonDisplayName = "Power",
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s-vr"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "VR Scene",
|
||||||
|
actions = listOf(action),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
assertEquals(1, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
|
||||||
|
assertTrue(executionResult.results[0].error is DomainError.OperationFailed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = kotlinx.coroutines.CancellationException::class)
|
||||||
|
fun rethrowsCancellationExceptionWhenDelayCancelled() = runTest {
|
||||||
|
val executor = DefaultSceneExecutor(
|
||||||
|
socketGateway = TestSocketGateway(),
|
||||||
|
infraredGateway = TestInfraredGateway(),
|
||||||
|
delayDurationProvider = { throw kotlinx.coroutines.CancellationException("Job cancelled") },
|
||||||
|
)
|
||||||
|
val action = SceneAction.Delay(
|
||||||
|
id = SceneActionId("d1"),
|
||||||
|
profileId = profileId,
|
||||||
|
durationSeconds = 10,
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s-cancelled"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Cancelled Scene",
|
||||||
|
actions = listOf(action),
|
||||||
|
)
|
||||||
|
executor.execute(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
|
||||||
|
val socketGateway = TestSocketGateway(
|
||||||
|
response = DomainResult.Failure(DomainError.OperationFailed(detail = "Socket failed")),
|
||||||
|
)
|
||||||
|
val executor = DefaultSceneExecutor(
|
||||||
|
socketGateway = socketGateway,
|
||||||
|
infraredGateway = TestInfraredGateway(),
|
||||||
|
delayDurationProvider = {
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
val action1 = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("a1"),
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
)
|
||||||
|
val action2 = SceneAction.Delay(
|
||||||
|
id = SceneActionId("a2"),
|
||||||
|
profileId = profileId,
|
||||||
|
durationSeconds = 10,
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("s-par-abort"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Parallel Abort Scene",
|
||||||
|
concurrencyMode = ConcurrencyMode.PARALLEL,
|
||||||
|
strategy = ExecutionStrategy.ABORT_ON_ERROR,
|
||||||
|
actions = listOf(action1, action2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
assertEquals(2, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
|
||||||
|
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package com.flagship.abox.manager.domain
|
||||||
|
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class InMemorySceneRepositoryTest {
|
||||||
|
|
||||||
|
private val localProfile = ProviderProfileId("local-experience")
|
||||||
|
private val aboxProfile = ProviderProfileId("abox:admin")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun isolatesScenesBetweenProfiles() = runTest {
|
||||||
|
val repository = InMemorySceneRepository()
|
||||||
|
|
||||||
|
val localScenes = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
|
||||||
|
assertTrue(localScenes.isNotEmpty())
|
||||||
|
|
||||||
|
val aboxScenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
|
||||||
|
assertTrue(aboxScenes.isEmpty())
|
||||||
|
|
||||||
|
val newScene = Scene(
|
||||||
|
id = SceneId("abox:admin:scene-custom"),
|
||||||
|
profileId = aboxProfile,
|
||||||
|
name = "ABox Scene",
|
||||||
|
icon = SceneIcon.NIGHT,
|
||||||
|
color = SceneColor("#4CAF50"),
|
||||||
|
actions = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val createResult = repository.createScene(aboxProfile, newScene)
|
||||||
|
assertTrue(createResult is DomainResult.Success)
|
||||||
|
|
||||||
|
val updatedAbox = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
|
||||||
|
assertEquals(1, updatedAbox.size)
|
||||||
|
assertEquals("ABox Scene", updatedAbox.first().name)
|
||||||
|
|
||||||
|
val updatedLocal = (repository.observeScenes(localProfile).first() as DomainResult.Success).value
|
||||||
|
assertEquals(localScenes.size, updatedLocal.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun crudOperationsFunctionCorrectly() = runTest {
|
||||||
|
val repository = InMemorySceneRepository()
|
||||||
|
|
||||||
|
val sceneId = SceneId("abox:admin:scene-1")
|
||||||
|
val initialScene = Scene(
|
||||||
|
id = sceneId,
|
||||||
|
profileId = aboxProfile,
|
||||||
|
name = "Initial",
|
||||||
|
icon = SceneIcon.HOME,
|
||||||
|
color = SceneColor("#FFFFFF"),
|
||||||
|
actions = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create
|
||||||
|
val createResult = repository.createScene(aboxProfile, initialScene)
|
||||||
|
assertTrue(createResult is DomainResult.Success)
|
||||||
|
|
||||||
|
// Get
|
||||||
|
val getResult = repository.getScene(aboxProfile, sceneId)
|
||||||
|
assertTrue(getResult is DomainResult.Success)
|
||||||
|
assertEquals("Initial", (getResult as DomainResult.Success).value?.name)
|
||||||
|
|
||||||
|
// Update
|
||||||
|
val updatedScene = initialScene.copy(name = "Updated")
|
||||||
|
val updateResult = repository.updateScene(aboxProfile, updatedScene)
|
||||||
|
assertTrue(updateResult is DomainResult.Success)
|
||||||
|
|
||||||
|
val getAfterUpdate = repository.getScene(aboxProfile, sceneId)
|
||||||
|
assertEquals("Updated", (getAfterUpdate as DomainResult.Success).value?.name)
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
val deleteResult = repository.deleteScene(aboxProfile, sceneId)
|
||||||
|
assertTrue(deleteResult is DomainResult.Success)
|
||||||
|
|
||||||
|
val getAfterDelete = repository.getScene(aboxProfile, sceneId)
|
||||||
|
assertNull((getAfterDelete as DomainResult.Success).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun rejectsSceneWithMismatchedActionProfileId() = runTest {
|
||||||
|
val repository = InMemorySceneRepository()
|
||||||
|
val crossAction = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("a-cross"),
|
||||||
|
profileId = ProviderProfileId("other-profile"),
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("abox:admin:scene-invalid"),
|
||||||
|
profileId = aboxProfile,
|
||||||
|
name = "Invalid Scene",
|
||||||
|
actions = listOf(crossAction),
|
||||||
|
)
|
||||||
|
repository.createScene(aboxProfile, scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun concurrentCreationsAreThreadSafe() = runTest {
|
||||||
|
val repository = InMemorySceneRepository()
|
||||||
|
val count = 20
|
||||||
|
coroutineScope {
|
||||||
|
(1..count).map { idx ->
|
||||||
|
async {
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("abox:admin:scene-$idx"),
|
||||||
|
profileId = aboxProfile,
|
||||||
|
name = "Scene $idx",
|
||||||
|
actions = emptyList(),
|
||||||
|
)
|
||||||
|
repository.createScene(aboxProfile, scene)
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
val scenes = (repository.observeScenes(aboxProfile).first() as DomainResult.Success).value
|
||||||
|
assertEquals(count, scenes.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -Dsun.jnu.encoding=UTF-8
|
||||||
org.gradle.configuration-cache=false
|
org.gradle.configuration-cache=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
package com.flagship.abox.manager.testing.fakes
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||||
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionExecutionResult
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionOutcome
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutionResult
|
||||||
|
import com.flagship.abox.manager.domain.SceneExecutor
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SceneRepository
|
||||||
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory [SceneRepository] used by the scene UI and ViewModel tests. Scenes are isolated by
|
||||||
|
* [ProviderProfileId]; mutating scenes for one profile never touches another profile's flow.
|
||||||
|
*
|
||||||
|
* The repository is intentionally deterministic: [defaultScenes] seeds the same catalog every
|
||||||
|
* run so UI snapshots and tests stay stable. Mutations emit a new list through the profile's
|
||||||
|
* StateFlow.
|
||||||
|
*/
|
||||||
|
class FakeSceneRepository : SceneRepository {
|
||||||
|
private val flows = ConcurrentHashMap<
|
||||||
|
ProviderProfileId,
|
||||||
|
MutableStateFlow<DomainResult<List<Scene>>>,
|
||||||
|
>()
|
||||||
|
private val mutexes = ConcurrentHashMap<ProviderProfileId, Mutex>()
|
||||||
|
|
||||||
|
private fun mutexFor(profileId: ProviderProfileId): Mutex =
|
||||||
|
mutexes.getOrPut(profileId) { Mutex() }
|
||||||
|
|
||||||
|
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> = mutexFor(profileId).withLock {
|
||||||
|
require(scene.profileId == profileId) {
|
||||||
|
"Scene profileId must match the supplied profileId"
|
||||||
|
}
|
||||||
|
require(scene.actions.all { it.profileId == profileId }) {
|
||||||
|
"All scene actions must belong to the supplied provider profile"
|
||||||
|
}
|
||||||
|
val scenes = currentScenes(profileId).orEmpty()
|
||||||
|
if (scenes.any { it.id == scene.id }) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(
|
||||||
|
detail = "Scene id ${scene.id.value} already exists in profile ${profileId.value}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val updated = scenes + scene
|
||||||
|
publish(profileId, updated)
|
||||||
|
DomainResult.Success(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun updateScene(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
scene: Scene,
|
||||||
|
): DomainResult<Scene> = mutexFor(profileId).withLock {
|
||||||
|
require(scene.profileId == profileId) {
|
||||||
|
"Scene profileId must match the supplied profileId"
|
||||||
|
}
|
||||||
|
require(scene.actions.all { it.profileId == profileId }) {
|
||||||
|
"All scene actions must belong to the supplied provider profile"
|
||||||
|
}
|
||||||
|
val scenes = currentScenes(profileId).orEmpty()
|
||||||
|
val index = scenes.indexOfFirst { it.id == scene.id }
|
||||||
|
if (index < 0) return notFound(scene.id)
|
||||||
|
val updated = scenes.toMutableList().apply { set(index, scene) }
|
||||||
|
publish(profileId, updated)
|
||||||
|
DomainResult.Success(scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteScene(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
sceneId: SceneId,
|
||||||
|
): DomainResult<Unit> = mutexFor(profileId).withLock {
|
||||||
|
val scenes = currentScenes(profileId).orEmpty()
|
||||||
|
val updated = scenes.filterNot { it.id == sceneId }
|
||||||
|
if (updated.size == scenes.size) return notFound(sceneId)
|
||||||
|
publish(profileId, updated)
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replaces the catalog for [profileId] in tests. Returns the same repository so callers can
|
||||||
|
* chain `repo.setScenes(profile, listOf(...))`.
|
||||||
|
*/
|
||||||
|
fun setScenes(profileId: ProviderProfileId, scenes: List<Scene>): FakeSceneRepository {
|
||||||
|
require(scenes.all { it.profileId == profileId }) {
|
||||||
|
"All scenes must belong to the supplied provider profile"
|
||||||
|
}
|
||||||
|
require(scenes.all { s -> s.actions.all { it.profileId == profileId } }) {
|
||||||
|
"All scene actions must belong to the supplied provider profile"
|
||||||
|
}
|
||||||
|
publish(profileId, scenes)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setError(profileId: ProviderProfileId, error: DomainError) {
|
||||||
|
flowFor(profileId).value = DomainResult.Failure(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun flowFor(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
): MutableStateFlow<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,
|
||||||
|
* respecting delays and recording virtual remote / notification actions.
|
||||||
|
*/
|
||||||
|
class FakeSceneExecutor(
|
||||||
|
private val socketGateway: SocketGateway,
|
||||||
|
private val infraredGateway: InfraredGateway,
|
||||||
|
private val delayDurationProvider: suspend (Long) -> Unit = { delay(it) },
|
||||||
|
) : SceneExecutor {
|
||||||
|
private val running = ConcurrentHashMap<SceneId, Boolean>()
|
||||||
|
val executedRemoteButtons = mutableListOf<SceneAction.VirtualRemoteButton>()
|
||||||
|
val sentNotifications = mutableListOf<SceneAction.Notification>()
|
||||||
|
|
||||||
|
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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val startTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val results: List<SceneActionExecutionResult> = if (scene.concurrencyMode == ConcurrencyMode.PARALLEL) {
|
||||||
|
if (scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
|
||||||
|
coroutineScope {
|
||||||
|
lateinit var deferreds: List<Deferred<SceneActionExecutionResult>>
|
||||||
|
deferreds = scene.actions.mapIndexed { index, action ->
|
||||||
|
async {
|
||||||
|
val actionStart = System.currentTimeMillis()
|
||||||
|
val execResult = executeAction(scene.profileId, action)
|
||||||
|
val duration = System.currentTimeMillis() - actionStart
|
||||||
|
val enriched = execResult.copy(executionDurationMillis = duration)
|
||||||
|
if (enriched.outcome == SceneActionOutcome.FAILURE) {
|
||||||
|
deferreds.forEachIndexed { otherIndex, otherDeferred ->
|
||||||
|
if (otherIndex != index) {
|
||||||
|
otherDeferred.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
enriched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deferreds.mapIndexed { index, deferred ->
|
||||||
|
try {
|
||||||
|
deferred.await()
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = scene.actions[index],
|
||||||
|
outcome = SceneActionOutcome.SKIPPED,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
coroutineScope {
|
||||||
|
scene.actions.map { action ->
|
||||||
|
async {
|
||||||
|
val actionStart = System.currentTimeMillis()
|
||||||
|
val execResult = executeAction(scene.profileId, action)
|
||||||
|
val duration = System.currentTimeMillis() - actionStart
|
||||||
|
execResult.copy(executionDurationMillis = duration)
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val sequentialResults = mutableListOf<SceneActionExecutionResult>()
|
||||||
|
var aborted = false
|
||||||
|
for (action in scene.actions) {
|
||||||
|
if (aborted) {
|
||||||
|
sequentialResults.add(
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SKIPPED,
|
||||||
|
error = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val actionStart = System.currentTimeMillis()
|
||||||
|
val execResult = executeAction(scene.profileId, action)
|
||||||
|
val duration = System.currentTimeMillis() - actionStart
|
||||||
|
val enrichedResult = execResult.copy(executionDurationMillis = duration)
|
||||||
|
sequentialResults.add(enrichedResult)
|
||||||
|
if (enrichedResult.outcome == SceneActionOutcome.FAILURE && scene.strategy == ExecutionStrategy.ABORT_ON_ERROR) {
|
||||||
|
aborted = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sequentialResults
|
||||||
|
}
|
||||||
|
val totalDuration = System.currentTimeMillis() - startTime
|
||||||
|
DomainResult.Success(
|
||||||
|
SceneExecutionResult(
|
||||||
|
scene = scene,
|
||||||
|
results = results,
|
||||||
|
totalDurationMillis = totalDuration,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
running.remove(scene.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun executeAction(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
action: SceneAction,
|
||||||
|
): SceneActionExecutionResult {
|
||||||
|
return try {
|
||||||
|
when (action) {
|
||||||
|
is SceneAction.SocketPower -> {
|
||||||
|
val result = socketGateway.setPower(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = action.deviceName,
|
||||||
|
powerState = action.powerState,
|
||||||
|
)
|
||||||
|
toExecutionResult(action, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.InfraredKey -> {
|
||||||
|
val result = infraredGateway.sendKey(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = action.deviceName,
|
||||||
|
key = action.key,
|
||||||
|
)
|
||||||
|
toExecutionResult(action, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.VirtualRemoteButton -> {
|
||||||
|
executedRemoteButtons.add(action)
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SUCCESS,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.Delay -> {
|
||||||
|
delayDurationProvider(action.durationSeconds * 1000L)
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SUCCESS,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is SceneAction.Notification -> {
|
||||||
|
sentNotifications.add(action)
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SUCCESS,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.FAILURE,
|
||||||
|
error = DomainError.OperationFailed(detail = e.message),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toExecutionResult(action: SceneAction, result: DomainResult<*>): SceneActionExecutionResult {
|
||||||
|
return when (result) {
|
||||||
|
is DomainResult.Success -> SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.SUCCESS,
|
||||||
|
error = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
is DomainResult.Failure -> SceneActionExecutionResult(
|
||||||
|
action = action,
|
||||||
|
outcome = SceneActionOutcome.FAILURE,
|
||||||
|
error = result.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
package com.flagship.abox.manager.testing.fakes
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.ConcurrencyMode
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ExecutionStrategy
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionOutcome
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SocketGateway
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import com.flagship.abox.manager.domain.SocketStatus
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FakeSceneExecutorTest {
|
||||||
|
private val profileId = ProviderProfileId("abox")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun executesActionsInOrder() = runTest {
|
||||||
|
val socketGateway = FakeSocketGateway()
|
||||||
|
val infraredGateway = FakeInfraredGateway()
|
||||||
|
val executor = FakeSceneExecutor(socketGateway, infraredGateway)
|
||||||
|
val scene = scene(
|
||||||
|
actions = listOf(
|
||||||
|
socketAction("socket-1", SocketPowerState.ON),
|
||||||
|
infraredAction("ir-1", "power"),
|
||||||
|
socketAction("socket-2", SocketPowerState.OFF),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
assertEquals(3, executionResult.results.size)
|
||||||
|
executionResult.results.forEach {
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, it.outcome)
|
||||||
|
}
|
||||||
|
assertEquals(1, infraredGateway.sentKeys.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun continuesAfterActionFailure() = runTest {
|
||||||
|
val socketGateway = FakeSocketGateway()
|
||||||
|
socketGateway.setError(
|
||||||
|
profileId,
|
||||||
|
DeviceName("socket-1"),
|
||||||
|
DomainError.DeviceOffline(detail = "offline"),
|
||||||
|
)
|
||||||
|
val infraredGateway = FakeInfraredGateway()
|
||||||
|
val executor = FakeSceneExecutor(socketGateway, infraredGateway)
|
||||||
|
val scene = scene(
|
||||||
|
actions = listOf(
|
||||||
|
socketAction("socket-1", SocketPowerState.ON),
|
||||||
|
infraredAction("ir-1", "power"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
assertEquals(2, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
|
||||||
|
assertEquals(SceneActionOutcome.SUCCESS, executionResult.results[1].outcome)
|
||||||
|
assertEquals(1, executionResult.succeededCount)
|
||||||
|
assertEquals(1, executionResult.failedCount)
|
||||||
|
assertFalse(executionResult.allSucceeded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun runningSceneRejectsConcurrentExecution() = runTest {
|
||||||
|
val gate = CompletableDeferred<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)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun executesDelayVirtualRemoteAndNotification() = runTest {
|
||||||
|
val delays = mutableListOf<Long>()
|
||||||
|
val executor = FakeSceneExecutor(
|
||||||
|
socketGateway = FakeSocketGateway(),
|
||||||
|
infraredGateway = FakeInfraredGateway(),
|
||||||
|
delayDurationProvider = { delays.add(it) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val remoteAction = SceneAction.VirtualRemoteButton(
|
||||||
|
id = SceneActionId("abox:btn-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
remoteId = "remote-1",
|
||||||
|
remoteDisplayName = "TV Remote",
|
||||||
|
buttonId = "power",
|
||||||
|
buttonDisplayName = "Power",
|
||||||
|
)
|
||||||
|
val delayAction = SceneAction.Delay(
|
||||||
|
id = SceneActionId("abox:delay-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
durationSeconds = 5,
|
||||||
|
)
|
||||||
|
val notificationAction = SceneAction.Notification(
|
||||||
|
id = SceneActionId("abox:notif-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
title = "Test Done",
|
||||||
|
message = "Success",
|
||||||
|
)
|
||||||
|
|
||||||
|
val scene = scene(listOf(remoteAction, delayAction, notificationAction))
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val execResult = (result as DomainResult.Success).value
|
||||||
|
assertTrue(execResult.allSucceeded)
|
||||||
|
assertEquals(listOf(5000L), delays)
|
||||||
|
assertEquals(listOf(remoteAction), executor.executedRemoteButtons)
|
||||||
|
assertEquals(listOf(notificationAction), executor.sentNotifications)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun parallelAbortOnErrorCancelsPendingActions() = runTest {
|
||||||
|
val socketGateway = FakeSocketGateway()
|
||||||
|
socketGateway.setError(
|
||||||
|
profileId,
|
||||||
|
DeviceName("socket-1"),
|
||||||
|
DomainError.DeviceOffline(detail = "offline"),
|
||||||
|
)
|
||||||
|
val executor = FakeSceneExecutor(
|
||||||
|
socketGateway = socketGateway,
|
||||||
|
infraredGateway = FakeInfraredGateway(),
|
||||||
|
delayDurationProvider = { kotlinx.coroutines.delay(1000) },
|
||||||
|
)
|
||||||
|
|
||||||
|
val action1 = socketAction("socket-1", SocketPowerState.ON)
|
||||||
|
val action2 = SceneAction.Delay(
|
||||||
|
id = SceneActionId("abox:delay-1"),
|
||||||
|
profileId = profileId,
|
||||||
|
durationSeconds = 10,
|
||||||
|
)
|
||||||
|
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("${profileId.value}:scene-par-abort"),
|
||||||
|
profileId = profileId,
|
||||||
|
name = "Parallel Abort Scene",
|
||||||
|
concurrencyMode = ConcurrencyMode.PARALLEL,
|
||||||
|
strategy = ExecutionStrategy.ABORT_ON_ERROR,
|
||||||
|
actions = listOf(action1, action2),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = executor.execute(scene)
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val executionResult = (result as DomainResult.Success).value
|
||||||
|
assertEquals(2, executionResult.results.size)
|
||||||
|
assertEquals(SceneActionOutcome.FAILURE, executionResult.results[0].outcome)
|
||||||
|
assertEquals(SceneActionOutcome.SKIPPED, executionResult.results[1].outcome)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scene(actions: List<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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
package com.flagship.abox.manager.testing.fakes
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Scene
|
||||||
|
import com.flagship.abox.manager.domain.SceneAction
|
||||||
|
import com.flagship.abox.manager.domain.SceneActionId
|
||||||
|
import com.flagship.abox.manager.domain.SceneColor
|
||||||
|
import com.flagship.abox.manager.domain.SceneIcon
|
||||||
|
import com.flagship.abox.manager.domain.SceneId
|
||||||
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.awaitAll
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class FakeSceneRepositoryTest {
|
||||||
|
private val aboxProfile = ProviderProfileId("abox")
|
||||||
|
private val localProfile = FakeSceneRepository.localProfile
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun defaultScenesAreIsolatedByProviderProfile() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
|
||||||
|
val aboxScenes = scenesFor(repo, aboxProfile)
|
||||||
|
val localScenes = scenesFor(repo, localProfile)
|
||||||
|
|
||||||
|
assertEquals(0, aboxScenes.size)
|
||||||
|
assertEquals(2, localScenes.size)
|
||||||
|
assertTrue(localScenes.all { it.profileId == localProfile })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createScenePublishesSceneToFlow() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
val scene = sampleScene(localProfile, "scene-1")
|
||||||
|
|
||||||
|
val result = repo.createScene(localProfile, scene)
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
assertEquals(scene, (result as DomainResult.Success).value)
|
||||||
|
val list = scenesFor(repo, localProfile)
|
||||||
|
assertEquals(3, list.size)
|
||||||
|
assertEquals(scene, list.last())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createSceneWithDuplicateIdFails() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
val scene = sampleScene(localProfile, "dup")
|
||||||
|
|
||||||
|
repo.createScene(localProfile, scene)
|
||||||
|
val second = repo.createScene(localProfile, scene)
|
||||||
|
|
||||||
|
assertTrue(second is DomainResult.Failure)
|
||||||
|
assertTrue((second as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateSceneReplacesExistingEntry() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
val original = sampleScene(localProfile, "scene-2", "Original")
|
||||||
|
repo.createScene(localProfile, original)
|
||||||
|
|
||||||
|
val updated = original.copy(name = "Renamed")
|
||||||
|
val result = repo.updateScene(localProfile, updated)
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val list = scenesFor(repo, localProfile)
|
||||||
|
assertEquals(3, list.size)
|
||||||
|
val stored = list.first { it.id == original.id }
|
||||||
|
assertEquals("Renamed", stored.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteSceneRemovesEntry() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
val scene = sampleScene(localProfile, "scene-3")
|
||||||
|
repo.createScene(localProfile, scene)
|
||||||
|
|
||||||
|
val result = repo.deleteScene(localProfile, scene.id)
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
val list = scenesFor(repo, localProfile)
|
||||||
|
assertEquals(2, list.size)
|
||||||
|
assertNull(list.firstOrNull { it.id == scene.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteUnknownSceneFails() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
|
||||||
|
val result = repo.deleteScene(localProfile, SceneId("missing"))
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Failure)
|
||||||
|
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getSceneReturnsNullForUnknownId() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
|
||||||
|
val result = repo.getScene(localProfile, SceneId("missing"))
|
||||||
|
|
||||||
|
assertTrue(result is DomainResult.Success)
|
||||||
|
assertNull((result as DomainResult.Success).value)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setErrorStopsPublishingSuccess() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
repo.setError(localProfile, DomainError.Unknown(detail = "boom"))
|
||||||
|
|
||||||
|
val value = repo.observeScenes(localProfile).first()
|
||||||
|
|
||||||
|
assertTrue(value is DomainResult.Failure)
|
||||||
|
assertNotNull((value as DomainResult.Failure).error)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = IllegalArgumentException::class)
|
||||||
|
fun createSceneRejectsMismatchedActionProfileId() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
val crossAction = SceneAction.SocketPower(
|
||||||
|
id = SceneActionId("a-cross"),
|
||||||
|
profileId = ProviderProfileId("other"),
|
||||||
|
deviceName = DeviceName("socket-1"),
|
||||||
|
powerState = SocketPowerState.ON,
|
||||||
|
)
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("${localProfile.value}:scene-cross"),
|
||||||
|
profileId = localProfile,
|
||||||
|
name = "Cross Action",
|
||||||
|
actions = listOf(crossAction),
|
||||||
|
)
|
||||||
|
repo.createScene(localProfile, scene)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun concurrentCreationsAreThreadSafe() = runTest {
|
||||||
|
val repo = FakeSceneRepository()
|
||||||
|
val count = 20
|
||||||
|
coroutineScope {
|
||||||
|
(1..count).map { idx ->
|
||||||
|
async {
|
||||||
|
val scene = Scene(
|
||||||
|
id = SceneId("${aboxProfile.value}:scene-$idx"),
|
||||||
|
profileId = aboxProfile,
|
||||||
|
name = "Scene $idx",
|
||||||
|
actions = emptyList(),
|
||||||
|
)
|
||||||
|
repo.createScene(aboxProfile, scene)
|
||||||
|
}
|
||||||
|
}.awaitAll()
|
||||||
|
}
|
||||||
|
val scenes = scenesFor(repo, aboxProfile)
|
||||||
|
assertEquals(count, scenes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun scenesFor(
|
||||||
|
repo: FakeSceneRepository,
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
): List<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