feat(scene): add scene editor, executor and fakes (phase 1)

Introduce the scene module skeleton that satisfies phase 1 exit conditions for
tzh's responsibility in project_plan.md section 6.5: scene list, editor and Fake-driven
execution that can be tested independently.

Domain layer (domain/):
- Scene, SceneAction (SocketPower / InfraredKey), SceneIcon, SceneColor
- SceneExecutionResult with per-action outcome aggregation
- SceneRepository port (observe/get/create/update/delete, provider-isolated)
- SceneExecutor port (sequential execution, per-action failure continues)

Fakes (testing/fakes/):
- FakeSceneRepository: in-memory, provider-isolated, seeds local-experience catalog
- FakeSceneExecutor: drives existing SocketGateway/InfraredGateway fakes,
  rejects concurrent execution of the same scene

Android app (android/app/):
- SceneListScreen / SceneListViewModel: list, empty state, delete confirm
- SceneEditorScreen / SceneEditorViewModel: name/icon/color, action add/remove/reorder
- SceneExecutionScreen / SceneExecutionViewModel: run, progress, per-action summary
- SceneRoutes + NavGraphBuilder extensions wired into ABoxManagerApp
- SceneModule (debug) binds FakeSceneRepository, FakeSceneExecutor and a
  LocalProfileId qualifier that feat/provider-login will retire later

Build (settings.gradle.kts):
- Apply foojay-resolver-convention so the JVM 17 toolchain is auto-provisioned

Resources: zh-rCN / en strings for all scene surfaces
Tests: 20 unit tests covering repository isolation, CRUD, executor ordering,
failure continuation and concurrent-execution rejection
This commit is contained in:
tzh
2026-08-24 11:26:46 +08:00
parent 6f7d3248b0
commit c9dc3e7a77
20 changed files with 1919 additions and 35 deletions
@@ -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>
}