Compare commits
1
Commits
4ea633a329
...
f2102096f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2102096f0 |
@@ -52,6 +52,7 @@ android {
|
|||||||
excludes += "/META-INF/DEPENDENCIES"
|
excludes += "/META-INF/DEPENDENCIES"
|
||||||
excludes += "/META-INF/LICENSE*"
|
excludes += "/META-INF/LICENSE*"
|
||||||
excludes += "/META-INF/NOTICE*"
|
excludes += "/META-INF/NOTICE*"
|
||||||
|
excludes += "/META-INF/INDEX.LIST"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,27 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import androidx.navigation.compose.NavHost
|
||||||
|
import androidx.navigation.compose.composable
|
||||||
|
import androidx.navigation.compose.rememberNavController
|
||||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
|
import com.flagship.abox.manager.ui.navigation.deviceDetailGraph
|
||||||
|
import com.flagship.abox.manager.ui.navigation.navigateToSocket
|
||||||
|
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
||||||
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||||
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
||||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||||
import com.flagship.abox.manager.ui.shell.MainAppShell
|
import com.flagship.abox.manager.ui.shell.MainAppShell
|
||||||
|
|
||||||
|
private const val AUTHENTICATED_HOME_ROUTE = "authenticated/home"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxManagerApp(
|
fun ABoxManagerApp(
|
||||||
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
||||||
@@ -57,13 +67,42 @@ fun ABoxManagerApp(
|
|||||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
viewModel = loginViewModel,
|
viewModel = loginViewModel,
|
||||||
)
|
)
|
||||||
is AppSessionUiState.Authenticated -> MainAppShell(
|
is AppSessionUiState.Authenticated -> {
|
||||||
|
val navController = rememberNavController()
|
||||||
|
NavHost(
|
||||||
|
navController = navController,
|
||||||
|
startDestination = AUTHENTICATED_HOME_ROUTE,
|
||||||
|
) {
|
||||||
|
composable(AUTHENTICATED_HOME_ROUTE) {
|
||||||
|
MainAppShell(
|
||||||
userName = state.username.value,
|
userName = state.username.value,
|
||||||
providerName = state.profile.displayName,
|
providerName = state.profile.displayName,
|
||||||
onLogout = sessionActionsViewModel::logout,
|
onLogout = sessionActionsViewModel::logout,
|
||||||
actionInProgress = actionState.inProgress,
|
actionInProgress = actionState.inProgress,
|
||||||
errorMessage = actionState.errorMessage,
|
errorMessage = actionState.errorMessage,
|
||||||
|
devicesSlot = {
|
||||||
|
DeviceListScreen(
|
||||||
|
onSocketClick = {
|
||||||
|
navController.navigateToSocket(
|
||||||
|
profileId = state.profile.id,
|
||||||
|
deviceName = DeviceName("智能插座"),
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onTemperatureHumidityClick = {
|
||||||
|
navController.navigateToTemperatureHumidity(
|
||||||
|
profileId = state.profile.id,
|
||||||
|
deviceName = DeviceName("温湿度传感器"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deviceDetailGraph(
|
||||||
|
onBackClick = navController::popBackStack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Any> = 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
|
import java.net.URLEncoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
|
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/${encode(profileId.value)}/${encode(deviceName.value)}"
|
||||||
|
|
||||||
|
fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
|
||||||
|
"device/th/${encode(profileId.value)}/${encode(deviceName.value)}"
|
||||||
|
|
||||||
|
private fun encode(value: String): String =
|
||||||
|
URLEncoder.encode(value, StandardCharsets.UTF_8.name())
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,385 @@
|
|||||||
|
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.AlertDialog
|
||||||
|
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.OutlinedTextField
|
||||||
|
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.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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.DeviceName
|
||||||
|
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()
|
||||||
|
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
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 = { showEditNameDialog = true },
|
||||||
|
enabled = !state.isLoading && !state.isOperating,
|
||||||
|
) {
|
||||||
|
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEditNameDialog) {
|
||||||
|
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showEditNameDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputName,
|
||||||
|
onValueChange = { inputName = it },
|
||||||
|
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val trimmed = inputName.trim()
|
||||||
|
if (trimmed.isNotBlank()) {
|
||||||
|
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
||||||
|
}
|
||||||
|
showEditNameDialog = false
|
||||||
|
},
|
||||||
|
enabled = inputName.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showEditNameDialog = false }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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("✕")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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 class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
||||||
|
data object Refresh : SocketUiEvent
|
||||||
|
data object TogglePower : SocketUiEvent
|
||||||
|
data class SetPower(val targetState: SocketPowerState) : SocketUiEvent
|
||||||
|
data object ClearError : SocketUiEvent
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
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 java.net.URLDecoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class SocketViewModel @Inject constructor(
|
||||||
|
private val socketGateway: SocketGateway,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(SocketUiState())
|
||||||
|
val state: StateFlow<SocketUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
|
||||||
|
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
|
||||||
|
|
||||||
|
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
|
||||||
|
onEvent(
|
||||||
|
SocketUiEvent.Initialize(
|
||||||
|
profileId = ProviderProfileId(profileIdString),
|
||||||
|
deviceName = DeviceName(deviceNameString),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode(value: String): String = try {
|
||||||
|
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: SocketUiEvent) {
|
||||||
|
when (event) {
|
||||||
|
is SocketUiEvent.Initialize -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
profileId = event.profileId,
|
||||||
|
deviceName = event.deviceName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
is SocketUiEvent.ChangeDeviceName -> {
|
||||||
|
val trimmed = event.deviceName.value.trim()
|
||||||
|
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
deviceName = DeviceName(trimmed),
|
||||||
|
powerState = null,
|
||||||
|
errorMessage = null,
|
||||||
|
lastConfirmedTimeMillis = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+442
@@ -0,0 +1,442 @@
|
|||||||
|
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.AlertDialog
|
||||||
|
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.OutlinedTextField
|
||||||
|
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.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
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 com.flagship.abox.manager.domain.DeviceName
|
||||||
|
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()
|
||||||
|
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
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 = { showEditNameDialog = true },
|
||||||
|
enabled = !state.isLoading && !state.isRefreshing,
|
||||||
|
) {
|
||||||
|
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showEditNameDialog) {
|
||||||
|
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showEditNameDialog = false },
|
||||||
|
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
||||||
|
text = {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = inputName,
|
||||||
|
onValueChange = { inputName = it },
|
||||||
|
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
val trimmed = inputName.trim()
|
||||||
|
if (trimmed.isNotBlank()) {
|
||||||
|
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
||||||
|
}
|
||||||
|
showEditNameDialog = false
|
||||||
|
},
|
||||||
|
enabled = inputName.isNotBlank(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.action_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showEditNameDialog = false }) {
|
||||||
|
Text(stringResource(R.string.action_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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("✕")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
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 class ChangeDeviceName(val deviceName: DeviceName) : TemperatureHumidityUiEvent
|
||||||
|
data object Refresh : TemperatureHumidityUiEvent
|
||||||
|
data object ClearError : TemperatureHumidityUiEvent
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
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 java.net.URLDecoder
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class TemperatureHumidityViewModel @Inject constructor(
|
||||||
|
private val thGateway: TemperatureHumidityGateway,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(TemperatureHumidityUiState())
|
||||||
|
val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
|
||||||
|
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
|
||||||
|
|
||||||
|
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
|
||||||
|
onEvent(
|
||||||
|
TemperatureHumidityUiEvent.Initialize(
|
||||||
|
profileId = ProviderProfileId(profileIdString),
|
||||||
|
deviceName = DeviceName(deviceNameString),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode(value: String): String = try {
|
||||||
|
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
|
||||||
|
} catch (_: Exception) {
|
||||||
|
value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: TemperatureHumidityUiEvent) {
|
||||||
|
when (event) {
|
||||||
|
is TemperatureHumidityUiEvent.Initialize -> {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
profileId = event.profileId,
|
||||||
|
deviceName = event.deviceName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadReading(isRefresh = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
|
||||||
|
val trimmed = event.deviceName.value.trim()
|
||||||
|
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
deviceName = DeviceName(trimmed),
|
||||||
|
temperature = null,
|
||||||
|
humidity = null,
|
||||||
|
hasNoData = false,
|
||||||
|
errorMessage = null,
|
||||||
|
lastUpdatedTimeMillis = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,4 +52,49 @@
|
|||||||
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
|
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
|
||||||
<string name="device_infrared_title">红外控制器</string>
|
<string name="device_infrared_title">红外控制器</string>
|
||||||
<string name="device_infrared_description">进入红外控制器相关功能。</string>
|
<string name="device_infrared_description">进入红外控制器相关功能。</string>
|
||||||
|
<!-- Socket strings -->
|
||||||
|
<string name="socket_title">智能插座</string>
|
||||||
|
<string name="socket_status_label">电源状态</string>
|
||||||
|
<string name="socket_status_on">已开启</string>
|
||||||
|
<string name="socket_status_off">已关闭</string>
|
||||||
|
<string name="socket_status_offline">已离线</string>
|
||||||
|
<string name="socket_status_unknown">未知状态</string>
|
||||||
|
<string name="socket_action_toggle">切换电源</string>
|
||||||
|
<string name="socket_action_turn_on">开启插座</string>
|
||||||
|
<string name="socket_action_turn_off">关闭插座</string>
|
||||||
|
<string name="socket_operating_label">正在切换状态…</string>
|
||||||
|
<string name="socket_confirmed_at">已于 %1$s 确认状态</string>
|
||||||
|
|
||||||
|
<!-- Temperature & Humidity strings -->
|
||||||
|
<string name="th_title">温湿度传感器</string>
|
||||||
|
<string name="th_temperature_label">温度</string>
|
||||||
|
<string name="th_humidity_label">湿度</string>
|
||||||
|
<string name="th_unit_celsius">°C</string>
|
||||||
|
<string name="th_unit_percent">%</string>
|
||||||
|
<string name="th_read_only_notice">传感器设备(只读监控)</string>
|
||||||
|
<string name="th_read_only_description">本设备提供实时环境温湿度遥测,不支持远程控制操作。</string>
|
||||||
|
<string name="th_no_data_title">暂无传感器数据</string>
|
||||||
|
<string name="th_no_data_description">尚未获取到该传感器的遥测数据,请点击刷新重新查询。</string>
|
||||||
|
<string name="th_last_updated">更新于 %1$s</string>
|
||||||
|
|
||||||
|
<!-- Common action & error strings -->
|
||||||
|
<string name="action_refresh">刷新</string>
|
||||||
|
<string name="action_retry">重试</string>
|
||||||
|
<string name="action_back">返回</string>
|
||||||
|
<string name="action_edit_device_name">修改设备名称</string>
|
||||||
|
<string name="dialog_edit_device_name_title">修改设备名称</string>
|
||||||
|
<string name="dialog_edit_device_name_hint">请输入网关中的设备名称</string>
|
||||||
|
<string name="action_confirm">确定</string>
|
||||||
|
<string name="action_cancel">取消</string>
|
||||||
|
<string name="status_loading">加载中…</string>
|
||||||
|
<string name="status_device_offline_banner">设备已离线,无法进行通信。</string>
|
||||||
|
<string name="error_authentication">认证失败,请重新登录。</string>
|
||||||
|
<string name="error_token_invalid">登录已过期,请重新登录。</string>
|
||||||
|
<string name="error_network">网络连接异常,请检查网络设置。</string>
|
||||||
|
<string name="error_device_not_found">未找到该设备。</string>
|
||||||
|
<string name="error_device_offline">设备当前处于离线状态。</string>
|
||||||
|
<string name="error_invalid_request">请求参数无效。</string>
|
||||||
|
<string name="error_operation_failed">操作执行失败,请重试。</string>
|
||||||
|
<string name="error_no_data">暂无可用数据。</string>
|
||||||
|
<string name="error_unknown">发生未知错误。</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -52,4 +52,49 @@
|
|||||||
<string name="device_temperature_humidity_description">View temperature and humidity status.</string>
|
<string name="device_temperature_humidity_description">View temperature and humidity status.</string>
|
||||||
<string name="device_infrared_title">Infrared Controller</string>
|
<string name="device_infrared_title">Infrared Controller</string>
|
||||||
<string name="device_infrared_description">Access infrared controller features.</string>
|
<string name="device_infrared_description">Access infrared controller features.</string>
|
||||||
|
<!-- Socket strings -->
|
||||||
|
<string name="socket_title">Smart Socket</string>
|
||||||
|
<string name="socket_status_label">Power Status</string>
|
||||||
|
<string name="socket_status_on">ON</string>
|
||||||
|
<string name="socket_status_off">OFF</string>
|
||||||
|
<string name="socket_status_offline">Offline</string>
|
||||||
|
<string name="socket_status_unknown">Unknown</string>
|
||||||
|
<string name="socket_action_toggle">Toggle Power</string>
|
||||||
|
<string name="socket_action_turn_on">Turn ON</string>
|
||||||
|
<string name="socket_action_turn_off">Turn OFF</string>
|
||||||
|
<string name="socket_operating_label">Updating socket state…</string>
|
||||||
|
<string name="socket_confirmed_at">Confirmed at %1$s</string>
|
||||||
|
|
||||||
|
<!-- Temperature & Humidity strings -->
|
||||||
|
<string name="th_title">Temperature & Humidity</string>
|
||||||
|
<string name="th_temperature_label">Temperature</string>
|
||||||
|
<string name="th_humidity_label">Humidity</string>
|
||||||
|
<string name="th_unit_celsius">°C</string>
|
||||||
|
<string name="th_unit_percent">%</string>
|
||||||
|
<string name="th_read_only_notice">Sensor Device (Read-only)</string>
|
||||||
|
<string name="th_read_only_description">This sensor provides real-time environmental telemetry and does not support remote control actions.</string>
|
||||||
|
<string name="th_no_data_title">No Sensor Data</string>
|
||||||
|
<string name="th_no_data_description">No telemetry data has been received from this sensor. Tap Refresh to query again.</string>
|
||||||
|
<string name="th_last_updated">Updated at %1$s</string>
|
||||||
|
|
||||||
|
<!-- Common action & error strings -->
|
||||||
|
<string name="action_refresh">Refresh</string>
|
||||||
|
<string name="action_retry">Retry</string>
|
||||||
|
<string name="action_back">Back</string>
|
||||||
|
<string name="action_edit_device_name">Edit Device Name</string>
|
||||||
|
<string name="dialog_edit_device_name_title">Edit Device Name</string>
|
||||||
|
<string name="dialog_edit_device_name_hint">Enter gateway device name</string>
|
||||||
|
<string name="action_confirm">Confirm</string>
|
||||||
|
<string name="action_cancel">Cancel</string>
|
||||||
|
<string name="status_loading">Loading…</string>
|
||||||
|
<string name="status_device_offline_banner">Device is offline. Commands cannot be sent.</string>
|
||||||
|
<string name="error_authentication">Authentication failed. Please log in again.</string>
|
||||||
|
<string name="error_token_invalid">Session expired. Please log in again.</string>
|
||||||
|
<string name="error_network">Network error. Please check your connection.</string>
|
||||||
|
<string name="error_device_not_found">Device not found.</string>
|
||||||
|
<string name="error_device_offline">Device is currently offline.</string>
|
||||||
|
<string name="error_invalid_request">Invalid request parameters.</string>
|
||||||
|
<string name="error_operation_failed">Operation failed. Please try again.</string>
|
||||||
|
<string name="error_no_data">No sensor data available.</string>
|
||||||
|
<string name="error_unknown">An unexpected error occurred.</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+197
@@ -0,0 +1,197 @@
|
|||||||
|
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 changeDeviceName_updatesNameAndReloads() = runTest {
|
||||||
|
val newDevice = DeviceName("socket-2")
|
||||||
|
fakeSocketGateway.setPower(profileId, newDevice, SocketPowerState.ON)
|
||||||
|
|
||||||
|
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||||
|
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||||
|
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||||
|
|
||||||
|
viewModel.onEvent(SocketUiEvent.ChangeDeviceName(newDevice))
|
||||||
|
assertEquals(newDevice, viewModel.state.value.deviceName)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+193
@@ -0,0 +1,193 @@
|
|||||||
|
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 changeDeviceName_updatesNameAndReloads() = runTest {
|
||||||
|
val newDevice = DeviceName("th-2")
|
||||||
|
fakeGateway.setReading(
|
||||||
|
TemperatureHumidityReading(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = newDevice,
|
||||||
|
temperature = "26.8",
|
||||||
|
humidity = "55",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||||
|
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||||
|
assertEquals("23.5", viewModel.state.value.temperature)
|
||||||
|
|
||||||
|
viewModel.onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(newDevice))
|
||||||
|
assertEquals(newDevice, viewModel.state.value.deviceName)
|
||||||
|
assertEquals("26.8", viewModel.state.value.temperature)
|
||||||
|
assertEquals("55", viewModel.state.value.humidity)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user