feat: add socket control and temperature humidity modules #7
@@ -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(
|
||||||
|
|||||||
+7
-2
@@ -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 =
|
||||||
|
|
|||||||
"device/socket/${profileId.value}/${deviceName.value}"
|
"device/socket/${encode(profileId.value)}/${encode(deviceName.value)}"
|
||||||
|
|
||||||
|
pr-agent-bot
commented
参数损坏 路由使用 **参数损坏**
路由使用 `URLEncoder` 编码,而 Navigation 已会解码路径参数,两个 ViewModel 随后又通过 `URLDecoder` 解码一次。设备名或配置 ID 包含字面量 `+`、`%2F` 等内容时会被二次转换,例如 `A+B` 会变成 `A B`,导致查询错误设备或找不到设备。应改用适合 URI 路径的编码方式,并移除 ViewModel 中的二次解码。
<!-- pr-agent-dedup: c724c8573d3b -->
<!-- pr-agent-key-issue-location: a2b312c1af6a -->
pr-agent-bot
commented
参数损坏 Navigation 在写入 **参数损坏**
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()
|
||||||
}
|
}
|
||||||
|
|||||||
+49
@@ -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
|
||||||
|
|||||||
+1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -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>
|
||||||
|
|||||||
+14
@@ -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())
|
||||||
|
|||||||
+22
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user
路由编码
profileId和deviceName被原样拼接到路由路径中。设备名称或配置 ID 包含/、?、#等字符时,会被解析成额外路径段、查询参数或片段,导致目标页面无法匹配或收到截断参数。请对每个路径参数进行 URI 编码,或改用类型安全导航参数。路由编码
profileId.value和deviceName.value被直接拼接到路由路径。设备名包含/、?、#或%时,这些字符会被解释为路由语法,导致目标无法匹配或SavedStateHandle收到错误参数;应对每个路径段进行 URI 编码,或改用类型安全路由。