feat: add socket control and temperature humidity modules #7

Merged
KremeCN merged 2 commits from feat/socket-and-temperature-control into main 2026-08-25 15:41:34 +08:00
16 changed files with 1823 additions and 0 deletions
+1
View File
@@ -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"
} }
} }
} }
@@ -19,6 +19,7 @@ import androidx.navigation.navArgument
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.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
@@ -27,6 +28,9 @@ import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen 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
@@ -103,6 +107,18 @@ private fun AuthenticatedNavHost(
devicesSlot = { devicesSlot = {
val encodedProfileId = android.net.Uri.encode(profile.id.value) val encodedProfileId = android.net.Uri.encode(profile.id.value)
DeviceListScreen( DeviceListScreen(
onSocketClick = {
navController.navigateToSocket(
profileId = profile.id,
deviceName = DeviceName("Zigbee插座控制器"),
)
},
onTemperatureHumidityClick = {
navController.navigateToTemperatureHumidity(
profileId = profile.id,
deviceName = DeviceName("温湿度传感器"),
)
},
onInfraredClick = { onInfraredClick = {
navController.navigate("infrared_hub/$encodedProfileId") navController.navigate("infrared_hub/$encodedProfileId")
}, },
@@ -110,6 +126,9 @@ private fun AuthenticatedNavHost(
}, },
) )
} }
deviceDetailGraph(
onBackClick = navController::popBackStack,
)
composable( composable(
route = "infrared_hub/{profileId}", route = "infrared_hub/{profileId}",
arguments = listOf( arguments = listOf(
@@ -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())
}
}
}
@@ -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),
)
}
}
}
}
}
}
@@ -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("✕")
}
}
}
}
@@ -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
}
@@ -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),
)
}
}
}
}
}
}
@@ -53,4 +53,49 @@
<string name="device_infrared_description">进入红外控制器相关功能。</string> <string name="device_infrared_description">进入红外控制器相关功能。</string>
<string name="infrared_control_action">红外控制</string> <string name="infrared_control_action">红外控制</string>
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string> <string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</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>
@@ -53,4 +53,49 @@
<string name="device_infrared_description">Access infrared controller features.</string> <string name="device_infrared_description">Access infrared controller features.</string>
<string name="infrared_control_action">Infrared Control</string> <string name="infrared_control_action">Infrared Control</string>
<string name="infrared_session_invalid_message">ABox session is not authenticated. Please log in first.</string> <string name="infrared_session_invalid_message">ABox session is not authenticated. Please log in first.</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 &amp; 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>
@@ -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)
}
}
@@ -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)
}
}
@@ -75,6 +75,7 @@
<string name="infrared_key_tv_ch_down">电视-频道-</string> <string name="infrared_key_tv_ch_down">电视-频道-</string>
<string name="infrared_key_tv_home">电视-主页</string> <string name="infrared_key_tv_home">电视-主页</string>
<string name="infrared_key_tv_back">电视-返回</string> <string name="infrared_key_tv_back">电视-返回</string>
<string name="infrared_key_tv_menu">电视-菜单</string>
<string name="infrared_control_title">红外设备与环境配置</string> <string name="infrared_control_title">红外设备与环境配置</string>
<string name="infrared_device_config_section">ABSDK 设备名称配置</string> <string name="infrared_device_config_section">ABSDK 设备名称配置</string>
<string name="infrared_device_config_desc">ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string> <string name="infrared_device_config_desc">ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string>