From 363532e449af0b9f12476100e8ef58bd295c5bc2 Mon Sep 17 00:00:00 2001 From: Aurum Date: Mon, 24 Aug 2026 14:37:30 +0800 Subject: [PATCH] feat: add socket control and temperature humidity modules Implement ViewModel state management, Compose UI screens, navigation routing, localized strings, and comprehensive unit tests for smart socket control and temperature/humidity telemetry monitoring. --- android/app/build.gradle.kts | 1 + .../manager/ui/common/DomainErrorMapper.kt | 25 ++ .../flagship/abox/manager/ui/common/UiText.kt | 36 ++ .../ui/navigation/DeviceDetailNavigation.kt | 54 +++ .../abox/manager/ui/socket/SocketScreen.kt | 336 +++++++++++++++ .../abox/manager/ui/socket/SocketUiState.kt | 31 ++ .../abox/manager/ui/socket/SocketViewModel.kt | 151 +++++++ .../TemperatureHumidityScreen.kt | 393 ++++++++++++++++++ .../TemperatureHumidityUiState.kt | 33 ++ .../TemperatureHumidityViewModel.kt | 110 +++++ .../src/main/res/values-zh-rCN/strings.xml | 40 ++ android/app/src/main/res/values/strings.xml | 40 ++ .../manager/ui/socket/SocketViewModelTest.kt | 183 ++++++++ .../TemperatureHumidityViewModelTest.kt | 171 ++++++++ 14 files changed, 1604 insertions(+) create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/UiText.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt create mode 100644 android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt create mode 100644 android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index f8843f4..1a4ce19 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -52,6 +52,7 @@ android { excludes += "/META-INF/DEPENDENCIES" excludes += "/META-INF/LICENSE*" excludes += "/META-INF/NOTICE*" + excludes += "/META-INF/INDEX.LIST" } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt new file mode 100644 index 0000000..fe37e14 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/DomainErrorMapper.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.ui.common + +import com.flagship.abox.manager.R +import com.flagship.abox.manager.domain.DomainError + +object DomainErrorMapper { + fun map(error: DomainError): UiText = when (error) { + is DomainError.Authentication -> UiText.StringResource(R.string.error_authentication) + is DomainError.TokenInvalid -> UiText.StringResource(R.string.error_token_invalid) + is DomainError.Network -> UiText.StringResource(R.string.error_network) + is DomainError.DeviceNotFound -> UiText.StringResource(R.string.error_device_not_found) + is DomainError.DeviceOffline -> UiText.StringResource(R.string.error_device_offline) + is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request) + is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed) + is DomainError.NoData -> UiText.StringResource(R.string.error_no_data) + is DomainError.Unknown -> { + val detail = error.detail + if (!detail.isNullOrBlank()) { + UiText.DynamicString(detail) + } else { + UiText.StringResource(R.string.error_unknown) + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/UiText.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/UiText.kt new file mode 100644 index 0000000..bbe7249 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/common/UiText.kt @@ -0,0 +1,36 @@ +package com.flagship.abox.manager.ui.common + +import android.content.Context +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource + +sealed interface UiText { + data class DynamicString(val value: String) : UiText + + data class StringResource( + @StringRes val resId: Int, + val args: List = emptyList(), + ) : UiText { + constructor(@StringRes resId: Int, vararg args: Any) : this(resId, args.toList()) + } + + @Composable + fun asString(): String = when (this) { + is DynamicString -> value + is StringResource -> if (args.isEmpty()) { + stringResource(resId) + } else { + stringResource(resId, *args.toTypedArray()) + } + } + + fun asString(context: Context): String = when (this) { + is DynamicString -> value + is StringResource -> if (args.isEmpty()) { + context.getString(resId) + } else { + context.getString(resId, *args.toTypedArray()) + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt new file mode 100644 index 0000000..3b6e60a --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt @@ -0,0 +1,54 @@ +package com.flagship.abox.manager.ui.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.ui.socket.SocketRoute +import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityRoute + +object DeviceDetailRoutes { + const val SOCKET_PATTERN = "device/socket/{profileId}/{deviceName}" + const val TH_PATTERN = "device/th/{profileId}/{deviceName}" + + fun socketRoute(profileId: ProviderProfileId, deviceName: DeviceName): String = + "device/socket/${profileId.value}/${deviceName.value}" + + fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String = + "device/th/${profileId.value}/${deviceName.value}" +} + +fun NavController.navigateToSocket(profileId: ProviderProfileId, deviceName: DeviceName) { + navigate(DeviceDetailRoutes.socketRoute(profileId, deviceName)) +} + +fun NavController.navigateToTemperatureHumidity(profileId: ProviderProfileId, deviceName: DeviceName) { + navigate(DeviceDetailRoutes.temperatureHumidityRoute(profileId, deviceName)) +} + +fun NavGraphBuilder.deviceDetailGraph( + onBackClick: () -> Unit, +) { + composable( + route = DeviceDetailRoutes.SOCKET_PATTERN, + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("deviceName") { type = NavType.StringType }, + ), + ) { + SocketRoute(onBackClick = onBackClick) + } + + composable( + route = DeviceDetailRoutes.TH_PATTERN, + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("deviceName") { type = NavType.StringType }, + ), + ) { + TemperatureHumidityRoute(onBackClick = onBackClick) + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt new file mode 100644 index 0000000..47f79e1 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt @@ -0,0 +1,336 @@ +package com.flagship.abox.manager.ui.socket + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import com.flagship.abox.manager.domain.SocketPowerState +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun SocketRoute( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: SocketViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + SocketScreen( + state = state, + onEvent = viewModel::onEvent, + onBackClick = onBackClick, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SocketScreen( + state: SocketUiState, + onEvent: (SocketUiEvent) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = state.deviceName?.value ?: stringResource(R.string.socket_title), + style = MaterialTheme.typography.titleLarge, + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + }, + actions = { + IconButton( + onClick = { onEvent(SocketUiEvent.Refresh) }, + enabled = !state.isLoading && !state.isOperating, + ) { + Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp, vertical = 16.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (state.isOffline) { + OfflineBanner( + onRetry = { onEvent(SocketUiEvent.Refresh) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + ) + } + + state.errorMessage?.let { errorText -> + ErrorBanner( + message = errorText.asString(), + onDismiss = { onEvent(SocketUiEvent.ClearError) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + ) + } + + // Central Status Card + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + PowerIndicatorBadge( + powerState = state.powerState, + isOffline = state.isOffline, + isLoading = state.isLoading, + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = stringResource(R.string.socket_status_label), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = when { + state.isOffline -> stringResource(R.string.socket_status_offline) + state.powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on) + state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off) + else -> stringResource(R.string.socket_status_unknown) + }, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = when { + state.isOffline -> MaterialTheme.colorScheme.error + state.powerState == SocketPowerState.ON -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.padding(top = 4.dp), + ) + + state.lastConfirmedTimeMillis?.let { timeMillis -> + val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis)) + Text( + text = stringResource(R.string.socket_confirmed_at, formattedTime), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + modifier = Modifier.padding(top = 8.dp), + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Main Toggle Action Button + ABoxButton( + onClick = { onEvent(SocketUiEvent.TogglePower) }, + enabled = state.isActionEnabled, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + ) { + if (state.isOperating) { + ABoxLoadingIndicator(modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(12.dp)) + Text(stringResource(R.string.socket_operating_label)) + } else { + Text( + text = if (state.isPowerOn) { + stringResource(R.string.socket_action_turn_off) + } else { + stringResource(R.string.socket_action_turn_on) + }, + style = MaterialTheme.typography.titleMedium, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Dedicated Segmented Action Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + OutlinedButton( + onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) }, + enabled = state.isActionEnabled && state.powerState != SocketPowerState.ON, + modifier = Modifier.weight(1f).height(48.dp), + ) { + Text(stringResource(R.string.socket_action_turn_on)) + } + + OutlinedButton( + onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) }, + enabled = state.isActionEnabled && state.powerState != SocketPowerState.OFF, + modifier = Modifier.weight(1f).height(48.dp), + ) { + Text(stringResource(R.string.socket_action_turn_off)) + } + } + } + } +} + +@Composable +private fun PowerIndicatorBadge( + powerState: SocketPowerState?, + isOffline: Boolean, + isLoading: Boolean, + modifier: Modifier = Modifier, +) { + val indicatorColor = when { + isOffline -> MaterialTheme.colorScheme.error + powerState == SocketPowerState.ON -> MaterialTheme.colorScheme.primary + powerState == SocketPowerState.OFF -> MaterialTheme.colorScheme.outline + else -> MaterialTheme.colorScheme.outlineVariant + } + + Box( + modifier = modifier + .size(96.dp) + .clip(CircleShape) + .background(indicatorColor.copy(alpha = 0.15f)) + .border(3.dp, indicatorColor, CircleShape), + contentAlignment = Alignment.Center, + ) { + if (isLoading) { + ABoxLoadingIndicator(modifier = Modifier.size(36.dp)) + } else { + Text( + text = if (powerState == SocketPowerState.ON) "⚡" else "○", + style = MaterialTheme.typography.headlineLarge, + color = indicatorColor, + ) + } + } +} + +@Composable +private fun OfflineBanner( + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.status_device_offline_banner), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.action_retry)) + } + } + } +} + +@Composable +private fun ErrorBanner( + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.85f), + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { + Text("✕") + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt new file mode 100644 index 0000000..13f0ef5 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.ui.socket + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.ui.common.UiText + +data class SocketUiState( + val profileId: ProviderProfileId? = null, + val deviceName: DeviceName? = null, + val powerState: SocketPowerState? = null, + val isLoading: Boolean = false, + val isOperating: Boolean = false, + val isOffline: Boolean = false, + val errorMessage: UiText? = null, + val lastConfirmedTimeMillis: Long? = null, +) { + val isActionEnabled: Boolean + get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null + + val isPowerOn: Boolean + get() = powerState == SocketPowerState.ON +} + +sealed interface SocketUiEvent { + data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent + data object Refresh : SocketUiEvent + data object TogglePower : SocketUiEvent + data class SetPower(val targetState: SocketPowerState) : SocketUiEvent + data object ClearError : SocketUiEvent +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt new file mode 100644 index 0000000..02b5e57 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt @@ -0,0 +1,151 @@ +package com.flagship.abox.manager.ui.socket + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketGateway +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.ui.common.DomainErrorMapper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class SocketViewModel @Inject constructor( + private val socketGateway: SocketGateway, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val _state = MutableStateFlow(SocketUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + val profileIdString: String? = savedStateHandle["profileId"] + val deviceNameString: String? = savedStateHandle["deviceName"] + + if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) { + onEvent( + SocketUiEvent.Initialize( + profileId = ProviderProfileId(profileIdString), + deviceName = DeviceName(deviceNameString), + ), + ) + } + } + + fun onEvent(event: SocketUiEvent) { + when (event) { + is SocketUiEvent.Initialize -> { + _state.update { + it.copy( + profileId = event.profileId, + deviceName = event.deviceName, + ) + } + loadStatus() + } + + SocketUiEvent.Refresh -> { + loadStatus() + } + + SocketUiEvent.TogglePower -> { + val currentState = _state.value + val targetState = if (currentState.isPowerOn) { + SocketPowerState.OFF + } else { + SocketPowerState.ON + } + setPower(targetState) + } + + is SocketUiEvent.SetPower -> { + setPower(event.targetState) + } + + SocketUiEvent.ClearError -> { + _state.update { it.copy(errorMessage = null) } + } + } + } + + private fun loadStatus() { + val profileId = _state.value.profileId ?: return + val deviceName = _state.value.deviceName ?: return + + viewModelScope.launch { + _state.update { it.copy(isLoading = true, errorMessage = null) } + when (val result = socketGateway.getStatus(profileId, deviceName)) { + is DomainResult.Success -> { + _state.update { + it.copy( + powerState = result.value.powerState, + isLoading = false, + isOffline = false, + errorMessage = null, + lastConfirmedTimeMillis = System.currentTimeMillis(), + ) + } + } + + is DomainResult.Failure -> { + val isOffline = result.error is DomainError.DeviceOffline + _state.update { + it.copy( + isLoading = false, + isOffline = isOffline, + errorMessage = DomainErrorMapper.map(result.error), + ) + } + } + } + } + } + + private fun setPower(targetState: SocketPowerState) { + val current = _state.value + val profileId = current.profileId ?: return + val deviceName = current.deviceName ?: return + + // Prevent duplicate action if currently operating, loading or offline + if (current.isOperating || current.isLoading || current.isOffline) { + return + } + + viewModelScope.launch { + _state.update { it.copy(isOperating = true, errorMessage = null) } + when (val result = socketGateway.setPower(profileId, deviceName, targetState)) { + is DomainResult.Success -> { + _state.update { + it.copy( + powerState = result.value.powerState, + isOperating = false, + isOffline = false, + errorMessage = null, + lastConfirmedTimeMillis = System.currentTimeMillis(), + ) + } + } + + is DomainResult.Failure -> { + val isOffline = result.error is DomainError.DeviceOffline + _state.update { + it.copy( + isOperating = false, + isOffline = isOffline, + errorMessage = DomainErrorMapper.map(result.error), + ) + } + } + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt new file mode 100644 index 0000000..f692de8 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt @@ -0,0 +1,393 @@ +package com.flagship.abox.manager.ui.temperaturehumidity + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.R +import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun TemperatureHumidityRoute( + onBackClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: TemperatureHumidityViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + TemperatureHumidityScreen( + state = state, + onEvent = viewModel::onEvent, + onBackClick = onBackClick, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun TemperatureHumidityScreen( + state: TemperatureHumidityUiState, + onEvent: (TemperatureHumidityUiEvent) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val scrollState = rememberScrollState() + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { + Text( + text = state.deviceName?.value ?: stringResource(R.string.th_title), + style = MaterialTheme.typography.titleLarge, + ) + }, + navigationIcon = { + IconButton(onClick = onBackClick) { + Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + }, + actions = { + IconButton( + onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) }, + enabled = !state.isLoading && !state.isRefreshing, + ) { + Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + } + }, + ) + }, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 24.dp, vertical = 16.dp) + .verticalScroll(scrollState), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (state.isOffline) { + OfflineBanner( + onRetry = { onEvent(TemperatureHumidityUiEvent.Refresh) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + ) + } + + state.errorMessage?.let { errorText -> + ErrorBanner( + message = errorText.asString(), + onDismiss = { onEvent(TemperatureHumidityUiEvent.ClearError) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + ) + } + + if (state.isLoading && !state.hasValidData) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ABoxLoadingIndicator(modifier = Modifier.size(40.dp)) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.status_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else if (state.hasNoData) { + NoDataCard( + onRefresh = { onEvent(TemperatureHumidityUiEvent.Refresh) }, + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp), + ) + } else { + // Telemetry Cards Row / Column + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + TelemetryMetricCard( + title = stringResource(R.string.th_temperature_label), + value = state.temperature ?: "--", + unit = stringResource(R.string.th_unit_celsius), + icon = "🌡️", + modifier = Modifier.weight(1f), + ) + + TelemetryMetricCard( + title = stringResource(R.string.th_humidity_label), + value = state.humidity ?: "--", + unit = stringResource(R.string.th_unit_percent), + icon = "💧", + modifier = Modifier.weight(1f), + ) + } + + state.lastUpdatedTimeMillis?.let { timeMillis -> + val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis)) + Text( + text = stringResource(R.string.th_last_updated, formattedTime), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Read-Only Informational Card + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ), + shape = MaterialTheme.shapes.medium, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = stringResource(R.string.th_read_only_notice), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.th_read_only_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f), + modifier = Modifier.padding(top = 4.dp), + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + + // Refresh Button + ABoxButton( + onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) }, + enabled = !state.isLoading && !state.isRefreshing && state.profileId != null, + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + if (state.isRefreshing || state.isLoading) { + ABoxLoadingIndicator(modifier = Modifier.size(22.dp)) + Spacer(modifier = Modifier.width(10.dp)) + Text(stringResource(R.string.status_loading)) + } else { + Text( + text = stringResource(R.string.action_refresh), + style = MaterialTheme.typography.titleMedium, + ) + } + } + } + } +} + +@Composable +private fun TelemetryMetricCard( + title: String, + value: String, + unit: String, + icon: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = icon, + style = MaterialTheme.typography.headlineMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.Center, + ) { + Text( + text = value, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (value != "--") { + Text( + text = " $unit", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + } + } + } +} + +@Composable +private fun NoDataCard( + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + shape = MaterialTheme.shapes.large, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text("📭", style = MaterialTheme.typography.displaySmall) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(R.string.th_no_data_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = stringResource(R.string.th_no_data_description), + style = MaterialTheme.typography.bodySmall, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 6.dp, bottom = 16.dp), + ) + TextButton(onClick = onRefresh) { + Text(stringResource(R.string.action_refresh)) + } + } + } +} + +@Composable +private fun OfflineBanner( + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource(R.string.status_device_offline_banner), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.action_retry)) + } + } + } +} + +@Composable +private fun ErrorBanner( + message: String, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.85f), + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ), + shape = MaterialTheme.shapes.medium, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f), + ) + TextButton(onClick = onDismiss) { + Text("✕") + } + } + } +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt new file mode 100644 index 0000000..a3dd0e8 --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt @@ -0,0 +1,33 @@ +package com.flagship.abox.manager.ui.temperaturehumidity + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.ui.common.UiText + +data class TemperatureHumidityUiState( + val profileId: ProviderProfileId? = null, + val deviceName: DeviceName? = null, + val temperature: String? = null, + val humidity: String? = null, + val isLoading: Boolean = false, + val isRefreshing: Boolean = false, + val isOffline: Boolean = false, + val hasNoData: Boolean = false, + val errorMessage: UiText? = null, + val lastUpdatedTimeMillis: Long? = null, +) { + val formattedTemperature: String + get() = if (temperature.isNullOrBlank()) "--" else "$temperature °C" + + val formattedHumidity: String + get() = if (humidity.isNullOrBlank()) "--" else "$humidity %" + + val hasValidData: Boolean + get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank() +} + +sealed interface TemperatureHumidityUiEvent { + data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : TemperatureHumidityUiEvent + data object Refresh : TemperatureHumidityUiEvent + data object ClearError : TemperatureHumidityUiEvent +} diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt new file mode 100644 index 0000000..3d4e90b --- /dev/null +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt @@ -0,0 +1,110 @@ +package com.flagship.abox.manager.ui.temperaturehumidity + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.TemperatureHumidityGateway +import com.flagship.abox.manager.ui.common.DomainErrorMapper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class TemperatureHumidityViewModel @Inject constructor( + private val thGateway: TemperatureHumidityGateway, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val _state = MutableStateFlow(TemperatureHumidityUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + val profileIdString: String? = savedStateHandle["profileId"] + val deviceNameString: String? = savedStateHandle["deviceName"] + + if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) { + onEvent( + TemperatureHumidityUiEvent.Initialize( + profileId = ProviderProfileId(profileIdString), + deviceName = DeviceName(deviceNameString), + ), + ) + } + } + + fun onEvent(event: TemperatureHumidityUiEvent) { + when (event) { + is TemperatureHumidityUiEvent.Initialize -> { + _state.update { + it.copy( + profileId = event.profileId, + deviceName = event.deviceName, + ) + } + loadReading(isRefresh = false) + } + + TemperatureHumidityUiEvent.Refresh -> { + loadReading(isRefresh = true) + } + + TemperatureHumidityUiEvent.ClearError -> { + _state.update { it.copy(errorMessage = null) } + } + } + } + + private fun loadReading(isRefresh: Boolean) { + val profileId = _state.value.profileId ?: return + val deviceName = _state.value.deviceName ?: return + + viewModelScope.launch { + if (isRefresh) { + _state.update { it.copy(isRefreshing = true, errorMessage = null) } + } else { + _state.update { it.copy(isLoading = true, errorMessage = null) } + } + + when (val result = thGateway.getReading(profileId, deviceName)) { + is DomainResult.Success -> { + val reading = result.value + val isBlankData = reading.temperature.isBlank() && reading.humidity.isBlank() + _state.update { + it.copy( + temperature = reading.temperature.takeIf { t -> t.isNotBlank() }, + humidity = reading.humidity.takeIf { h -> h.isNotBlank() }, + isLoading = false, + isRefreshing = false, + isOffline = false, + hasNoData = isBlankData, + errorMessage = null, + lastUpdatedTimeMillis = System.currentTimeMillis(), + ) + } + } + + is DomainResult.Failure -> { + val isOffline = result.error is DomainError.DeviceOffline + val hasNoData = result.error is DomainError.NoData + _state.update { + it.copy( + isLoading = false, + isRefreshing = false, + isOffline = isOffline, + hasNoData = hasNoData, + errorMessage = DomainErrorMapper.map(result.error), + ) + } + } + } + } + } +} diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index f09dfa4..13419f7 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -52,4 +52,44 @@ 进入温湿度状态相关功能。 红外控制器 进入红外控制器相关功能。 + + 智能插座 + 电源状态 + 已开启 + 已关闭 + 已离线 + 未知状态 + 切换电源 + 开启插座 + 关闭插座 + 正在切换状态… + 已于 %1$s 确认状态 + + + 温湿度传感器 + 温度 + 湿度 + °C + % + 传感器设备(只读监控) + 本设备提供实时环境温湿度遥测,不支持远程控制操作。 + 暂无传感器数据 + 尚未获取到该传感器的遥测数据,请点击刷新重新查询。 + 更新于 %1$s + + + 刷新 + 重试 + 返回 + 加载中… + 设备已离线,无法进行通信。 + 认证失败,请重新登录。 + 登录已过期,请重新登录。 + 网络连接异常,请检查网络设置。 + 未找到该设备。 + 设备当前处于离线状态。 + 请求参数无效。 + 操作执行失败,请重试。 + 暂无可用数据。 + 发生未知错误。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index f9455e4..adce3ad 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -52,4 +52,44 @@ View temperature and humidity status. Infrared Controller Access infrared controller features. + + Smart Socket + Power Status + ON + OFF + Offline + Unknown + Toggle Power + Turn ON + Turn OFF + Updating socket state… + Confirmed at %1$s + + + Temperature & Humidity + Temperature + Humidity + °C + % + Sensor Device (Read-only) + This sensor provides real-time environmental telemetry and does not support remote control actions. + No Sensor Data + No telemetry data has been received from this sensor. Tap Refresh to query again. + Updated at %1$s + + + Refresh + Retry + Back + Loading… + Device is offline. Commands cannot be sent. + Authentication failed. Please log in again. + Session expired. Please log in again. + Network error. Please check your connection. + Device not found. + Device is currently offline. + Invalid request parameters. + Operation failed. Please try again. + No sensor data available. + An unexpected error occurred. diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt new file mode 100644 index 0000000..d325d9f --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt @@ -0,0 +1,183 @@ +package com.flagship.abox.manager.ui.socket + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.SocketPowerState +import com.flagship.abox.manager.testing.fakes.FakeSocketGateway +import com.flagship.abox.manager.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SocketViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher()) + + private lateinit var fakeSocketGateway: FakeSocketGateway + private val profileId = ProviderProfileId("test-profile") + private val deviceName = DeviceName("test-socket") + + @Before + fun setUp() { + fakeSocketGateway = FakeSocketGateway() + } + + @Test + fun initialization_fromSavedStateHandle_loadsStatusSuccessfully() = runTest { + fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON) + + val savedStateHandle = SavedStateHandle( + mapOf( + "profileId" to profileId.value, + "deviceName" to deviceName.value, + ), + ) + + val viewModel = SocketViewModel(fakeSocketGateway, savedStateHandle) + val state = viewModel.state.value + + assertEquals(profileId, state.profileId) + assertEquals(deviceName, state.deviceName) + assertEquals(SocketPowerState.ON, state.powerState) + assertTrue(state.isPowerOn) + assertFalse(state.isLoading) + assertFalse(state.isOffline) + assertNull(state.errorMessage) + assertNotNull(state.lastConfirmedTimeMillis) + } + + @Test + fun initialization_viaEvent_loadsStatusSuccessfully() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + assertNull(viewModel.state.value.profileId) + + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + val state = viewModel.state.value + + assertEquals(profileId, state.profileId) + assertEquals(deviceName, state.deviceName) + assertEquals(SocketPowerState.OFF, state.powerState) + assertFalse(state.isPowerOn) + assertFalse(state.isLoading) + } + + @Test + fun loadStatus_whenDeviceOffline_setsOfflineState() = runTest { + fakeSocketGateway.setError(profileId, deviceName, DomainError.DeviceOffline()) + + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + + val state = viewModel.state.value + assertTrue(state.isOffline) + assertFalse(state.isLoading) + assertNotNull(state.errorMessage) + } + + @Test + fun loadStatus_whenNetworkFails_setsErrorMessage() = runTest { + fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Timeout")) + + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + + val state = viewModel.state.value + assertFalse(state.isOffline) + assertFalse(state.isLoading) + assertNotNull(state.errorMessage) + } + + @Test + fun togglePower_fromOffToOn_updatesState() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState) + + viewModel.onEvent(SocketUiEvent.TogglePower) + + val state = viewModel.state.value + assertEquals(SocketPowerState.ON, state.powerState) + assertTrue(state.isPowerOn) + assertFalse(state.isOperating) + assertNull(state.errorMessage) + } + + @Test + fun togglePower_fromOnToOff_updatesState() = runTest { + fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON) + + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + assertEquals(SocketPowerState.ON, viewModel.state.value.powerState) + + viewModel.onEvent(SocketUiEvent.TogglePower) + + val state = viewModel.state.value + assertEquals(SocketPowerState.OFF, state.powerState) + assertFalse(state.isPowerOn) + assertFalse(state.isOperating) + } + + @Test + fun setPower_explicitState_updatesStateCorrectly() = 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) + + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) + assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState) + } + + @Test + fun setPower_whenGatewayFails_setsErrorMessageAndPreservesState() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState) + + fakeSocketGateway.setError(profileId, deviceName, DomainError.OperationFailed(detail = "Command failed")) + viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) + + val state = viewModel.state.value + assertEquals(SocketPowerState.OFF, state.powerState) + assertFalse(state.isOperating) + assertNotNull(state.errorMessage) + } + + @Test + fun refresh_queriesUpdatedGatewayStatus() = runTest { + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState) + + // Modify gateway backend directly + fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON) + + viewModel.onEvent(SocketUiEvent.Refresh) + assertEquals(SocketPowerState.ON, viewModel.state.value.powerState) + } + + @Test + fun clearError_resetsErrorMessage() = runTest { + fakeSocketGateway.setError(profileId, deviceName, DomainError.InvalidRequest()) + val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle()) + viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName)) + assertNotNull(viewModel.state.value.errorMessage) + + viewModel.onEvent(SocketUiEvent.ClearError) + assertNull(viewModel.state.value.errorMessage) + } +} diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt new file mode 100644 index 0000000..dcb5c63 --- /dev/null +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt @@ -0,0 +1,171 @@ +package com.flagship.abox.manager.ui.temperaturehumidity + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.TemperatureHumidityReading +import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway +import com.flagship.abox.manager.testing.MainDispatcherRule +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class TemperatureHumidityViewModelTest { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher()) + + private lateinit var fakeGateway: FakeTemperatureHumidityGateway + private val profileId = ProviderProfileId("test-profile") + private val deviceName = DeviceName("test-sensor") + + @Before + fun setUp() { + fakeGateway = FakeTemperatureHumidityGateway() + } + + @Test + fun initialization_fromSavedStateHandle_loadsReadingSuccessfully() = runTest { + fakeGateway.setReading( + TemperatureHumidityReading( + profileId = profileId, + deviceName = deviceName, + temperature = "26.5", + humidity = "58", + ), + ) + + val savedStateHandle = SavedStateHandle( + mapOf( + "profileId" to profileId.value, + "deviceName" to deviceName.value, + ), + ) + + val viewModel = TemperatureHumidityViewModel(fakeGateway, savedStateHandle) + val state = viewModel.state.value + + assertEquals(profileId, state.profileId) + assertEquals(deviceName, state.deviceName) + assertEquals("26.5", state.temperature) + assertEquals("58", state.humidity) + assertEquals("26.5 °C", state.formattedTemperature) + assertEquals("58 %", state.formattedHumidity) + assertTrue(state.hasValidData) + assertFalse(state.isLoading) + assertFalse(state.isOffline) + assertFalse(state.hasNoData) + assertNull(state.errorMessage) + assertNotNull(state.lastUpdatedTimeMillis) + } + + @Test + fun initialization_viaEvent_loadsReadingSuccessfully() = runTest { + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + assertNull(viewModel.state.value.profileId) + + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + val state = viewModel.state.value + + assertEquals(profileId, state.profileId) + assertEquals(deviceName, state.deviceName) + assertEquals("23.5", state.temperature) + assertEquals("45", state.humidity) + assertFalse(state.isLoading) + assertTrue(state.hasValidData) + } + + @Test + fun loadReading_whenDeviceOffline_setsOfflineState() = runTest { + fakeGateway.setError(profileId, deviceName, DomainError.DeviceOffline()) + + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + + val state = viewModel.state.value + assertTrue(state.isOffline) + assertFalse(state.isLoading) + assertNotNull(state.errorMessage) + } + + @Test + fun loadReading_whenNoDataError_setsNoDataState() = runTest { + fakeGateway.setError(profileId, deviceName, DomainError.NoData()) + + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + + val state = viewModel.state.value + assertTrue(state.hasNoData) + assertFalse(state.isLoading) + assertNotNull(state.errorMessage) + } + + @Test + fun loadReading_whenBlankValuesReturned_handlesAsNoData() = runTest { + fakeGateway.setReading( + TemperatureHumidityReading( + profileId = profileId, + deviceName = deviceName, + temperature = "", + humidity = "", + ), + ) + + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + + val state = viewModel.state.value + assertTrue(state.hasNoData) + assertNull(state.temperature) + assertNull(state.humidity) + assertEquals("--", state.formattedTemperature) + assertEquals("--", state.formattedHumidity) + assertFalse(state.hasValidData) + } + + @Test + fun refresh_queriesUpdatedReading() = runTest { + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + assertEquals("23.5", viewModel.state.value.temperature) + + fakeGateway.setReading( + TemperatureHumidityReading( + profileId = profileId, + deviceName = deviceName, + temperature = "30.2", + humidity = "65", + ), + ) + + viewModel.onEvent(TemperatureHumidityUiEvent.Refresh) + val state = viewModel.state.value + assertEquals("30.2", state.temperature) + assertEquals("65", state.humidity) + assertEquals("30.2 °C", state.formattedTemperature) + assertEquals("65 %", state.formattedHumidity) + assertFalse(state.isRefreshing) + } + + @Test + fun clearError_resetsErrorMessage() = runTest { + fakeGateway.setError(profileId, deviceName, DomainError.Network()) + val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle()) + viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName)) + assertNotNull(viewModel.state.value.errorMessage) + + viewModel.onEvent(TemperatureHumidityUiEvent.ClearError) + assertNull(viewModel.state.value.errorMessage) + } +}