Compare commits

..
1 Commits
Author SHA1 Message Date
AurumandClaude Sonnet 4.6 4ea633a329 feat: add socket control and temperature humidity modules
Implement ViewModel state management, Compose UI screens, navigation routing,
localized strings, and comprehensive unit tests for smart socket control and
temperature/humidity telemetry monitoring.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-08-25 12:00:33 +08:00
12 changed files with 8 additions and 207 deletions
@@ -85,13 +85,13 @@ fun ABoxManagerApp(
onSocketClick = { onSocketClick = {
navController.navigateToSocket( navController.navigateToSocket(
profileId = state.profile.id, profileId = state.profile.id,
deviceName = DeviceName("智能插座"), deviceName = DeviceName("智能插座 1"),
) )
}, },
onTemperatureHumidityClick = { onTemperatureHumidityClick = {
navController.navigateToTemperatureHumidity( navController.navigateToTemperatureHumidity(
profileId = state.profile.id, profileId = state.profile.id,
deviceName = DeviceName("温湿度传感器"), deviceName = DeviceName("温湿度传感器 1"),
) )
}, },
) )
@@ -9,21 +9,16 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.ui.socket.SocketRoute import com.flagship.abox.manager.ui.socket.SocketRoute
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityRoute import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityRoute
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object DeviceDetailRoutes { object DeviceDetailRoutes {
const val SOCKET_PATTERN = "device/socket/{profileId}/{deviceName}" const val SOCKET_PATTERN = "device/socket/{profileId}/{deviceName}"
const val TH_PATTERN = "device/th/{profileId}/{deviceName}" const val TH_PATTERN = "device/th/{profileId}/{deviceName}"
fun socketRoute(profileId: ProviderProfileId, deviceName: DeviceName): String = fun socketRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
"device/socket/${encode(profileId.value)}/${encode(deviceName.value)}" "device/socket/${profileId.value}/${deviceName.value}"
fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String = fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
"device/th/${encode(profileId.value)}/${encode(deviceName.value)}" "device/th/${profileId.value}/${deviceName.value}"
private fun encode(value: String): String =
URLEncoder.encode(value, StandardCharsets.UTF_8.name())
} }
fun NavController.navigateToSocket(profileId: ProviderProfileId, deviceName: DeviceName) { fun NavController.navigateToSocket(profileId: ProviderProfileId, deviceName: DeviceName) {
@@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@@ -24,16 +23,12 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -48,7 +43,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.SocketPowerState import com.flagship.abox.manager.domain.SocketPowerState
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -79,7 +73,6 @@ fun SocketScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var showEditNameDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@@ -97,12 +90,6 @@ fun SocketScreen(
} }
}, },
actions = { actions = {
IconButton(
onClick = { showEditNameDialog = true },
enabled = !state.isLoading && !state.isOperating,
) {
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
IconButton( IconButton(
onClick = { onEvent(SocketUiEvent.Refresh) }, onClick = { onEvent(SocketUiEvent.Refresh) },
enabled = !state.isLoading && !state.isOperating, enabled = !state.isLoading && !state.isOperating,
@@ -247,42 +234,6 @@ fun SocketScreen(
} }
} }
} }
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 @Composable
@@ -24,7 +24,6 @@ data class SocketUiState(
sealed interface SocketUiEvent { sealed interface SocketUiEvent {
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
data object Refresh : SocketUiEvent data object Refresh : SocketUiEvent
data object TogglePower : SocketUiEvent data object TogglePower : SocketUiEvent
data class SetPower(val targetState: SocketPowerState) : SocketUiEvent data class SetPower(val targetState: SocketPowerState) : SocketUiEvent
@@ -16,8 +16,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -30,8 +28,8 @@ class SocketViewModel @Inject constructor(
val state: StateFlow<SocketUiState> = _state.asStateFlow() val state: StateFlow<SocketUiState> = _state.asStateFlow()
init { init {
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) } val profileIdString: String? = savedStateHandle["profileId"]
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) } val deviceNameString: String? = savedStateHandle["deviceName"]
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) { if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent( onEvent(
@@ -43,12 +41,6 @@ class SocketViewModel @Inject constructor(
} }
} }
private fun decode(value: String): String = try {
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
} catch (_: Exception) {
value
}
fun onEvent(event: SocketUiEvent) { fun onEvent(event: SocketUiEvent) {
when (event) { when (event) {
is SocketUiEvent.Initialize -> { is SocketUiEvent.Initialize -> {
@@ -61,21 +53,6 @@ class SocketViewModel @Inject constructor(
loadStatus() 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 -> { SocketUiEvent.Refresh -> {
loadStatus() loadStatus()
} }
@@ -13,22 +13,17 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -40,7 +35,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.domain.DeviceName
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@@ -70,7 +64,6 @@ fun TemperatureHumidityScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val scrollState = rememberScrollState() val scrollState = rememberScrollState()
var showEditNameDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
@@ -88,12 +81,6 @@ fun TemperatureHumidityScreen(
} }
}, },
actions = { actions = {
IconButton(
onClick = { showEditNameDialog = true },
enabled = !state.isLoading && !state.isRefreshing,
) {
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
IconButton( IconButton(
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) }, onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
enabled = !state.isLoading && !state.isRefreshing, enabled = !state.isLoading && !state.isRefreshing,
@@ -238,42 +225,6 @@ fun TemperatureHumidityScreen(
} }
} }
} }
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 @Composable
@@ -28,7 +28,6 @@ data class TemperatureHumidityUiState(
sealed interface TemperatureHumidityUiEvent { sealed interface TemperatureHumidityUiEvent {
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : TemperatureHumidityUiEvent data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : TemperatureHumidityUiEvent
data class ChangeDeviceName(val deviceName: DeviceName) : TemperatureHumidityUiEvent
data object Refresh : TemperatureHumidityUiEvent data object Refresh : TemperatureHumidityUiEvent
data object ClearError : TemperatureHumidityUiEvent data object ClearError : TemperatureHumidityUiEvent
} }
@@ -15,8 +15,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -29,8 +27,8 @@ class TemperatureHumidityViewModel @Inject constructor(
val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow() val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow()
init { init {
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) } val profileIdString: String? = savedStateHandle["profileId"]
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) } val deviceNameString: String? = savedStateHandle["deviceName"]
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) { if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent( onEvent(
@@ -42,12 +40,6 @@ class TemperatureHumidityViewModel @Inject constructor(
} }
} }
private fun decode(value: String): String = try {
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
} catch (_: Exception) {
value
}
fun onEvent(event: TemperatureHumidityUiEvent) { fun onEvent(event: TemperatureHumidityUiEvent) {
when (event) { when (event) {
is TemperatureHumidityUiEvent.Initialize -> { is TemperatureHumidityUiEvent.Initialize -> {
@@ -60,23 +52,6 @@ class TemperatureHumidityViewModel @Inject constructor(
loadReading(isRefresh = false) 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 -> { TemperatureHumidityUiEvent.Refresh -> {
loadReading(isRefresh = true) loadReading(isRefresh = true)
} }
@@ -81,11 +81,6 @@
<string name="action_refresh">刷新</string> <string name="action_refresh">刷新</string>
<string name="action_retry">重试</string> <string name="action_retry">重试</string>
<string name="action_back">返回</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_loading">加载中…</string>
<string name="status_device_offline_banner">设备已离线,无法进行通信。</string> <string name="status_device_offline_banner">设备已离线,无法进行通信。</string>
<string name="error_authentication">认证失败,请重新登录。</string> <string name="error_authentication">认证失败,请重新登录。</string>
@@ -81,11 +81,6 @@
<string name="action_refresh">Refresh</string> <string name="action_refresh">Refresh</string>
<string name="action_retry">Retry</string> <string name="action_retry">Retry</string>
<string name="action_back">Back</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_loading">Loading…</string>
<string name="status_device_offline_banner">Device is offline. Commands cannot be sent.</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_authentication">Authentication failed. Please log in again.</string>
@@ -170,20 +170,6 @@ class SocketViewModelTest {
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState) 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 @Test
fun clearError_resetsErrorMessage() = runTest { fun clearError_resetsErrorMessage() = runTest {
fakeSocketGateway.setError(profileId, deviceName, DomainError.InvalidRequest()) fakeSocketGateway.setError(profileId, deviceName, DomainError.InvalidRequest())
@@ -158,28 +158,6 @@ class TemperatureHumidityViewModelTest {
assertFalse(state.isRefreshing) 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 @Test
fun clearError_resetsErrorMessage() = runTest { fun clearError_resetsErrorMessage() = runTest {
fakeGateway.setError(profileId, deviceName, DomainError.Network()) fakeGateway.setError(profileId, deviceName, DomainError.Network())