feat(scene): refactor scene automation into modular trigger-action system
- Support manual one-tap commands and automatic condition-based rules - Add modular triggers (time, temperature, humidity, device state) - Add modular conditions (environment thresholds, socket state, time range) - Upgrade actions to support virtual remotes, sockets, delays, and notifications - Support sequential/parallel concurrency mode and error execution strategies - Refactor SceneListScreen with filter tabs, quick execution, and enable switches - Refactor SceneEditorScreen with modular builders and preset pickers - Enhance SceneExecutionScreen with step timing, skips, and rerun capability - Add comprehensive domain and UI ViewModel unit tests
This commit is contained in:
@@ -6,6 +6,7 @@ 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
|
||||
@@ -27,5 +28,10 @@ object SceneIntegrationModule {
|
||||
fun provideSceneExecutor(
|
||||
socketGateway: SocketGateway,
|
||||
infraredGateway: InfraredGateway,
|
||||
): SceneExecutor = DefaultSceneExecutor(socketGateway, infraredGateway)
|
||||
sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||
): SceneExecutor = DefaultSceneExecutor(
|
||||
socketGateway = socketGateway,
|
||||
infraredGateway = infraredGateway,
|
||||
sendVirtualRemoteAction = sendVirtualRemoteAction,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -130,7 +130,8 @@ private fun AuthenticatedNavHost(
|
||||
},
|
||||
scenesSlot = {
|
||||
SceneListScreen(
|
||||
onAddScene = { navController.navigate(SceneRoutes.EDITOR) },
|
||||
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)) },
|
||||
)
|
||||
|
||||
+870
-146
File diff suppressed because it is too large
Load Diff
+225
-12
@@ -2,32 +2,54 @@ 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 = "#FF8A65",
|
||||
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 isSaving: Boolean = false,
|
||||
val isSaved: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
@@ -39,15 +61,40 @@ data class SceneEditorState(
|
||||
@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()
|
||||
|
||||
fun initialize(sceneId: SceneId?) {
|
||||
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 (sceneId == null) {
|
||||
_state.value = SceneEditorState()
|
||||
_state.update {
|
||||
SceneEditorState(
|
||||
type = initialType ?: AutomationType.MANUAL,
|
||||
colorHex = if (initialType == AutomationType.AUTOMATIC) "#26A69A" else "#3F51B5",
|
||||
availableRemotes = it.availableRemotes,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
@@ -58,13 +105,23 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
val scene = (sceneRepository.getScene(profile.id, sceneId) as? DomainResult.Success)?.value
|
||||
if (scene != null) {
|
||||
_state.value = SceneEditorState(
|
||||
sceneId = scene.id,
|
||||
name = scene.name,
|
||||
icon = scene.icon,
|
||||
colorHex = scene.color.hex,
|
||||
actions = scene.actions,
|
||||
)
|
||||
_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,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
_state.update { it.copy(errorMessage = "Scene not found") }
|
||||
}
|
||||
@@ -72,11 +129,104 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -91,6 +241,26 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -105,6 +275,33 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
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 }) }
|
||||
|
||||
@@ -115,6 +312,13 @@ class SceneEditorViewModel @Inject constructor(
|
||||
_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
|
||||
@@ -129,9 +333,17 @@ class SceneEditorViewModel @Inject constructor(
|
||||
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)
|
||||
@@ -151,3 +363,4 @@ class SceneEditorViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+167
-52
@@ -1,28 +1,39 @@
|
||||
package com.flagship.abox.manager.ui.scene
|
||||
|
||||
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.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.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -30,8 +41,8 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
@@ -39,21 +50,25 @@ 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: com.flagship.abox.manager.domain.SceneId,
|
||||
sceneId: SceneId,
|
||||
onBack: () -> Unit,
|
||||
viewModel: SceneExecutionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
LaunchedEffect(sceneId) { viewModel.initialize(sceneId) }
|
||||
LaunchedEffect(sceneId) {
|
||||
viewModel.initialize(sceneId)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.scene_execution_title)) },
|
||||
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))
|
||||
@@ -64,29 +79,28 @@ fun SceneExecutionScreen(
|
||||
) { innerPadding ->
|
||||
when (val state = uiState) {
|
||||
SceneExecutionUiState.Loading -> CenteredLoading(innerPadding)
|
||||
is SceneExecutionUiState.Failed -> CenteredMessage(innerPadding, state.message)
|
||||
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)
|
||||
is SceneExecutionUiState.Completed -> CompletedContent(innerPadding, state.result, onBack, onRerun = viewModel::execute)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredLoading(paddingValues: PaddingValues) {
|
||||
Row(
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredMessage(paddingValues: PaddingValues, message: String) {
|
||||
private fun CenteredMessage(paddingValues: PaddingValues, message: String, onRetry: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -96,6 +110,12 @@ private fun CenteredMessage(paddingValues: PaddingValues, message: String) {
|
||||
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("重试")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +133,18 @@ private fun ReadyContent(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(sceneName, style = MaterialTheme.typography.headlineSmall)
|
||||
Spacer(Modifier.size(24.dp))
|
||||
Button(onClick = onExecute) {
|
||||
Text(stringResource(R.string.scene_execution_run))
|
||||
Text(sceneName, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("点击下方按钮立即开始执行", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,11 +159,12 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
Spacer(Modifier.size(16.dp))
|
||||
CircularProgressIndicator(modifier = Modifier.size(56.dp))
|
||||
Spacer(Modifier.height(20.dp))
|
||||
Text(
|
||||
stringResource(R.string.scene_execution_running, sceneName),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -143,8 +172,9 @@ private fun RunningContent(paddingValues: PaddingValues, sceneName: String) {
|
||||
@Composable
|
||||
private fun CompletedContent(
|
||||
paddingValues: PaddingValues,
|
||||
result: com.flagship.abox.manager.domain.SceneExecutionResult,
|
||||
result: SceneExecutionResult,
|
||||
onBack: () -> Unit,
|
||||
onRerun: () -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
@@ -153,57 +183,131 @@ private fun CompletedContent(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.scene_execution_summary,
|
||||
result.succeededCount,
|
||||
result.failedCount,
|
||||
result.totalActions,
|
||||
item(key = "summary") {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (result.allSucceeded) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f)
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
) {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
items(result.results, key = { it.action.id.value }) { entry ->
|
||||
ActionResultRow(entry)
|
||||
|
||||
itemsIndexed(result.results, key = { index, _ -> "action_res_$index" }) { index, entry ->
|
||||
ActionResultRow(index + 1, entry)
|
||||
}
|
||||
item {
|
||||
Button(onClick = onBack, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(stringResource(R.string.scene_execution_done))
|
||||
|
||||
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("重新执行")
|
||||
}
|
||||
Button(
|
||||
onClick = onBack,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text("返回", fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ActionResultRow(entry: SceneActionExecutionResult) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
private fun ActionResultRow(stepNumber: Int, entry: SceneActionExecutionResult) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(12.dp),
|
||||
.padding(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (entry.outcome == SceneActionOutcome.SUCCESS) {
|
||||
Icons.Filled.Check
|
||||
} else {
|
||||
Icons.Filled.Close
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = when (entry.outcome) {
|
||||
SceneActionOutcome.SUCCESS -> MaterialTheme.colorScheme.primaryContainer
|
||||
SceneActionOutcome.FAILURE -> MaterialTheme.colorScheme.errorContainer
|
||||
SceneActionOutcome.SKIPPED -> MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
contentDescription = null,
|
||||
tint = if (entry.outcome == SceneActionOutcome.SUCCESS) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.error
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.size(12.dp))
|
||||
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 = entry.action.describe(),
|
||||
text = "步骤 $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,
|
||||
@@ -211,11 +315,22 @@ private fun ActionResultRow(entry: SceneActionExecutionResult) {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (entry.executionDurationMillis > 0) {
|
||||
Text(
|
||||
text = "${entry.executionDurationMillis}ms",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun SceneAction.describe(): String = when (this) {
|
||||
is SceneAction.SocketPower -> "Socket ${deviceName.value} -> ${powerState.name}"
|
||||
is SceneAction.InfraredKey -> "Infrared ${deviceName.value} -> $key"
|
||||
is SceneAction.SocketPower -> "插座【${deviceName.value}】-> ${powerState.name}"
|
||||
is SceneAction.VirtualRemoteButton -> "遥控【$remoteDisplayName】-> $buttonDisplayName"
|
||||
is SceneAction.InfraredKey -> "红外【${deviceName.value}】-> 槽位 $key"
|
||||
is SceneAction.Delay -> "⏱️ 等待 ${durationSeconds} 秒"
|
||||
is SceneAction.Notification -> "🔔 提示: $title"
|
||||
}
|
||||
|
||||
|
||||
+276
-50
@@ -1,5 +1,6 @@
|
||||
package com.flagship.abox.manager.ui.scene
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
@@ -10,60 +11,119 @@ 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.CircleShape
|
||||
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.Card
|
||||
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.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.material3.TopAppBar
|
||||
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(
|
||||
onAddScene: () -> Unit,
|
||||
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(
|
||||
floatingActionButton = {
|
||||
ExtendedFloatingActionButton(
|
||||
onClick = onAddScene,
|
||||
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
|
||||
text = { Text(stringResource(R.string.scene_create_action)) },
|
||||
)
|
||||
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(
|
||||
@@ -71,7 +131,7 @@ fun SceneListScreen(
|
||||
contentPadding = PaddingValues(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 16.dp,
|
||||
top = 12.dp,
|
||||
bottom = innerPadding.calculateBottomPadding() + 80.dp,
|
||||
),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
@@ -81,15 +141,48 @@ fun SceneListScreen(
|
||||
headerContent()
|
||||
}
|
||||
}
|
||||
|
||||
item(key = "tabs") {
|
||||
SecondaryTabRow(
|
||||
selectedTabIndex = selectedTab.ordinal,
|
||||
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 ->
|
||||
SceneRow(
|
||||
SceneCard(
|
||||
scene = scene,
|
||||
onClick = { onExecuteScene(scene.id) },
|
||||
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) },
|
||||
)
|
||||
@@ -105,7 +198,7 @@ fun SceneListScreen(
|
||||
text = { Text(stringResource(R.string.scene_delete_dialog_message)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { viewModel.deleteScene(sceneId) }) {
|
||||
Text(stringResource(R.string.scene_delete_confirm))
|
||||
Text(stringResource(R.string.scene_delete_confirm), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
@@ -118,58 +211,169 @@ fun SceneListScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SceneRow(
|
||||
private fun SceneCard(
|
||||
scene: Scene,
|
||||
onClick: () -> Unit,
|
||||
onQuickExecute: () -> Unit,
|
||||
onToggleEnabled: () -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onClick)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(
|
||||
color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = scene.name,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
R.string.scene_action_count,
|
||||
scene.actions.size,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
color = scene.color.toColorOrNull() ?: MaterialTheme.colorScheme.primary,
|
||||
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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onEdit) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = stringResource(R.string.scene_edit),
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(
|
||||
Icons.Filled.Delete,
|
||||
contentDescription = stringResource(R.string.scene_delete),
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(10.dp))
|
||||
|
||||
// Trigger & Action summary pills
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (scene.type == AutomationType.AUTOMATIC && scene.triggers.isNotEmpty()) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.tertiaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = "⚡ ${scene.triggers.size} 触发器",
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
color = MaterialTheme.colorScheme.secondaryContainer,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.scene_action_count, scene.actions.size),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (scene.type == AutomationType.MANUAL) {
|
||||
FilledTonalButton(
|
||||
onClick = onQuickExecute,
|
||||
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp),
|
||||
modifier = Modifier.height(32.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))
|
||||
}
|
||||
IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
|
||||
Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.scene_delete), modifier = Modifier.size(18.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TypeBadge(type: AutomationType) {
|
||||
val (label, bg, fg) = when (type) {
|
||||
AutomationType.MANUAL -> Triple(
|
||||
stringResource(R.string.scene_tab_manual),
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
AutomationType.AUTOMATIC -> Triple(
|
||||
stringResource(R.string.scene_tab_automatic),
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
Surface(
|
||||
shape = RoundedCornerShape(4.dp),
|
||||
color = bg,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = fg,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptySceneList(modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
@@ -179,12 +383,18 @@ private fun EmptySceneList(modifier: 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(12.dp))
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.scene_empty_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -194,8 +404,24 @@ private fun EmptySceneList(modifier: Modifier = Modifier) {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun com.flagship.abox.manager.domain.SceneColor.toColorOrNull(): Color? {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
|
||||
+52
-1
@@ -2,32 +2,48 @@ 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() {
|
||||
|
||||
val scenes: StateFlow<List<Scene>> = profileStore.observeActiveProfile()
|
||||
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()))
|
||||
@@ -42,9 +58,43 @@ class SceneListViewModel @Inject constructor(
|
||||
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
|
||||
@@ -62,3 +112,4 @@ class SceneListViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,15 +9,17 @@ 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. [SceneRoutes.LIST] is the entry point; the editor and execution routes
|
||||
* carry the scene id as a path segment so deep linking works without manual argument parsing.
|
||||
* 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}"
|
||||
|
||||
@@ -39,7 +41,8 @@ fun SceneNavHost(
|
||||
modifier = modifier,
|
||||
) {
|
||||
sceneListDestination(
|
||||
onAddScene = { navController.navigate(SceneRoutes.EDITOR) },
|
||||
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)) },
|
||||
)
|
||||
@@ -56,14 +59,16 @@ fun SceneNavHost(
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.sceneListDestination(
|
||||
onAddScene: () -> Unit,
|
||||
onAddManual: () -> Unit,
|
||||
onAddAutomatic: () -> Unit,
|
||||
onEditScene: (SceneId) -> Unit,
|
||||
onExecuteScene: (SceneId) -> Unit,
|
||||
headerContent: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
composable(SceneRoutes.LIST) {
|
||||
SceneListScreen(
|
||||
onAddScene = onAddScene,
|
||||
onAddManual = onAddManual,
|
||||
onAddAutomatic = onAddAutomatic,
|
||||
onEditScene = onEditScene,
|
||||
onExecuteScene = onExecuteScene,
|
||||
headerContent = headerContent,
|
||||
@@ -75,10 +80,14 @@ fun NavGraphBuilder.sceneEditorDestination(
|
||||
onBack: () -> Unit,
|
||||
onSaved: () -> Unit,
|
||||
) {
|
||||
composable(
|
||||
route = SceneRoutes.EDITOR,
|
||||
) {
|
||||
SceneEditorScreen(sceneId = null, onBack = onBack, onSaved = onSaved)
|
||||
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,
|
||||
@@ -87,6 +96,7 @@ fun NavGraphBuilder.sceneEditorDestination(
|
||||
val id = backStackEntry.arguments?.getString("sceneId")
|
||||
SceneEditorScreen(
|
||||
sceneId = id?.let { SceneId(it) },
|
||||
initialType = null,
|
||||
onBack = onBack,
|
||||
onSaved = onSaved,
|
||||
)
|
||||
|
||||
@@ -42,44 +42,124 @@
|
||||
<string name="activity_placeholder_title">活动记录</string>
|
||||
<string name="activity_placeholder_description">未记录任何事件。</string>
|
||||
|
||||
<!-- Scene: list -->
|
||||
<string name="scene_list_title">情景模式</string>
|
||||
<string name="scene_create_action">新建情景</string>
|
||||
<!-- 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_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_dialog_title">删除指令</string>
|
||||
<string name="scene_delete_dialog_message">确定要删除该指令吗?此操作无法撤销。</string>
|
||||
<string name="scene_delete_confirm">删除</string>
|
||||
<string name="scene_delete_cancel">取消</string>
|
||||
|
||||
<!-- Scene: editor -->
|
||||
<string name="scene_editor_new_title">新建情景</string>
|
||||
<string name="scene_editor_edit_title">编辑情景</string>
|
||||
<string name="scene_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_icon_label">图标</string>
|
||||
<string name="scene_color_label">主题色(十六进制)</string>
|
||||
<string name="scene_actions_label">动作(%1$d)</string>
|
||||
<string name="scene_add_socket_action">添加插座动作</string>
|
||||
<string name="scene_add_infrared_action">添加红外动作</string>
|
||||
<string name="scene_device_name_label">设备名称</string>
|
||||
<string name="scene_infrared_key_label">红外按键</string>
|
||||
<string name="scene_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>
|
||||
<string name="scene_action_socket">插座 %1$s -> %2$s</string>
|
||||
<string name="scene_action_infrared">红外 %1$s -> %2$s</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_title">执行指令</string>
|
||||
<string name="scene_execution_run">执行</string>
|
||||
<string name="scene_execution_running">正在执行 %1$s…</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_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="device_list_title">设备列表</string>
|
||||
|
||||
@@ -42,44 +42,124 @@
|
||||
<string name="activity_placeholder_title">Activity Log</string>
|
||||
<string name="activity_placeholder_description">No recorded events yet.</string>
|
||||
|
||||
<!-- Scene: list -->
|
||||
<string name="scene_list_title">Scenes</string>
|
||||
<string name="scene_create_action">New scene</string>
|
||||
<!-- 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 scene</string>
|
||||
<string name="scene_delete">Delete scene</string>
|
||||
<string name="scene_empty_title">No scenes yet</string>
|
||||
<string name="scene_empty_message">Create your first scene to combine socket and infrared actions.</string>
|
||||
<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 scene</string>
|
||||
<string name="scene_delete_dialog_message">This action cannot be undone.</string>
|
||||
<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_new_title">New scene</string>
|
||||
<string name="scene_editor_edit_title">Edit scene</string>
|
||||
<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">Name</string>
|
||||
<string name="scene_icon_label">Icon</string>
|
||||
<string name="scene_color_label">Accent color (hex)</string>
|
||||
<string name="scene_actions_label">Actions (%1$d)</string>
|
||||
<string name="scene_add_socket_action">Add socket action</string>
|
||||
<string name="scene_add_infrared_action">Add infrared action</string>
|
||||
<string name="scene_device_name_label">Device name</string>
|
||||
<string name="scene_infrared_key_label">Infrared key</string>
|
||||
<string name="scene_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 scene</string>
|
||||
<string name="scene_action_socket">Socket %1$s -> %2$s</string>
|
||||
<string name="scene_action_infrared">Infrared %1$s -> %2$s</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">Run scene</string>
|
||||
<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_running">Running: %1$s…</string>
|
||||
<string name="scene_execution_summary">%1$d succeeded, %2$d failed of %3$d actions</string>
|
||||
<string name="scene_execution_done">Done</string>
|
||||
<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>
|
||||
|
||||
<!-- Device list -->
|
||||
<string name="device_list_title">Device List</string>
|
||||
|
||||
+47
-12
@@ -1,6 +1,10 @@
|
||||
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
|
||||
@@ -8,12 +12,15 @@ 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
|
||||
@@ -31,6 +38,7 @@ class SceneEditorViewModelTest {
|
||||
|
||||
private val profileStore = FakeProviderProfileStore()
|
||||
private val sceneRepository = FakeSceneRepository()
|
||||
private val virtualRemoteRepository = FakeVirtualRemoteRepository()
|
||||
private val profileId = ProviderProfileId("local-experience")
|
||||
|
||||
private val existingScene = Scene(
|
||||
@@ -58,8 +66,8 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeWithNullPreparesNewScene() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
|
||||
viewModel.initialize(null)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertFalse(state.isEditing)
|
||||
@@ -71,7 +79,7 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun initializeWithExistingSceneLoadsState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
viewModel.initialize(existingScene.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
@@ -85,34 +93,60 @@ class SceneEditorViewModelTest {
|
||||
@Test
|
||||
fun addingAndReorderingActionsUpdatesState() = runTest {
|
||||
setupProfile()
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, profileStore)
|
||||
viewModel.initialize(null)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
viewModel.updateName("My New Scene")
|
||||
|
||||
viewModel.addSocketAction("socket-1", SocketPowerState.ON)
|
||||
viewModel.addInfraredAction("ir-1", "power")
|
||||
viewModel.addDelayAction(3)
|
||||
viewModel.addNotificationAction("Done", "Completed")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(2, viewModel.state.value.actions.size)
|
||||
assertEquals(3, viewModel.state.value.actions.size)
|
||||
assertTrue(viewModel.state.value.actions[0] is SceneAction.SocketPower)
|
||||
assertTrue(viewModel.state.value.actions[1] is SceneAction.InfraredKey)
|
||||
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.InfraredKey)
|
||||
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(1, viewModel.state.value.actions.size)
|
||||
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, profileStore)
|
||||
viewModel.initialize(null)
|
||||
val viewModel = SceneEditorViewModel(sceneRepository, virtualRemoteRepository, profileStore)
|
||||
viewModel.initialize(null, AutomationType.MANUAL)
|
||||
viewModel.updateName("Brand New Scene")
|
||||
viewModel.addSocketAction("socket-main", SocketPowerState.OFF)
|
||||
advanceUntilIdle()
|
||||
@@ -125,3 +159,4 @@ class SceneEditorViewModelTest {
|
||||
assertFalse(viewModel.state.value.isSaving)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+82
-13
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -12,14 +13,18 @@ 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
|
||||
@@ -33,12 +38,16 @@ class SceneListViewModelTest {
|
||||
|
||||
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 testScene = Scene(
|
||||
private val manualScene = Scene(
|
||||
id = SceneId("local-experience:scene-1"),
|
||||
profileId = profileId,
|
||||
name = "Test Scene",
|
||||
name = "Manual Scene",
|
||||
type = AutomationType.MANUAL,
|
||||
icon = SceneIcon.HOME,
|
||||
color = SceneColor("#FF8A65"),
|
||||
actions = listOf(
|
||||
@@ -50,39 +59,98 @@ class SceneListViewModelTest {
|
||||
),
|
||||
),
|
||||
)
|
||||
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(testScene))
|
||||
sceneRepository.setScenes(profileId, listOf(manualScene, autoScene))
|
||||
|
||||
val viewModel = SceneListViewModel(sceneRepository, profileStore)
|
||||
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("Test Scene", viewModel.scenes.value.first().name)
|
||||
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(testScene))
|
||||
sceneRepository.setScenes(profileId, listOf(manualScene))
|
||||
|
||||
val viewModel = SceneListViewModel(sceneRepository, profileStore)
|
||||
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
|
||||
viewModel.scenes.collect {}
|
||||
}
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.markPendingDelete(testScene.id)
|
||||
assertEquals(testScene.id, viewModel.pendingDelete.value)
|
||||
viewModel.markPendingDelete(manualScene.id)
|
||||
assertEquals(manualScene.id, viewModel.pendingDelete.value)
|
||||
|
||||
viewModel.deleteScene(testScene.id)
|
||||
viewModel.deleteScene(manualScene.id)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(viewModel.pendingDelete.value)
|
||||
@@ -91,11 +159,12 @@ class SceneListViewModelTest {
|
||||
|
||||
@Test
|
||||
fun cancelDeleteClearsPendingState() = runTest {
|
||||
val viewModel = SceneListViewModel(sceneRepository, profileStore)
|
||||
viewModel.markPendingDelete(testScene.id)
|
||||
assertEquals(testScene.id, viewModel.pendingDelete.value)
|
||||
val viewModel = SceneListViewModel(sceneRepository, sceneExecutor, profileStore)
|
||||
viewModel.markPendingDelete(manualScene.id)
|
||||
assertEquals(manualScene.id, viewModel.pendingDelete.value)
|
||||
|
||||
viewModel.cancelDelete()
|
||||
assertNull(viewModel.pendingDelete.value)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user