diff --git a/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md b/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md new file mode 100644 index 0000000..942c7f7 --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md @@ -0,0 +1,589 @@ +# 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 +```