fix(infrared): propagate learning error result and localize remote screen strings

This commit is contained in:
2026-08-25 12:20:29 +08:00
parent a3bcdd973c
commit 8682f81cb8
6 changed files with 99 additions and 24 deletions
@@ -1,13 +1,15 @@
package com.flagship.abox.manager.infrared.application package com.flagship.abox.manager.infrared.application
import com.flagship.abox.manager.domain.DeviceName 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.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Deferred
import kotlinx.coroutines.launch import kotlinx.coroutines.async
class InfraredLearningCoordinator( class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey, private val learnInfraredKey: LearnInfraredKey,
@@ -19,11 +21,11 @@ class InfraredLearningCoordinator(
deviceName: DeviceName, deviceName: DeviceName,
displayName: String, displayName: String,
sdkKey: String? = null, sdkKey: String? = null,
): Job = externalScope.launch { ): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
learnInfraredKey(profileId, deviceName, displayName, sdkKey) learnInfraredKey(profileId, deviceName, displayName, sdkKey)
} }
fun retry(ref: InfraredKeyRef): Job = externalScope.launch { fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
retryInfraredKeyLearning(ref) retryInfraredKeyLearning(ref)
} }
} }
@@ -171,27 +171,43 @@ class InfraredRemoteViewModel @Inject constructor(
if (activeLearningSlotFlow.value != null) { if (activeLearningSlotFlow.value != null) {
return return
} }
activeLearningSlotFlow.value = spec.defaultSlot
val existingKey = state.value.learnedSlotMap[spec.defaultSlot] val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
val job = if (existingKey != null) { if (existingKey?.status == InfraredKeyStatus.LEARNING) {
learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) return
} else {
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
} }
activeLearningJob = job activeLearningSlotFlow.value = spec.defaultSlot
job.invokeOnCompletion { val job = viewModelScope.launch {
if (activeLearningJob === job) { try {
activeLearningJob = null 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) { if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null activeLearningSlotFlow.value = null
} }
} }
} }
activeLearningJob = job
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else { } else {
if (sendingKeySlotFlow.value != null) { if (sendingKeySlotFlow.value != null) {
@@ -37,12 +37,14 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface 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.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
@@ -108,13 +110,13 @@ internal fun InfraredRemoteScreen(
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = "红外遥控器", text = stringResource(R.string.infrared_remote_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
) )
}, },
navigationIcon = { navigationIcon = {
TextButton(onClick = onBack) { TextButton(onClick = onBack) {
Text("返回") Text(stringResource(R.string.infrared_back))
} }
}, },
actions = { actions = {
@@ -124,7 +126,11 @@ internal fun InfraredRemoteScreen(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
Text( 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, fontSize = 12.sp,
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal, fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
@@ -178,7 +184,7 @@ internal fun InfraredRemoteScreen(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text( Text(
text = "🔴 红外学习模式已开启:点击任意按键即可录制信号", text = stringResource(R.string.infrared_remote_learning_banner),
style = MaterialTheme.typography.bodyMedium.copy( style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onErrorContainer, color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
@@ -196,7 +202,7 @@ internal fun InfraredRemoteScreen(
FilterChip( FilterChip(
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER, selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) }, onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
label = { Text("❄️ 空调遥控") }, label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
modifier = Modifier modifier = Modifier
.padding(end = 8.dp) .padding(end = 8.dp)
.testTag("scene_tab_ac"), .testTag("scene_tab_ac"),
@@ -208,7 +214,7 @@ internal fun InfraredRemoteScreen(
FilterChip( FilterChip(
selected = state.selectedScene == RemoteScene.TELEVISION, selected = state.selectedScene == RemoteScene.TELEVISION,
onClick = { onSceneSelected(RemoteScene.TELEVISION) }, onClick = { onSceneSelected(RemoteScene.TELEVISION) },
label = { Text("📺 电视遥控") }, label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
modifier = Modifier.testTag("scene_tab_tv"), modifier = Modifier.testTag("scene_tab_tv"),
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
@@ -32,4 +32,11 @@
<string name="infrared_dialog_cancel">取消</string> <string name="infrared_dialog_cancel">取消</string>
<string name="infrared_dialog_confirm">确认</string> <string name="infrared_dialog_confirm">确认</string>
<string name="infrared_back">返回</string> <string name="infrared_back">返回</string>
<string name="infrared_remote_title">红外遥控器</string>
<string name="infrared_remote_recording">录制中</string>
<string name="infrared_remote_learning_mode">学习模式</string>
<string name="infrared_remote_learning_banner">🔴 红外学习模式已开启:点击任意按键即可录制信号</string>
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
<string name="infrared_remote_scene_tv">📺 电视遥控</string>
<string name="infrared_remote_settings">设置</string>
</resources> </resources>
@@ -32,4 +32,11 @@
<string name="infrared_dialog_cancel">Cancel</string> <string name="infrared_dialog_cancel">Cancel</string>
<string name="infrared_dialog_confirm">Confirm</string> <string name="infrared_dialog_confirm">Confirm</string>
<string name="infrared_back">Back</string> <string name="infrared_back">Back</string>
<string name="infrared_remote_title">Infrared Remote</string>
<string name="infrared_remote_recording">Recording</string>
<string name="infrared_remote_learning_mode">Learn Mode</string>
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
<string name="infrared_remote_scene_tv">📺 Television</string>
<string name="infrared_remote_settings">Settings</string>
</resources> </resources>
@@ -213,6 +213,43 @@ class InfraredRemoteViewModelTest {
assertEquals("1", gateway.learnedKeys.first().key) assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status) 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() job.cancel()
} }