feat(ui): complete ABox app experience redesign #10

Merged
KremeCN merged 40 commits from feat/ui-ux-redesign into main 2026-08-27 23:25:08 +08:00
3 changed files with 232 additions and 1 deletions
Showing only changes of commit cce0441476 - Show all commits
@@ -0,0 +1,151 @@
package com.flagship.abox.manager.domain
import java.time.DayOfWeek
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.LocalTime
import java.time.format.DateTimeFormatter
interface AutomationClock {
fun nowEpochMillis(): Long
}
data class AutomationSnapshot(
val observedAtEpochMillis: Long,
val readings: Map<DeviceName, DomainResult<TemperatureHumidityReading>> = emptyMap(),
val socketStatuses: Map<DeviceName, DomainResult<SocketStatus>> = emptyMap(),
)
sealed interface TriggerEvaluation {
data class Matched(val occurrenceKey: String) : TriggerEvaluation
data object NotMatched : TriggerEvaluation
data class Unavailable(val error: DomainError) : TriggerEvaluation
}
sealed interface ConditionsEvaluation {
data object Satisfied : ConditionsEvaluation
data class NotSatisfied(val condition: SceneCondition) : ConditionsEvaluation
data class Unavailable(val error: DomainError) : ConditionsEvaluation
}
class SceneTriggerEvaluator {
fun evaluate(scene: Scene, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation {
if (scene.triggers.isEmpty()) return TriggerEvaluation.NotMatched
val matched = scene.triggers.firstNotNullOfOrNull { trigger -> evaluateOne(trigger, snapshot, now) }
return when (matched) {
is TriggerEvaluation.Matched -> matched
is TriggerEvaluation.Unavailable -> matched
is TriggerEvaluation.NotMatched -> matched
null -> TriggerEvaluation.NotMatched
}
}
private fun evaluateOne(trigger: SceneTrigger, snapshot: AutomationSnapshot, now: LocalDateTime): TriggerEvaluation? = when (trigger) {
is SceneTrigger.Time -> {
val time = parseTime(trigger.timeOfDay) ?: return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid trigger time"))
val dayMatches = trigger.daysOfWeek.isEmpty() || now.dayOfWeek.value in trigger.daysOfWeek
if (dayMatches && now.toLocalTime().hour == time.hour && now.toLocalTime().minute == time.minute) {
TriggerEvaluation.Matched("${now.toLocalDate()}:${trigger.timeOfDay}:${trigger.id.value}")
} else null
}
is SceneTrigger.Temperature -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdCelsius) {
it.temperature
}
is SceneTrigger.Humidity -> threshold(snapshot.readings[trigger.deviceName], trigger.operator, trigger.thresholdPercent) {
it.humidity
}
is SceneTrigger.DeviceState -> {
when (val result = snapshot.socketStatuses[trigger.deviceName]) {
null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable"))
is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error)
is DomainResult.Success -> if (result.value.powerState == trigger.expectedState) {
TriggerEvaluation.Matched("${snapshot.observedAtEpochMillis}:${trigger.id.value}")
} else null
}
}
}
private fun threshold(
result: DomainResult<TemperatureHumidityReading>?,
operator: ComparisonOperator,
threshold: Double,
value: (TemperatureHumidityReading) -> String?,
): TriggerEvaluation? {
if (!threshold.isFinite()) return TriggerEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
return when (result) {
null -> TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
is DomainResult.Failure -> TriggerEvaluation.Unavailable(result.error)
is DomainResult.Success -> {
val actual = value(result.value)?.toDoubleOrNull()
?: return TriggerEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
if (compare(actual, operator, threshold)) TriggerEvaluation.Matched("threshold") else null
}
}
}
private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) {
ComparisonOperator.GREATER_THAN -> actual > threshold
ComparisonOperator.LESS_THAN -> actual < threshold
ComparisonOperator.EQUALS -> actual == threshold
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
}
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
}
class SceneConditionEvaluator {
fun evaluate(conditions: List<SceneCondition>, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation {
for (condition in conditions) {
when (val result = evaluateOne(condition, snapshot, now)) {
ConditionsEvaluation.Satisfied -> Unit
else -> return result
}
}
return ConditionsEvaluation.Satisfied
}
private fun evaluateOne(condition: SceneCondition, snapshot: AutomationSnapshot, now: LocalDateTime): ConditionsEvaluation = when (condition) {
is SceneCondition.Temperature -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdCelsius) { it.temperature }
is SceneCondition.Humidity -> metricCondition(condition, snapshot.readings[condition.deviceName], condition.operator, condition.thresholdPercent) { it.humidity }
is SceneCondition.SocketPower -> when (val result = snapshot.socketStatuses[condition.deviceName]) {
null -> ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Socket state unavailable"))
is DomainResult.Failure -> ConditionsEvaluation.Unavailable(result.error)
is DomainResult.Success -> if (result.value.powerState == condition.expectedState) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
}
is SceneCondition.TimeRange -> {
val start = parseTime(condition.startTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range start"))
val end = parseTime(condition.endTime) ?: return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid range end"))
val current = now.toLocalTime()
val inside = if (start <= end) current in start..end else current >= start || current <= end
if (inside) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
}
}
private fun metricCondition(
condition: SceneCondition,
result: DomainResult<TemperatureHumidityReading>?,
operator: ComparisonOperator,
threshold: Double,
value: (TemperatureHumidityReading) -> String?,
): ConditionsEvaluation {
if (!threshold.isFinite()) return ConditionsEvaluation.Unavailable(DomainError.InvalidRequest(detail = "Invalid threshold"))
val actual = when (result) {
null -> return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
is DomainResult.Failure -> return ConditionsEvaluation.Unavailable(result.error)
is DomainResult.Success -> value(result.value)?.toDoubleOrNull()
?: return ConditionsEvaluation.Unavailable(DomainError.NoData(detail = "Reading unavailable"))
}
return if (compare(actual, operator, threshold)) ConditionsEvaluation.Satisfied else ConditionsEvaluation.NotSatisfied(condition)
}
private fun compare(actual: Double, operator: ComparisonOperator, threshold: Double): Boolean = when (operator) {
ComparisonOperator.GREATER_THAN -> actual > threshold
ComparisonOperator.LESS_THAN -> actual < threshold
ComparisonOperator.EQUALS -> actual == threshold
ComparisonOperator.GREATER_OR_EQUAL -> actual >= threshold
ComparisonOperator.LESS_OR_EQUAL -> actual <= threshold
}
private fun parseTime(value: String): LocalTime? = runCatching { LocalTime.parse(value, DateTimeFormatter.ofPattern("HH:mm")) }.getOrNull()
}
@@ -62,7 +62,7 @@ sealed interface SceneTrigger {
data class DeviceState( data class DeviceState(
override val id: SceneTriggerId, override val id: SceneTriggerId,
val deviceName: DeviceName, val deviceName: DeviceName,
val expectedState: String, // e.g. "ON" / "OFF" val expectedState: SocketPowerState,
val label: String = "", val label: String = "",
) : SceneTrigger ) : SceneTrigger
} }
@@ -0,0 +1,80 @@
package com.flagship.abox.manager.domain.automation
import com.flagship.abox.manager.domain.AutomationSnapshot
import com.flagship.abox.manager.domain.ComparisonOperator
import com.flagship.abox.manager.domain.ConditionsEvaluation
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneCondition
import com.flagship.abox.manager.domain.SceneConditionEvaluator
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTrigger
import com.flagship.abox.manager.domain.SceneTriggerEvaluator
import com.flagship.abox.manager.domain.SceneTriggerId
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import com.flagship.abox.manager.domain.TriggerEvaluation
import java.time.LocalDateTime
import org.junit.Assert.assertTrue
import org.junit.Test
class SceneEvaluatorTest {
private val profile = ProviderProfileId("profile")
private val sensor = DeviceName("sensor")
private val clockTime = LocalDateTime.of(2026, 8, 26, 7, 30)
private val triggerEvaluator = SceneTriggerEvaluator()
private val conditionEvaluator = SceneConditionEvaluator()
@Test
fun timeMatchesConfiguredWeekday() {
val trigger = SceneTrigger.Time(SceneTriggerId("time"), "07:30", setOf(3))
val result = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime)
assertTrue(result is TriggerEvaluation.Matched)
}
@Test
fun timeRangeCrossesMidnight() {
val condition = SceneCondition.TimeRange(com.flagship.abox.manager.domain.SceneConditionId("range"), "22:00", "07:00")
val result = conditionEvaluator.evaluate(listOf(condition), AutomationSnapshot(1L), LocalDateTime.of(2026, 8, 26, 6, 45))
assertTrue(result is ConditionsEvaluation.Satisfied)
}
@Test
fun temperatureComparisonAndMissingReadingFailClosed() {
val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0)
val missing = triggerEvaluator.evaluate(scene(trigger), AutomationSnapshot(1L), clockTime)
val reading = AutomationSnapshot(
1L,
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", null))),
)
val matched = triggerEvaluator.evaluate(scene(trigger), reading, clockTime)
assertTrue(missing is TriggerEvaluation.Unavailable)
assertTrue(matched is TriggerEvaluation.Matched)
}
@Test
fun allConditionsMustPass() {
val reading = AutomationSnapshot(
1L,
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))),
)
val conditions = listOf(
SceneCondition.Temperature(com.flagship.abox.manager.domain.SceneConditionId("temp"), sensor, ComparisonOperator.GREATER_THAN, 25.0),
SceneCondition.Humidity(com.flagship.abox.manager.domain.SceneConditionId("humidity"), sensor, ComparisonOperator.LESS_THAN, 50.0),
)
assertTrue(conditionEvaluator.evaluate(conditions, reading, clockTime) is ConditionsEvaluation.Satisfied)
}
@Test
fun invalidNumberReturnsUnavailable() {
val trigger = SceneTrigger.Temperature(SceneTriggerId("temp"), sensor, ComparisonOperator.GREATER_THAN, Double.NaN)
val reading = AutomationSnapshot(
1L,
readings = mapOf(sensor to DomainResult.Success(TemperatureHumidityReading(profile, sensor, "26", "40"))),
)
assertTrue(triggerEvaluator.evaluate(scene(trigger), reading, clockTime) is TriggerEvaluation.Unavailable)
}
private fun scene(trigger: SceneTrigger) = Scene(SceneId("scene"), profile, "测试", triggers = listOf(trigger))
}