feat(scene): add scene editor, executor and fakes (phase 1)
Introduce the scene module skeleton that satisfies phase 1 exit conditions for tzh's responsibility in project_plan.md section 6.5: scene list, editor and Fake-driven execution that can be tested independently. Domain layer (domain/): - Scene, SceneAction (SocketPower / InfraredKey), SceneIcon, SceneColor - SceneExecutionResult with per-action outcome aggregation - SceneRepository port (observe/get/create/update/delete, provider-isolated) - SceneExecutor port (sequential execution, per-action failure continues) Fakes (testing/fakes/): - FakeSceneRepository: in-memory, provider-isolated, seeds local-experience catalog - FakeSceneExecutor: drives existing SocketGateway/InfraredGateway fakes, rejects concurrent execution of the same scene Android app (android/app/): - SceneListScreen / SceneListViewModel: list, empty state, delete confirm - SceneEditorScreen / SceneEditorViewModel: name/icon/color, action add/remove/reorder - SceneExecutionScreen / SceneExecutionViewModel: run, progress, per-action summary - SceneRoutes + NavGraphBuilder extensions wired into ABoxManagerApp - SceneModule (debug) binds FakeSceneRepository, FakeSceneExecutor and a LocalProfileId qualifier that feat/provider-login will retire later Build (settings.gradle.kts): - Apply foojay-resolver-convention so the JVM 17 toolchain is auto-provisioned Resources: zh-rCN / en strings for all scene surfaces Tests: 20 unit tests covering repository isolation, CRUD, executor ordering, failure continuation and concurrent-execution rejection
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
@JvmInline
|
||||
value class SceneId(val value: String)
|
||||
|
||||
@JvmInline
|
||||
value class SceneActionId(val value: String)
|
||||
|
||||
enum class SceneActionType {
|
||||
SOCKET_POWER,
|
||||
INFRARED_KEY,
|
||||
}
|
||||
|
||||
/**
|
||||
* A single executable step inside a [Scene].
|
||||
*
|
||||
* Socket actions reference the [DeviceName] accepted by ABSDK 0630 and a target [SocketPowerState].
|
||||
* Infrared actions reference a [DeviceName] and the stable key string provided by the infrared
|
||||
* module; the executor delegates the actual send to [InfraredGateway.sendKey].
|
||||
*
|
||||
* `DeviceName` is used (rather than `DeviceId`) because ABSDK addresses devices by name, matching
|
||||
* the existing [SocketGateway] and [InfraredGateway] contracts. The infrared module is responsible
|
||||
* for keeping the key string stable across edits, so scenes can reference it without coupling to
|
||||
* the infrared storage primary key.
|
||||
*/
|
||||
sealed interface SceneAction {
|
||||
val id: SceneActionId
|
||||
val profileId: ProviderProfileId
|
||||
val deviceName: DeviceName
|
||||
val type: SceneActionType
|
||||
|
||||
data class SocketPower(
|
||||
override val id: SceneActionId,
|
||||
override val profileId: ProviderProfileId,
|
||||
override val deviceName: DeviceName,
|
||||
val powerState: SocketPowerState,
|
||||
) : SceneAction {
|
||||
override val type: SceneActionType get() = SceneActionType.SOCKET_POWER
|
||||
}
|
||||
|
||||
data class InfraredKey(
|
||||
override val id: SceneActionId,
|
||||
override val profileId: ProviderProfileId,
|
||||
override val deviceName: DeviceName,
|
||||
val key: String,
|
||||
) : SceneAction {
|
||||
override val type: SceneActionType get() = SceneActionType.INFRARED_KEY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual identity for a scene. Kept as a small enum so the UI can resolve the icon without
|
||||
* external resources and so the persistence layer stays stable across icon set changes.
|
||||
*/
|
||||
enum class SceneIcon {
|
||||
HOME,
|
||||
OFFICE,
|
||||
NIGHT,
|
||||
MOVIE,
|
||||
AWAY,
|
||||
CUSTOM,
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene accent color stored as an uppercase hex string (e.g. `#FF8A65`). The UI maps it to the
|
||||
* Compose color space; persistence stores it verbatim.
|
||||
*/
|
||||
@JvmInline
|
||||
value class SceneColor(val hex: String)
|
||||
|
||||
data class Scene(
|
||||
val id: SceneId,
|
||||
val profileId: ProviderProfileId,
|
||||
val name: String,
|
||||
val icon: SceneIcon,
|
||||
val color: SceneColor,
|
||||
val actions: List<SceneAction>,
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
enum class SceneActionOutcome {
|
||||
SUCCESS,
|
||||
FAILURE,
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of executing a single [SceneAction]. [error] is non-null when [outcome] is [FAILURE].
|
||||
*/
|
||||
data class SceneActionExecutionResult(
|
||||
val action: SceneAction,
|
||||
val outcome: SceneActionOutcome,
|
||||
val error: DomainError?,
|
||||
)
|
||||
|
||||
/**
|
||||
* Aggregated result of executing a [Scene]. Actions are executed sequentially in the order they
|
||||
* appear in [scene.actions]; a per-action failure does not abort subsequent actions.
|
||||
*/
|
||||
data class SceneExecutionResult(
|
||||
val scene: Scene,
|
||||
val results: List<SceneActionExecutionResult>,
|
||||
) {
|
||||
val totalActions: Int get() = results.size
|
||||
val succeededCount: Int
|
||||
get() = results.count { it.outcome == SceneActionOutcome.SUCCESS }
|
||||
val failedCount: Int
|
||||
get() = results.count { it.outcome == SceneActionOutcome.FAILURE }
|
||||
val allSucceeded: Boolean
|
||||
get() = results.isNotEmpty() && failedCount == 0
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
/**
|
||||
* Executes a [Scene] sequentially. Per-action failures are recorded but do not abort subsequent
|
||||
* actions; callers receive a [SceneExecutionResult] that summarizes every step.
|
||||
*
|
||||
* Implementations must be idempotent from the caller's perspective: triggering a scene that is
|
||||
* already running returns [DomainError.OperationFailed] rather than starting a second pass.
|
||||
*/
|
||||
interface SceneExecutor {
|
||||
suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult>
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.flagship.abox.manager.domain
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/**
|
||||
* Owns the scene catalog for every provider profile. Implementations must isolate data by
|
||||
* [ProviderProfileId]: scenes, actions and identifiers must not leak across profiles.
|
||||
*
|
||||
* This port is intentionally persistence-agnostic. The first PR provides an in-memory Fake
|
||||
* implementation; Room persistence will land in a follow-up `feat/scene-persistence` branch.
|
||||
*/
|
||||
interface SceneRepository {
|
||||
fun observeScenes(profileId: ProviderProfileId): Flow<DomainResult<List<Scene>>>
|
||||
|
||||
suspend fun getScene(
|
||||
profileId: ProviderProfileId,
|
||||
sceneId: SceneId,
|
||||
): DomainResult<Scene?>
|
||||
|
||||
suspend fun createScene(
|
||||
profileId: ProviderProfileId,
|
||||
scene: Scene,
|
||||
): DomainResult<Scene>
|
||||
|
||||
suspend fun updateScene(
|
||||
profileId: ProviderProfileId,
|
||||
scene: Scene,
|
||||
): DomainResult<Scene>
|
||||
|
||||
suspend fun deleteScene(
|
||||
profileId: ProviderProfileId,
|
||||
sceneId: SceneId,
|
||||
): DomainResult<Unit>
|
||||
}
|
||||
Reference in New Issue
Block a user