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(
|
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
|
||||||
|
|||||||
+18
-12
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -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,
|
||||||
|
|||||||
+9
@@ -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 {
|
||||||
|
|||||||
+17
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user