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
8 changed files with 121 additions and 13 deletions
Showing only changes of commit a30ab9e376 - Show all commits
@@ -133,6 +133,25 @@ class AbsdkBridge internal constructor(
} }
} }
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = serialized {
withActiveSession(profileId) {
when (val control = call {
client.setSocketPower(deviceName.value, powerState.toSdkStatus())
}) {
is ClientCall.Failed -> DomainResult.Failure(control.error)
is ClientCall.Completed -> if (control.response.isSuccess()) {
DomainResult.Success(Unit)
} else {
DomainResult.Failure(AbsdkErrorMapper.fromResponse(control.response))
}
}
}
}
override suspend fun getReading( override suspend fun getReading(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
@@ -62,6 +62,15 @@ internal class RoutingSocketGateway(
is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName) is DomainResult.Success -> delegate(provider.value).getStatus(profileId, deviceName)
} }
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = when (val provider = resolver.resolve(profileId)) {
is DomainResult.Failure -> provider
is DomainResult.Success -> delegate(provider.value).commandPower(profileId, deviceName, powerState)
}
override suspend fun setPower( override suspend fun setPower(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
@@ -14,14 +14,27 @@ data class SocketUiState(
val isOffline: Boolean = false, val isOffline: Boolean = false,
val errorMessage: UiText? = null, val errorMessage: UiText? = null,
val lastConfirmedTimeMillis: Long? = null, val lastConfirmedTimeMillis: Long? = null,
val operation: SocketOperationState = SocketOperationState.IdleConfirmed,
) { ) {
val isActionEnabled: Boolean val isActionEnabled: Boolean
get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null get() = !isLoading && operation !is SocketOperationState.PendingCommand &&
operation !is SocketOperationState.RefreshingConfirmation &&
operation !is SocketOperationState.AwaitingConfirmation &&
!isOffline && profileId != null && deviceName != null
val isPowerOn: Boolean val isPowerOn: Boolean
get() = powerState == SocketPowerState.ON get() = powerState == SocketPowerState.ON
} }
sealed interface SocketOperationState {
data object IdleConfirmed : SocketOperationState
data class PendingCommand(val target: SocketPowerState) : SocketOperationState
data class RefreshingConfirmation(val target: SocketPowerState) : SocketOperationState
data class AwaitingConfirmation(val target: SocketPowerState) : SocketOperationState
data class Failed(val target: SocketPowerState) : SocketOperationState
data object Unavailable : SocketOperationState
}
sealed interface SocketUiEvent { sealed interface SocketUiEvent {
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
@@ -56,6 +56,7 @@ class SocketViewModel @Inject constructor(
it.copy( it.copy(
profileId = event.profileId, profileId = event.profileId,
deviceName = event.deviceName, deviceName = event.deviceName,
operation = SocketOperationState.Unavailable,
) )
} }
loadStatus() loadStatus()
@@ -115,6 +116,7 @@ class SocketViewModel @Inject constructor(
isOffline = false, isOffline = false,
errorMessage = null, errorMessage = null,
lastConfirmedTimeMillis = System.currentTimeMillis(), lastConfirmedTimeMillis = System.currentTimeMillis(),
operation = SocketOperationState.IdleConfirmed,
) )
} }
} }
@@ -126,6 +128,7 @@ class SocketViewModel @Inject constructor(
isLoading = false, isLoading = false,
isOffline = isOffline, isOffline = isOffline,
errorMessage = DomainErrorMapper.map(result.error), errorMessage = DomainErrorMapper.map(result.error),
operation = SocketOperationState.Unavailable,
) )
} }
} }
@@ -139,25 +142,27 @@ class SocketViewModel @Inject constructor(
val deviceName = current.deviceName ?: return val deviceName = current.deviceName ?: return
// Prevent duplicate action if currently operating, loading or offline // Prevent duplicate action if currently operating, loading or offline
if (current.isOperating || current.isLoading || current.isOffline) { if (current.isOperating || current.isLoading || current.isOffline || !current.isActionEnabled) {
return return
} }
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isOperating = true, errorMessage = null) } _state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
when (val result = socketGateway.setPower(profileId, deviceName, targetState)) { when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) {
is DomainResult.Success -> { is DomainResult.Success -> {
_state.update { _state.update { it.copy(operation = SocketOperationState.RefreshingConfirmation(targetState)) }
it.copy( when (val confirmation = socketGateway.getStatus(profileId, deviceName)) {
powerState = result.value.powerState, is DomainResult.Success -> _state.update {
isOperating = false, it.copy(powerState = confirmation.value.powerState, isOperating = false, isOffline = false,
isOffline = false, errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(),
errorMessage = null, operation = SocketOperationState.IdleConfirmed)
lastConfirmedTimeMillis = System.currentTimeMillis(), }
) is DomainResult.Failure -> _state.update {
it.copy(isOperating = false, errorMessage = DomainErrorMapper.map(confirmation.error),
operation = SocketOperationState.AwaitingConfirmation(targetState))
}
} }
} }
is DomainResult.Failure -> { is DomainResult.Failure -> {
val isOffline = result.error is DomainError.DeviceOffline val isOffline = result.error is DomainError.DeviceOffline
_state.update { _state.update {
@@ -165,6 +170,7 @@ class SocketViewModel @Inject constructor(
isOperating = false, isOperating = false,
isOffline = isOffline, isOffline = isOffline,
errorMessage = DomainErrorMapper.map(result.error), errorMessage = DomainErrorMapper.map(result.error),
operation = SocketOperationState.Failed(targetState),
) )
} }
} }
@@ -157,6 +157,32 @@ class SocketViewModelTest {
assertNotNull(state.errorMessage) assertNotNull(state.errorMessage)
} }
@Test
fun commandSuccessReadsBackConfirmedState() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
assertEquals(SocketOperationState.IdleConfirmed, viewModel.state.value.operation)
}
@Test
fun readbackFailureKeepsLastConfirmedStateAndMarksAwaitingConfirmation() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Readback failed"))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
assertEquals(
SocketOperationState.AwaitingConfirmation(SocketPowerState.ON),
viewModel.state.value.operation,
)
}
@Test @Test
fun refresh_queriesUpdatedGatewayStatus() = runTest { fun refresh_queriesUpdatedGatewayStatus() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
@@ -63,6 +63,15 @@ interface SocketGateway {
deviceName: DeviceName, deviceName: DeviceName,
): DomainResult<SocketStatus> ): DomainResult<SocketStatus>
suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> = when (val result = setPower(profileId, deviceName, powerState)) {
is DomainResult.Success -> DomainResult.Success(Unit)
is DomainResult.Failure -> result
}
suspend fun setPower( suspend fun setPower(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,
@@ -33,6 +33,15 @@ class SimulationSocketGateway : SocketGateway {
states[key] = powerState states[key] = powerState
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
} }
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> {
states[DeviceKey(profileId, deviceName)] = powerState
return DomainResult.Success(Unit)
}
} }
class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway { class SimulationTemperatureHumidityGateway : TemperatureHumidityGateway {
@@ -18,6 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList
class FakeSocketGateway : SocketGateway { class FakeSocketGateway : SocketGateway {
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>() private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
private val errors = ConcurrentHashMap<DeviceKey, DomainError>() private val errors = ConcurrentHashMap<DeviceKey, DomainError>()
private val commandErrors = ConcurrentHashMap<DeviceKey, DomainError>()
override suspend fun getStatus( override suspend fun getStatus(
profileId: ProviderProfileId, profileId: ProviderProfileId,
@@ -35,11 +36,27 @@ class FakeSocketGateway : SocketGateway {
return DomainResult.Success(SocketStatus(profileId, deviceName, powerState)) return DomainResult.Success(SocketStatus(profileId, deviceName, powerState))
} }
override suspend fun commandPower(
profileId: ProviderProfileId,
deviceName: DeviceName,
powerState: SocketPowerState,
): DomainResult<Unit> {
val key = DeviceKey(profileId, deviceName)
commandErrors[key]?.let { return DomainResult.Failure(it) }
states[key] = powerState
return DomainResult.Success(Unit)
}
fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) { fun setError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) {
val key = DeviceKey(profileId, deviceName) val key = DeviceKey(profileId, deviceName)
if (error == null) errors.remove(key) else errors[key] = error if (error == null) errors.remove(key) else errors[key] = error
} }
fun setCommandError(profileId: ProviderProfileId, deviceName: DeviceName, error: DomainError?) {
val key = DeviceKey(profileId, deviceName)
if (error == null) commandErrors.remove(key) else commandErrors[key] = error
}
private fun resultFor( private fun resultFor(
profileId: ProviderProfileId, profileId: ProviderProfileId,
deviceName: DeviceName, deviceName: DeviceName,