# 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` 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 = modeFlow override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { 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 = 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(null) private val activeLearningSlotFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(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 = 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)?.value val keys = (keysResult as? DomainResult.Success>)?.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 ```