Files
abox-mgr/docs/superpowers/plans/2026-08-24-infrared-remote-ui.md
T

24 KiB
Raw Blame History

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

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
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
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

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
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
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

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
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
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