feat(ui): complete app experience redesign

This commit is contained in:
2026-08-27 11:45:30 +08:00
parent 816606234b
commit 54aa74126f
70 changed files with 4510 additions and 985 deletions
@@ -0,0 +1,54 @@
package com.flagship.abox.manager.domain
import java.util.UUID
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
class ActivityEventWriter(
private val repository: ActivityRepository,
private val scope: CoroutineScope,
) {
fun recordDevice(
profileId: ProviderProfileId,
deviceName: DeviceName,
kind: ActivityEventKind,
outcome: ActivityOutcome,
errorCode: String? = null,
source: ActivitySource = ActivitySource.USER,
) {
record(
profileId = profileId,
kind = kind,
source = source,
outcome = outcome,
target = ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value),
errorCode = errorCode,
)
}
fun record(
profileId: ProviderProfileId,
kind: ActivityEventKind,
source: ActivitySource,
outcome: ActivityOutcome,
target: ActivityTarget,
errorCode: String? = null,
) {
scope.launch {
runCatching {
repository.append(
ActivityEvent(
id = ActivityEventId(UUID.randomUUID().toString()),
profileId = profileId,
occurredAtEpochMillis = System.currentTimeMillis(),
kind = kind,
source = source,
outcome = outcome,
target = target,
errorCode = errorCode,
),
)
}
}
}
}
@@ -92,8 +92,7 @@ class InMemorySceneRepository : SceneRepository {
private fun flowFor(profileId: ProviderProfileId): MutableStateFlow<DomainResult<List<Scene>>> =
flows.getOrPut(profileId) {
val initial = if (profileId == LOCAL_EXPERIENCE_PROFILE) defaultLocalScenes(profileId) else emptyList()
MutableStateFlow(DomainResult.Success(initial))
MutableStateFlow(DomainResult.Success(defaultScenes(profileId)))
}
private fun currentScenes(profileId: ProviderProfileId): List<Scene>? =
@@ -107,12 +106,30 @@ class InMemorySceneRepository : SceneRepository {
DomainError.InvalidRequest(detail = "Scene ${sceneId.value} not found"),
)
private fun defaultLocalScenes(profileId: ProviderProfileId): List<Scene> = listOf(
private fun defaultScenes(profileId: ProviderProfileId): List<Scene> = listOf(
Scene(
id = SceneId("${profileId.value}:scene-arriving-home"),
profileId = profileId,
name = "回家模式",
description = "开启 Zigbee 插座控制器",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.HOME,
color = SceneColor("#4CAF50"),
actions = listOf(
SceneAction.SocketPower(
id = SceneActionId("${profileId.value}:scene-arriving-home:socket-1"),
profileId = profileId,
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.ON,
),
),
),
Scene(
id = SceneId("${profileId.value}:scene-leaving-home"),
profileId = profileId,
name = "离家模式",
description = "关闭插座并延时关闭电视",
description = "关闭 Zigbee 插座控制器",
type = AutomationType.MANUAL,
enabled = true,
icon = SceneIcon.AWAY,
@@ -124,16 +141,32 @@ class InMemorySceneRepository : SceneRepository {
deviceName = DeviceName("Zigbee插座控制器"),
powerState = SocketPowerState.OFF,
),
SceneAction.Delay(
id = SceneActionId("${profileId.value}:scene-leaving-home:delay-1"),
profileId = profileId,
durationSeconds = 1,
),
),
Scene(
id = SceneId("${profileId.value}:scene-temperature-alert"),
profileId = profileId,
name = "高温提醒",
description = "室温高于 28°C 时发送提醒",
type = AutomationType.AUTOMATIC,
enabled = true,
icon = SceneIcon.THERMOSTAT,
color = SceneColor("#EF5350"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
label = "温度高于 28°C",
),
SceneAction.InfraredKey(
id = SceneActionId("${profileId.value}:scene-leaving-home:ir-1"),
),
actions = listOf(
SceneAction.Notification(
id = SceneActionId("${profileId.value}:scene-temperature-alert:notification-1"),
profileId = profileId,
deviceName = DeviceName("红外控制器"),
key = "1",
title = "温度提醒",
message = "当前室温已高于 28°C",
),
),
),
@@ -148,7 +181,7 @@ class InMemorySceneRepository : SceneRepository {
color = SceneColor("#26A69A"),
triggers = listOf(
SceneTrigger.Temperature(
id = SceneTriggerId("${profileId.value}:trigger-temp"),
id = SceneTriggerId("${profileId.value}:trigger-cooling-temp"),
deviceName = DeviceName("温湿度传感器"),
operator = ComparisonOperator.GREATER_THAN,
thresholdCelsius = 28.0,
@@ -157,7 +190,7 @@ class InMemorySceneRepository : SceneRepository {
),
conditions = listOf(
SceneCondition.SocketPower(
id = SceneConditionId("${profileId.value}:cond-socket"),
id = SceneConditionId("${profileId.value}:cond-cooling-socket"),
deviceName = DeviceName("Zigbee插座控制器"),
expectedState = SocketPowerState.OFF,
),
@@ -23,18 +23,6 @@ class AddVirtualRemoteKey(
}
val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase()
val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
// Find next available slot
val quota = when (val result = repository.slotQuota(profileId, remote.deviceName)) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return result
}
val usedSlots = quota.assignedStaticSlots
val nextSlot = (1..99).firstOrNull { it !in usedSlots }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"))
nextSlot.toString()
} else null
val binding = RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
@@ -42,9 +30,9 @@ class AddVirtualRemoteKey(
hardwareDeviceName = remote.deviceName,
buttonId = buttonId,
displayName = trimmed,
assignedSlot = assignedSlot,
assignedSlot = null,
rawHexCode = null,
status = InfraredKeyStatus.FAILED, // Initially unlearned
status = InfraredKeyStatus.UNLEARNED,
)
when (val res = repository.updateKeyBinding(binding)) {
@@ -69,7 +69,7 @@ class CreateVirtualRemote(
displayName = template.displayName,
assignedSlot = (index + 1).toString(),
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED,
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.UNLEARNED,
)
}
} else {
@@ -86,7 +86,7 @@ class CreateVirtualRemote(
status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) {
InfraredKeyStatus.READY
} else {
InfraredKeyStatus.FAILED
InfraredKeyStatus.UNLEARNED
},
)
}
@@ -12,6 +12,7 @@ data class InfraredKeyRef(
)
enum class InfraredKeyStatus {
UNLEARNED,
LEARNING,
READY,
FAILED,
@@ -181,6 +181,7 @@ class RelearnInfraredKey(
val result = repository.transitionStatus(
ref = ref,
expectedStatuses = setOf(
InfraredKeyStatus.UNLEARNED,
InfraredKeyStatus.READY,
InfraredKeyStatus.FAILED,
InfraredKeyStatus.LEARNING,
@@ -18,6 +18,11 @@ class LearnVirtualRemoteKey(
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
if (remote.type != RemoteDeviceType.CUSTOM) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Only custom remotes support learning"),
)
}
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> return res
@@ -85,7 +85,7 @@ data class RemoteKeyBinding(
val displayName: String,
val assignedSlot: String? = null,
val rawHexCode: String? = null,
val status: InfraredKeyStatus = InfraredKeyStatus.READY,
val status: InfraredKeyStatus = InfraredKeyStatus.UNLEARNED,
val lastErrorCode: String? = null,
val lastErrorDetail: String? = null,
)