feat(ui): complete ABox app experience redesign #10
@@ -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(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
|
||||
@@ -62,6 +62,15 @@ internal class RoutingSocketGateway(
|
||||
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(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
|
||||
@@ -14,14 +14,27 @@ data class SocketUiState(
|
||||
val isOffline: Boolean = false,
|
||||
val errorMessage: UiText? = null,
|
||||
val lastConfirmedTimeMillis: Long? = null,
|
||||
val operation: SocketOperationState = SocketOperationState.IdleConfirmed,
|
||||
) {
|
||||
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
|
||||
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 {
|
||||
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
|
||||
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
||||
|
||||
+18
-12
@@ -56,6 +56,7 @@ class SocketViewModel @Inject constructor(
|
||||
it.copy(
|
||||
profileId = event.profileId,
|
||||
deviceName = event.deviceName,
|
||||
operation = SocketOperationState.Unavailable,
|
||||
)
|
||||
}
|
||||
loadStatus()
|
||||
@@ -115,6 +116,7 @@ class SocketViewModel @Inject constructor(
|
||||
isOffline = false,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||
operation = SocketOperationState.IdleConfirmed,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -126,6 +128,7 @@ class SocketViewModel @Inject constructor(
|
||||
isLoading = false,
|
||||
isOffline = isOffline,
|
||||
errorMessage = DomainErrorMapper.map(result.error),
|
||||
operation = SocketOperationState.Unavailable,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,25 +142,27 @@ class SocketViewModel @Inject constructor(
|
||||
val deviceName = current.deviceName ?: return
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isOperating = true, errorMessage = null) }
|
||||
when (val result = socketGateway.setPower(profileId, deviceName, targetState)) {
|
||||
_state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
|
||||
when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) {
|
||||
is DomainResult.Success -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
powerState = result.value.powerState,
|
||||
isOperating = false,
|
||||
isOffline = false,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
_state.update { it.copy(operation = SocketOperationState.RefreshingConfirmation(targetState)) }
|
||||
when (val confirmation = socketGateway.getStatus(profileId, deviceName)) {
|
||||
is DomainResult.Success -> _state.update {
|
||||
it.copy(powerState = confirmation.value.powerState, isOperating = false, isOffline = false,
|
||||
errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||
operation = SocketOperationState.IdleConfirmed)
|
||||
}
|
||||
is DomainResult.Failure -> _state.update {
|
||||
it.copy(isOperating = false, errorMessage = DomainErrorMapper.map(confirmation.error),
|
||||
operation = SocketOperationState.AwaitingConfirmation(targetState))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is DomainResult.Failure -> {
|
||||
val isOffline = result.error is DomainError.DeviceOffline
|
||||
_state.update {
|
||||
@@ -165,6 +170,7 @@ class SocketViewModel @Inject constructor(
|
||||
isOperating = false,
|
||||
isOffline = isOffline,
|
||||
errorMessage = DomainErrorMapper.map(result.error),
|
||||
operation = SocketOperationState.Failed(targetState),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+26
@@ -157,6 +157,32 @@ class SocketViewModelTest {
|
||||
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
|
||||
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
|
||||
@@ -63,6 +63,15 @@ interface SocketGateway {
|
||||
deviceName: DeviceName,
|
||||
): 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(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
|
||||
+9
@@ -33,6 +33,15 @@ class SimulationSocketGateway : SocketGateway {
|
||||
states[key] = 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 {
|
||||
|
||||
+17
@@ -18,6 +18,7 @@ import java.util.concurrent.CopyOnWriteArrayList
|
||||
class FakeSocketGateway : SocketGateway {
|
||||
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
||||
private val errors = ConcurrentHashMap<DeviceKey, DomainError>()
|
||||
private val commandErrors = ConcurrentHashMap<DeviceKey, DomainError>()
|
||||
|
||||
override suspend fun getStatus(
|
||||
profileId: ProviderProfileId,
|
||||
@@ -35,11 +36,27 @@ class FakeSocketGateway : SocketGateway {
|
||||
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?) {
|
||||
val key = DeviceKey(profileId, deviceName)
|
||||
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(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
|
||||
Reference in New Issue
Block a user