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
13 changed files with 225 additions and 6 deletions
Showing only changes of commit dc45e6affc - Show all commits
@@ -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(
@@ -9,16 +9,21 @@ 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 =
Outdated
Review

路由编码

profileId 和 deviceName 被原样拼接到路由路径中。设备名称或配置 ID 包含 /、?、# 等字符时,会被解析成额外路径段、查询参数或片段,导致目标页面无法匹配或收到截断参数。请对每个路径参数进行 URI 编码,或改用类型安全导航参数。

**路由编码** `profileId` 和 `deviceName` 被原样拼接到路由路径中。设备名称或配置 ID 包含 `/`、`?`、`#` 等字符时,会被解析成额外路径段、查询参数或片段,导致目标页面无法匹配或收到截断参数。请对每个路径参数进行 URI 编码,或改用类型安全导航参数。 <!-- pr-agent-dedup: b313370a059d --> <!-- pr-agent-key-issue-location: 48955917f6ca -->
Outdated
Review

路由编码

profileId.value 和 deviceName.value 被直接拼接到路由路径。设备名包含 /、?、# 或 % 时,这些字符会被解释为路由语法,导致目标无法匹配或 SavedStateHandle 收到错误参数;应对每个路径段进行 URI 编码,或改用类型安全路由。

**路由编码** `profileId.value` 和 `deviceName.value` 被直接拼接到路由路径。设备名包含 `/`、`?`、`#` 或 `%` 时,这些字符会被解释为路由语法,导致目标无法匹配或 `SavedStateHandle` 收到错误参数;应对每个路径段进行 URI 编码,或改用类型安全路由。 <!-- pr-agent-dedup: 415e3644f613 --> <!-- pr-agent-key-issue-location: 5bcef409df98 -->
"device/socket/${profileId.value}/${deviceName.value}" "device/socket/${encode(profileId.value)}/${encode(deviceName.value)}"
Outdated
Review

参数损坏

路由使用 URLEncoder 编码,而 Navigation 已会解码路径参数,两个 ViewModel 随后又通过 URLDecoder 解码一次。设备名或配置 ID 包含字面量 +、%2F 等内容时会被二次转换,例如 A+B 会变成 A B,导致查询错误设备或找不到设备。应改用适合 URI 路径的编码方式,并移除 ViewModel 中的二次解码。

**参数损坏** 路由使用 `URLEncoder` 编码,而 Navigation 已会解码路径参数,两个 ViewModel 随后又通过 `URLDecoder` 解码一次。设备名或配置 ID 包含字面量 `+`、`%2F` 等内容时会被二次转换,例如 `A+B` 会变成 `A B`,导致查询错误设备或找不到设备。应改用适合 URI 路径的编码方式,并移除 ViewModel 中的二次解码。 <!-- pr-agent-dedup: c724c8573d3b --> <!-- pr-agent-key-issue-location: a2b312c1af6a -->
Outdated
Review

参数损坏

Navigation 在写入 SavedStateHandle 前已经解码路径参数,而两个 ViewModel 又使用 URLDecoder 再次解码。设备名或配置 ID 包含字面量 +、%2F 等内容时会被转换成空格或斜杠,导致网关查询错误的设备。应使用适合路径的编码方式,并确保参数只解码一次。

**参数损坏** Navigation 在写入 `SavedStateHandle` 前已经解码路径参数,而两个 ViewModel 又使用 `URLDecoder` 再次解码。设备名或配置 ID 包含字面量 `+`、`%2F` 等内容时会被转换成空格或斜杠,导致网关查询错误的设备。应使用适合路径的编码方式,并确保参数只解码一次。 <!-- pr-agent-dedup: 95fc3b6d0547 --> <!-- pr-agent-key-issue-location: 1d90e03d542c -->
fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String = fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
"device/th/${profileId.value}/${deviceName.value}" "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) { fun NavController.navigateToSocket(profileId: ProviderProfileId, deviceName: DeviceName) {
@@ -16,6 +16,7 @@ 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
@@ -23,12 +24,16 @@ 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
@@ -43,6 +48,7 @@ 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
@@ -73,6 +79,7 @@ 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(),
@@ -90,6 +97,12 @@ 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,
@@ -234,6 +247,42 @@ 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,6 +24,7 @@ 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,6 +16,8 @@ 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
@@ -28,8 +30,8 @@ class SocketViewModel @Inject constructor(
val state: StateFlow<SocketUiState> = _state.asStateFlow() val state: StateFlow<SocketUiState> = _state.asStateFlow()
init { init {
val profileIdString: String? = savedStateHandle["profileId"] val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
val deviceNameString: String? = savedStateHandle["deviceName"] val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) { if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent( onEvent(
@@ -41,6 +43,12 @@ 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 -> {
@@ -53,6 +61,21 @@ 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,17 +13,22 @@ 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
@@ -35,6 +40,7 @@ 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
@@ -64,6 +70,7 @@ 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(),
@@ -81,6 +88,12 @@ 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,
@@ -225,6 +238,42 @@ 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,6 +28,7 @@ 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,6 +15,8 @@ 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
@@ -27,8 +29,8 @@ class TemperatureHumidityViewModel @Inject constructor(
val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow() val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow()
init { init {
val profileIdString: String? = savedStateHandle["profileId"] val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
val deviceNameString: String? = savedStateHandle["deviceName"] val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) { if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
onEvent( onEvent(
@@ -40,6 +42,12 @@ 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 -> {
@@ -52,6 +60,23 @@ 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)
} }
@@ -82,6 +82,11 @@
<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>
@@ -82,6 +82,11 @@
<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,6 +170,20 @@ 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,6 +158,28 @@ 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())
@@ -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>