diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 9a2dc11..330cd1d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -11,17 +11,27 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute 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.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel import com.flagship.abox.manager.ui.shell.MainAppShell +private const val AUTHENTICATED_HOME_ROUTE = "authenticated/home" + @Composable fun ABoxManagerApp( appSessionViewModel: AppSessionViewModel = hiltViewModel(), @@ -57,13 +67,42 @@ fun ABoxManagerApp( onSwitchProvider = sessionActionsViewModel::switchProvider, viewModel = loginViewModel, ) - is AppSessionUiState.Authenticated -> MainAppShell( - userName = state.username.value, - providerName = state.profile.displayName, - onLogout = sessionActionsViewModel::logout, - actionInProgress = actionState.inProgress, - errorMessage = actionState.errorMessage, - ) + is AppSessionUiState.Authenticated -> { + val navController = rememberNavController() + NavHost( + navController = navController, + startDestination = AUTHENTICATED_HOME_ROUTE, + ) { + composable(AUTHENTICATED_HOME_ROUTE) { + MainAppShell( + userName = state.username.value, + providerName = state.profile.displayName, + onLogout = sessionActionsViewModel::logout, + actionInProgress = actionState.inProgress, + errorMessage = actionState.errorMessage, + devicesSlot = { + DeviceListScreen( + onSocketClick = { + navController.navigateToSocket( + profileId = state.profile.id, + deviceName = DeviceName("Zigbee插座控制器"), + ) + }, + onTemperatureHumidityClick = { + navController.navigateToTemperatureHumidity( + profileId = state.profile.id, + deviceName = DeviceName("温湿度传感器"), + ) + }, + ) + }, + ) + } + deviceDetailGraph( + onBackClick = navController::popBackStack, + ) + } + } } } } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt index 3b6e60a..9d5a094 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/navigation/DeviceDetailNavigation.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt index 47f79e1..62db0e1 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketScreen.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt index 13f0ef5..2c47d33 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketUiState.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt index 02b5e57..15364cc 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModel.kt @@ -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 = _state.asStateFlow() init { - val profileIdString: String? = savedStateHandle["profileId"] - val deviceNameString: String? = savedStateHandle["deviceName"] + val profileIdString: String? = savedStateHandle.get("profileId")?.let { decode(it) } + val deviceNameString: String? = savedStateHandle.get("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() } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt index f692de8..3cf5571 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityScreen.kt @@ -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 diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt index a3dd0e8..678a497 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityUiState.kt @@ -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 } diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt index 3d4e90b..916fc80 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModel.kt @@ -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 = _state.asStateFlow() init { - val profileIdString: String? = savedStateHandle["profileId"] - val deviceNameString: String? = savedStateHandle["deviceName"] + val profileIdString: String? = savedStateHandle.get("profileId")?.let { decode(it) } + val deviceNameString: String? = savedStateHandle.get("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) } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 13419f7..ed502c3 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -81,6 +81,11 @@ 刷新 重试 返回 + 修改设备名称 + 修改设备名称 + 请输入网关中的设备名称 + 确定 + 取消 加载中… 设备已离线,无法进行通信。 认证失败,请重新登录。 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index adce3ad..9314d6e 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -81,6 +81,11 @@ Refresh Retry Back + Edit Device Name + Edit Device Name + Enter gateway device name + Confirm + Cancel Loading… Device is offline. Commands cannot be sent. Authentication failed. Please log in again. diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt index d325d9f..9c43ba8 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/socket/SocketViewModelTest.kt @@ -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()) diff --git a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt index dcb5c63..b5c2512 100644 --- a/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt +++ b/android/app/src/test/kotlin/com/flagship/abox/manager/ui/temperaturehumidity/TemperatureHumidityViewModelTest.kt @@ -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())