diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 25ef11a..468b9e3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -1,13 +1,15 @@ package com.flagship.abox.manager.infrared.application 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.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async class InfraredLearningCoordinator( private val learnInfraredKey: LearnInfraredKey, @@ -19,11 +21,11 @@ class InfraredLearningCoordinator( deviceName: DeviceName, displayName: String, sdkKey: String? = null, - ): Job = externalScope.launch { + ): Deferred> = externalScope.async { learnInfraredKey(profileId, deviceName, displayName, sdkKey) } - fun retry(ref: InfraredKeyRef): Job = externalScope.launch { + fun retry(ref: InfraredKeyRef): Deferred> = externalScope.async { retryInfraredKeyLearning(ref) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index b2b6439..56d7029 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -171,27 +171,43 @@ class InfraredRemoteViewModel @Inject constructor( if (activeLearningSlotFlow.value != null) { return } - activeLearningSlotFlow.value = spec.defaultSlot val existingKey = state.value.learnedSlotMap[spec.defaultSlot] - val job = if (existingKey != null) { - learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) - } else { - learningCoordinator.learn( - profileId = currentProfile, - deviceName = savedDevice, - displayName = spec.displayName, - sdkKey = spec.defaultSlot, - ) + if (existingKey?.status == InfraredKeyStatus.LEARNING) { + return } - activeLearningJob = job - job.invokeOnCompletion { - if (activeLearningJob === job) { - activeLearningJob = null + activeLearningSlotFlow.value = spec.defaultSlot + val job = viewModelScope.launch { + try { + val result = if (existingKey != null) { + learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)).await() + } else { + when (val res = learningCoordinator.learn( + profileId = currentProfile, + deviceName = savedDevice, + displayName = spec.displayName, + sdkKey = spec.defaultSlot, + ).await()) { + is DomainResult.Success -> DomainResult.Success(Unit) + is DomainResult.Failure -> DomainResult.Failure(res.error) + } + } + when (result) { + is DomainResult.Success -> { + messageFlow.value = RemoteUiMessage.Info("【${spec.displayName}】录制成功") + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } finally { if (activeLearningSlotFlow.value == spec.defaultSlot) { activeLearningSlotFlow.value = null } } } + activeLearningJob = job messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") } else { if (sendingKeySlotFlow.value != null) { diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index bd3e38e..ad7bde0 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -37,12 +37,14 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.infrared.R import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec @@ -108,13 +110,13 @@ internal fun InfraredRemoteScreen( TopAppBar( title = { Text( - text = "红外遥控器", + text = stringResource(R.string.infrared_remote_title), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), ) }, navigationIcon = { TextButton(onClick = onBack) { - Text("返回") + Text(stringResource(R.string.infrared_back)) } }, actions = { @@ -124,7 +126,11 @@ internal fun InfraredRemoteScreen( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = if (state.isLearningMode) "录制中" else "学习模式", + text = if (state.isLearningMode) { + stringResource(R.string.infrared_remote_recording) + } else { + stringResource(R.string.infrared_remote_learning_mode) + }, fontSize = 12.sp, fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal, color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, @@ -178,7 +184,7 @@ internal fun InfraredRemoteScreen( contentAlignment = Alignment.Center, ) { Text( - text = "🔴 红外学习模式已开启:点击任意按键即可录制信号", + text = stringResource(R.string.infrared_remote_learning_banner), style = MaterialTheme.typography.bodyMedium.copy( color = MaterialTheme.colorScheme.onErrorContainer, fontWeight = FontWeight.SemiBold, @@ -196,7 +202,7 @@ internal fun InfraredRemoteScreen( FilterChip( selected = state.selectedScene == RemoteScene.AIR_CONDITIONER, onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) }, - label = { Text("❄️ 空调遥控") }, + label = { Text(stringResource(R.string.infrared_remote_scene_ac)) }, modifier = Modifier .padding(end = 8.dp) .testTag("scene_tab_ac"), @@ -208,7 +214,7 @@ internal fun InfraredRemoteScreen( FilterChip( selected = state.selectedScene == RemoteScene.TELEVISION, onClick = { onSceneSelected(RemoteScene.TELEVISION) }, - label = { Text("📺 电视遥控") }, + label = { Text(stringResource(R.string.infrared_remote_scene_tv)) }, modifier = Modifier.testTag("scene_tab_tv"), colors = FilterChipDefaults.filterChipColors( selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index c166919..f1cf838 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -32,4 +32,11 @@ 取消 确认 返回 + 红外遥控器 + 录制中 + 学习模式 + 🔴 红外学习模式已开启:点击任意按键即可录制信号 + ❄️ 空调遥控 + 📺 电视遥控 + 设置 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 95304d2..e676a1f 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -32,4 +32,11 @@ Cancel Confirm Back + Infrared Remote + Recording + Learn Mode + 🔴 Learn mode active: tap any button to record + ❄️ Air Conditioner + 📺 Television + Settings diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index fc4e03d..fcbedff 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -213,6 +213,43 @@ class InfraredRemoteViewModelTest { assertEquals("1", gateway.learnedKeys.first().key) val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) + assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + + job.cancel() + } + + @Test + fun learningFailurePropagatesErrorMessage() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206")) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(RemoteUiMessage.Error("红外控制器离线 (错误码: 20206)"), vm.state.value.message) + + job.cancel() + } + + @Test + fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired")) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message) job.cancel() }