Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2102096f0 |
@@ -85,13 +85,13 @@ fun ABoxManagerApp(
|
||||
onSocketClick = {
|
||||
navController.navigateToSocket(
|
||||
profileId = state.profile.id,
|
||||
deviceName = DeviceName("智能插座 1"),
|
||||
deviceName = DeviceName("智能插座"),
|
||||
)
|
||||
},
|
||||
onTemperatureHumidityClick = {
|
||||
navController.navigateToTemperatureHumidity(
|
||||
profileId = state.profile.id,
|
||||
deviceName = DeviceName("温湿度传感器 1"),
|
||||
deviceName = DeviceName("温湿度传感器"),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
+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.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/${profileId.value}/${deviceName.value}"
|
||||
"device/socket/${encode(profileId.value)}/${encode(deviceName.value)}"
|
||||
|
||||
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) {
|
||||
|
||||
@@ -16,6 +16,7 @@ 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
|
||||
@@ -23,12 +24,16 @@ 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
|
||||
@@ -43,6 +48,7 @@ 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
|
||||
@@ -73,6 +79,7 @@ fun SocketScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
@@ -90,6 +97,12 @@ fun SocketScreen(
|
||||
}
|
||||
},
|
||||
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,
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,7 @@ data class SocketUiState(
|
||||
|
||||
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
|
||||
|
||||
@@ -16,6 +16,8 @@ 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
|
||||
@@ -28,8 +30,8 @@ class SocketViewModel @Inject constructor(
|
||||
val state: StateFlow<SocketUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
val profileIdString: String? = savedStateHandle["profileId"]
|
||||
val deviceNameString: String? = savedStateHandle["deviceName"]
|
||||
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(
|
||||
@@ -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) {
|
||||
when (event) {
|
||||
is SocketUiEvent.Initialize -> {
|
||||
@@ -53,6 +61,21 @@ class SocketViewModel @Inject constructor(
|
||||
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()
|
||||
}
|
||||
|
||||
+49
@@ -13,17 +13,22 @@ 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
|
||||
@@ -35,6 +40,7 @@ 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
|
||||
@@ -64,6 +70,7 @@ fun TemperatureHumidityScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
@@ -81,6 +88,12 @@ fun TemperatureHumidityScreen(
|
||||
}
|
||||
},
|
||||
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,
|
||||
@@ -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
|
||||
|
||||
+1
@@ -28,6 +28,7 @@ data class TemperatureHumidityUiState(
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+27
-2
@@ -15,6 +15,8 @@ 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
|
||||
@@ -27,8 +29,8 @@ class TemperatureHumidityViewModel @Inject constructor(
|
||||
val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
val profileIdString: String? = savedStateHandle["profileId"]
|
||||
val deviceNameString: String? = savedStateHandle["deviceName"]
|
||||
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(
|
||||
@@ -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) {
|
||||
when (event) {
|
||||
is TemperatureHumidityUiEvent.Initialize -> {
|
||||
@@ -52,6 +60,23 @@ class TemperatureHumidityViewModel @Inject constructor(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@
|
||||
<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>
|
||||
|
||||
@@ -81,6 +81,11 @@
|
||||
<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>
|
||||
|
||||
+14
@@ -170,6 +170,20 @@ class SocketViewModelTest {
|
||||
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())
|
||||
|
||||
+22
@@ -158,6 +158,28 @@ class TemperatureHumidityViewModelTest {
|
||||
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())
|
||||
|
||||
Reference in New Issue
Block a user