Files
abox-mgr/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md
T
MolerandKremeCN 0c783ca2f4 feat(infrared): add infrared control, learning, and virtual remote module (#9)
### 概述 / Summary
完整实现红外控制与学习模块(`:android:feature:infrared`),支持红外设备按键学习、实体发射、Room 本地持久化,以及空调 (AC) / 电视 (TV) 虚拟遥控器面板。

### 核心变更 / Key Changes
1. **领域模型与用例 (`:domain`)**:
   - 实现按键学习流程:`LearnInfraredKey`、`ManageInfraredKey`、`InfraredKeyFactory`;
   - 增加虚拟遥控器模型与用例:`CreateVirtualRemote`、`LearnVirtualRemoteKey`、`SendVirtualRemoteAction`、`AcCodeGenerator` 与预置码库 `PresetCodeLibrary`。
2. **数据持久化与仓储 (`:android:feature:infrared`)**:
   - 引入 Room 数据库 `InfraredDatabase` 及 DAO,提供 `RoomInfraredKeyRepository` 与 `RoomVirtualRemoteRepository`;
   - 支持多配置隔离(`ProviderProfileId`)、按键状态管理(`LEARNING` / `READY` / `FAILED`)。
3. **UI 与 ViewModel (`:android:feature:infrared`)**:
   - `InfraredControlScreen`:红外学习与按键调试列表;
   - `RemotePanelScreen` / `AcRemotePanel` / `TvRemotePanel`:空调/电视专属可视化遥控面板;
   - `InfraredHubScreen`:红外中枢与多遥控器管理入口;
   - 在认证后导航栈中将 `DeviceListScreen` 的红外入口无缝对接到 `InfraredHubRoute`。
4. **测试与文档**:
   - 补齐用例、Fakes、Repository、ViewModel 以及 Compose UI 自动化测试;
   - 补充红外控制设计规范与真机硬件验收清单。

Reviewed-on: #9
Co-authored-by: mxhsnd <2049295946@qq.com>
Co-authored-by: KremeCN <3265494849@qq.com>
2026-08-25 15:00:06 +08:00

590 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Infrared Remote Control UI & Multi-Scene Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a realistic, multi-scene infrared remote control UI (Air Conditioner & Television) with learning/control mode toggle, fixed slot key bindings (1-100), graphic component modeling (circles, capsules, D-pad wheels), and settings navigation.
**Architecture:** Add scene models and predefined key specs in `:android:feature:infrared`. Implement `InfraredRemoteViewModel` to observe Room keys and coordinator actions. Build Compose panels for AC and TV remotes, and integrate the navigation route `infrared_remote/{profileId}` alongside `infrared_settings/{profileId}`.
**Tech Stack:** Kotlin 1.9.24, Jetpack Compose Material 3, Android Gradle Plugin 8.7.3, Room, StateFlow, Hilt, Coroutines.
**Spec:** `docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md`
## Global Constraints
- Remote buttons display clean symbols/icons, never raw Chinese long names on the button faces.
- Every button maps to a dedicated 1-100 integer slot (AC: 1–20, TV: 21–50).
- Learning mode toggle activates visual indicator and switches button clicks from `sendKey` to `learnKey`.
- Settings navigation allows jumping to the existing `InfraredControlScreen` (renamed to Settings) to view raw entries and device names.
- Follow TDD with unit and Compose UI tests.
---
### Task 1: Define Remote Scenes and Key Specs
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt`
**Interfaces:**
- Produces: `RemoteScene`, `RemoteButtonId`, `RemoteButtonSpec`, and `RemoteSceneConfig`.
- [ ] **Step 1: Write test for predefined AC and TV remote button specifications**
```kotlin
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 21..50 })
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `RemoteModels.kt`**
```kotlin
package com.flagship.abox.manager.infrared.remote
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU
)
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt
git commit -m "feat: define infrared remote scenes and button specifications"
```
---
### Task 2: Implement Infrared Remote ViewModel
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteUiState.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt`
**Interfaces:**
- Consumes: `InfraredKeyRepository`, `InfraredDeviceConfigurationRepository`, `InfraredLearningCoordinator`, `SendInfraredKey`, `InfraredRuntimeModeStore`.
- Produces: `StateFlow<InfraredRemoteUiState>` with `onButtonClick`, `toggleLearningMode`, `switchScene`.
- [ ] **Step 1: Write failing ViewModel test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
private class TestModeStore : InfraredRuntimeModeStore {
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
override val mode: Flow<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
modeFlow.value = mode
return DomainResult.Success(Unit)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("id-1") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
job.cancel()
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `InfraredRemoteUiState` and `InfraredRemoteViewModel`**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode = runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(emptyList()))
else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Success -> {
val device = deviceResult.value
if (device == null) flowOf(DomainResult.Success(emptyList()))
else keyRepository.observeKeys(profile, device)
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { scene, isLearning, profileAndMode, deviceResult, keysResult, sendingSlot, learningSlot, msg ->
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (enabled) {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (state.value.isLearningMode) {
activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按物理遥控器按键...")
} else {
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.symbol}")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
}
else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
}
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: implement infrared remote control viewmodel"
```
---
### Task 3: Build Custom Remote Visual Components
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt`
- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt`
- [ ] **Step 1: Implement Compose visual elements**
- `RemoteCircleButton`, `VerticalCapsuleRocker`, `RemoteDpadWheel`, `AcLcdDisplay`.
- Compose panels for `AcRemotePanel` and `TvRemotePanel`.
- Assemble `InfraredRemoteScreen` with top learning switch and settings action.
- [ ] **Step 2: Write Compose UI Test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredRemoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersAcPanelAndTvPanelCorrectly() {
val state = InfraredRemoteUiState(
selectedScene = RemoteScene.AIR_CONDITIONER,
savedDeviceName = DeviceName("ir-1"),
)
var clickedSpec: RemoteButtonSpec? = null
composeTestRule.setContent {
InfraredRemoteScreen(
state = state,
onBack = {},
onOpenSettings = {},
onSessionInvalid = {},
onSceneSelected = {},
onToggleLearningMode = {},
onButtonClick = { clickedSpec = it },
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id)
}
}
```
- [ ] **Step 3: Run compilation and tests**
Run: `./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:feature:infrared:testDebugUnitTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: add infrared remote control Compose screen and panels"
```
---
### Task 4: Integrate Navigation and App Flow
**Files:**
- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt`
- Modify: `android/app/src/main/res/values/strings.xml`
- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml`
- [ ] **Step 1: Wire Navigation**
- Route `infrared/{profileId}` now loads `InfraredRemoteRoute`.
- Add Route `infrared_settings/{profileId}` that loads `InfraredControlRoute` (the settings & raw list view).
- Top bar of `InfraredRemoteScreen` has ⚙️ button navigating to `infrared_settings/{profileId}`.
- [ ] **Step 2: Run Full Verification Suite & Build APK**
Run: `./gradlew :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:assembleDebug`
Expected: BUILD SUCCESSFUL.
- [ ] **Step 3: Commit and Push**
```bash
git add android/app android/feature/infrared
git commit -m "feat: wire infrared remote screen and settings navigation"
git push origin feat/moler-infrared-control
```