### 概述 / 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>
590 lines
24 KiB
Markdown
590 lines
24 KiB
Markdown
# 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
|
||
```
|