feat: add socket control and temperature humidity modules (#7)

## Summary
- **Smart Socket Module**:
  - Implemented `SocketUiState` and `SocketViewModel` with support for querying power state, toggle control, duplicate click debouncing, and state confirmation timestamping.
  - Implemented Material 3 responsive `SocketScreen` with power indicator badge, toggle action, explicit on/off buttons, offline banner, and error handling.
- **Temperature & Humidity Module**:
  - Implemented `TemperatureHumidityUiState` and `TemperatureHumidityViewModel` for read-only environmental telemetry.
  - Implemented `TemperatureHumidityScreen` featuring distinct temperature and humidity cards, read-only notice, no-data placeholder, and pull-to-refresh actions.
- **Common & Infrastructure**:
  - Added `UiText` and `DomainErrorMapper` for localizing domain errors (`DomainError`).
  - Added navigation graph extensions (`deviceDetailGraph`) in `DeviceDetailNavigation.kt` and wired into `ABoxManagerApp.kt`.
  - Added `ReleaseModule` for Hilt dependency injection in release builds.
  - Added full English and Simplified Chinese resource translations in `values/strings.xml` and `values-zh-rCN/strings.xml`.
- **Testing**:
  - Added `SocketViewModelTest` and `TemperatureHumidityViewModelTest` covering normal operations, offline states, error mapping, and refresh scenarios.

## Test Plan
- [x] Run `./gradlew test` (all modules passed)
- [x] Run `./gradlew :android:app:testDebugUnitTest` (passed)
- [x] Run `./gradlew assembleDebug` (build successful)

?? Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: KremeCN <3265494849@qq.com>
Reviewed-on: #7
Co-authored-by: Aurum <aurum_197@outlook.com>
This commit was merged in pull request #7.
This commit is contained in:
2026-08-25 15:41:34 +08:00
committed by KremeCN
co-authored by KremeCN
parent 0c783ca2f4
commit 30d6d39597
16 changed files with 1823 additions and 0 deletions
@@ -0,0 +1,197 @@
package com.flagship.abox.manager.ui.socket
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
import com.flagship.abox.manager.testing.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SocketViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
private lateinit var fakeSocketGateway: FakeSocketGateway
private val profileId = ProviderProfileId("test-profile")
private val deviceName = DeviceName("test-socket")
@Before
fun setUp() {
fakeSocketGateway = FakeSocketGateway()
}
@Test
fun initialization_fromSavedStateHandle_loadsStatusSuccessfully() = runTest {
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
val savedStateHandle = SavedStateHandle(
mapOf(
"profileId" to profileId.value,
"deviceName" to deviceName.value,
),
)
val viewModel = SocketViewModel(fakeSocketGateway, savedStateHandle)
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals(SocketPowerState.ON, state.powerState)
assertTrue(state.isPowerOn)
assertFalse(state.isLoading)
assertFalse(state.isOffline)
assertNull(state.errorMessage)
assertNotNull(state.lastConfirmedTimeMillis)
}
@Test
fun initialization_viaEvent_loadsStatusSuccessfully() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
assertNull(viewModel.state.value.profileId)
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals(SocketPowerState.OFF, state.powerState)
assertFalse(state.isPowerOn)
assertFalse(state.isLoading)
}
@Test
fun loadStatus_whenDeviceOffline_setsOfflineState() = runTest {
fakeSocketGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.isOffline)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun loadStatus_whenNetworkFails_setsErrorMessage() = runTest {
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Timeout"))
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertFalse(state.isOffline)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun togglePower_fromOffToOn_updatesState() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.TogglePower)
val state = viewModel.state.value
assertEquals(SocketPowerState.ON, state.powerState)
assertTrue(state.isPowerOn)
assertFalse(state.isOperating)
assertNull(state.errorMessage)
}
@Test
fun togglePower_fromOnToOff_updatesState() = runTest {
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.TogglePower)
val state = viewModel.state.value
assertEquals(SocketPowerState.OFF, state.powerState)
assertFalse(state.isPowerOn)
assertFalse(state.isOperating)
}
@Test
fun setPower_explicitState_updatesStateCorrectly() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
}
@Test
fun setPower_whenGatewayFails_setsErrorMessageAndPreservesState() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
fakeSocketGateway.setError(profileId, deviceName, DomainError.OperationFailed(detail = "Command failed"))
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
val state = viewModel.state.value
assertEquals(SocketPowerState.OFF, state.powerState)
assertFalse(state.isOperating)
assertNotNull(state.errorMessage)
}
@Test
fun refresh_queriesUpdatedGatewayStatus() = runTest {
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
// Modify gateway backend directly
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
viewModel.onEvent(SocketUiEvent.Refresh)
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())
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
assertNotNull(viewModel.state.value.errorMessage)
viewModel.onEvent(SocketUiEvent.ClearError)
assertNull(viewModel.state.value.errorMessage)
}
}
@@ -0,0 +1,193 @@
package com.flagship.abox.manager.ui.temperaturehumidity
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.TemperatureHumidityReading
import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway
import com.flagship.abox.manager.testing.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class TemperatureHumidityViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
private lateinit var fakeGateway: FakeTemperatureHumidityGateway
private val profileId = ProviderProfileId("test-profile")
private val deviceName = DeviceName("test-sensor")
@Before
fun setUp() {
fakeGateway = FakeTemperatureHumidityGateway()
}
@Test
fun initialization_fromSavedStateHandle_loadsReadingSuccessfully() = runTest {
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "26.5",
humidity = "58",
),
)
val savedStateHandle = SavedStateHandle(
mapOf(
"profileId" to profileId.value,
"deviceName" to deviceName.value,
),
)
val viewModel = TemperatureHumidityViewModel(fakeGateway, savedStateHandle)
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals("26.5", state.temperature)
assertEquals("58", state.humidity)
assertEquals("26.5 °C", state.formattedTemperature)
assertEquals("58 %", state.formattedHumidity)
assertTrue(state.hasValidData)
assertFalse(state.isLoading)
assertFalse(state.isOffline)
assertFalse(state.hasNoData)
assertNull(state.errorMessage)
assertNotNull(state.lastUpdatedTimeMillis)
}
@Test
fun initialization_viaEvent_loadsReadingSuccessfully() = runTest {
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
assertNull(viewModel.state.value.profileId)
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertEquals(profileId, state.profileId)
assertEquals(deviceName, state.deviceName)
assertEquals("23.5", state.temperature)
assertEquals("45", state.humidity)
assertFalse(state.isLoading)
assertTrue(state.hasValidData)
}
@Test
fun loadReading_whenDeviceOffline_setsOfflineState() = runTest {
fakeGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.isOffline)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun loadReading_whenNoDataError_setsNoDataState() = runTest {
fakeGateway.setError(profileId, deviceName, DomainError.NoData())
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.hasNoData)
assertFalse(state.isLoading)
assertNotNull(state.errorMessage)
}
@Test
fun loadReading_whenBlankValuesReturned_handlesAsNoData() = runTest {
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "",
humidity = "",
),
)
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
val state = viewModel.state.value
assertTrue(state.hasNoData)
assertNull(state.temperature)
assertNull(state.humidity)
assertEquals("--", state.formattedTemperature)
assertEquals("--", state.formattedHumidity)
assertFalse(state.hasValidData)
}
@Test
fun refresh_queriesUpdatedReading() = runTest {
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
assertEquals("23.5", viewModel.state.value.temperature)
fakeGateway.setReading(
TemperatureHumidityReading(
profileId = profileId,
deviceName = deviceName,
temperature = "30.2",
humidity = "65",
),
)
viewModel.onEvent(TemperatureHumidityUiEvent.Refresh)
val state = viewModel.state.value
assertEquals("30.2", state.temperature)
assertEquals("65", state.humidity)
assertEquals("30.2 °C", state.formattedTemperature)
assertEquals("65 %", state.formattedHumidity)
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())
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
assertNotNull(viewModel.state.value.errorMessage)
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
assertNull(viewModel.state.value.errorMessage)
}
}