From 714fe699f676ce56ea2b4011343247918188d524 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 12:05:02 +0800 Subject: [PATCH] fix(infrared): clear learning state on completion and guard against duplicate key sending --- .../InfraredLearningCoordinator.kt | 13 ++--- .../remote/InfraredRemoteViewModel.kt | 17 ++++++- .../infrared/remote/ui/RemoteComponents.kt | 34 +++++++++++--- .../remote/InfraredRemoteViewModelTest.kt | 47 +++++++++++++++++++ 4 files changed, 95 insertions(+), 16 deletions(-) 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 a191321..25ef11a 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 @@ -6,6 +6,7 @@ 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 class InfraredLearningCoordinator( @@ -18,15 +19,11 @@ class InfraredLearningCoordinator( deviceName: DeviceName, displayName: String, sdkKey: String? = null, - ) { - externalScope.launch { - learnInfraredKey(profileId, deviceName, displayName, sdkKey) - } + ): Job = externalScope.launch { + learnInfraredKey(profileId, deviceName, displayName, sdkKey) } - fun retry(ref: InfraredKeyRef) { - externalScope.launch { - retryInfraredKeyLearning(ref) - } + fun retry(ref: InfraredKeyRef): Job = externalScope.launch { + 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 b50f7e4..a0cdefb 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 @@ -148,7 +148,9 @@ class InfraredRemoteViewModel @Inject constructor( fun toggleLearningMode(enabled: Boolean) { isLearningModeFlow.value = enabled - if (enabled) { + if (!enabled) { + activeLearningSlotFlow.value = null + } else { messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") } } @@ -162,15 +164,26 @@ class InfraredRemoteViewModel @Inject constructor( } if (isLearningModeFlow.value) { + if (activeLearningSlotFlow.value != null) { + return + } activeLearningSlotFlow.value = spec.defaultSlot - learningCoordinator.learn( + val job = learningCoordinator.learn( profileId = currentProfile, deviceName = savedDevice, displayName = spec.displayName, sdkKey = spec.defaultSlot, ) + job.invokeOnCompletion { + if (activeLearningSlotFlow.value == spec.defaultSlot) { + activeLearningSlotFlow.value = null + } + } messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") } else { + if (sendingKeySlotFlow.value != null) { + return + } val key = state.value.learnedSlotMap[spec.defaultSlot] if (key == null || key.status != InfraredKeyStatus.READY) { messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt index 3dc9e39..94100e4 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt @@ -108,6 +108,7 @@ fun RemoteCircleButton( color = defaultContainer, contentColor = defaultContent, shadowElevation = if (isLearned) 4.dp else 1.dp, + enabled = !isLoading, onClick = onClick, ) { Box( @@ -174,6 +175,7 @@ fun RemoteRectButton( color = containerColor, contentColor = contentColor, shadowElevation = if (isLearned) 3.dp else 1.dp, + enabled = !isLoading, onClick = onClick, ) { Box( @@ -240,7 +242,7 @@ fun VerticalCapsuleRocker( else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else Color.Transparent, ) - .clickable(onClick = onUpClick) + .clickable(enabled = !isUpLoading, onClick = onUpClick) .testTag("remote_button_${upSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -267,7 +269,7 @@ fun VerticalCapsuleRocker( else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) else Color.Transparent, ) - .clickable(onClick = onDownClick) + .clickable(enabled = !isDownLoading, onClick = onDownClick) .testTag("remote_button_${downSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -338,7 +340,12 @@ fun RemoteDpadWheel( .padding(top = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onUpClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isUpLoading, onClick = onUpClick) .testTag("remote_button_${upSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -353,7 +360,12 @@ fun RemoteDpadWheel( .padding(bottom = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onDownClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isDownLoading, onClick = onDownClick) .testTag("remote_button_${downSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -368,7 +380,12 @@ fun RemoteDpadWheel( .padding(start = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onLeftClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isLeftLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isLeftLoading, onClick = onLeftClick) .testTag("remote_button_${leftSpec.id}"), contentAlignment = Alignment.Center, ) { @@ -383,7 +400,12 @@ fun RemoteDpadWheel( .padding(end = 10.dp) .size(54.dp) .clip(CircleShape) - .clickable(onClick = onRightClick) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isRightLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(enabled = !isRightLoading, onClick = onRightClick) .testTag("remote_button_${rightSpec.id}"), contentAlignment = Alignment.Center, ) { 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 6bca3ff..71e7d93 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 @@ -133,6 +133,53 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) + assertEquals(null, vm.state.value.activeLearningSlot) + + job.cancel() + } + + @Test + fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest { + deviceRepo.setDeviceName(fakeProfile, device) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.toggleLearningMode(true) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + vm.toggleLearningMode(false) + + assertEquals(null, vm.state.value.activeLearningSlot) + + job.cancel() + } + + @Test + fun duplicateSendButtonClickIsIgnoredWhileSending() = 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) + vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) job.cancel() }