feat(infrared): add infrared control, learning, and virtual remote module #9

Merged
KremeCN merged 41 commits from feat/moler-infrared-control into main 2026-08-25 14:51:34 +08:00
4 changed files with 95 additions and 16 deletions
Showing only changes of commit 714fe699f6 - Show all commits
@@ -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 {
): Job = externalScope.launch {
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
}
}
fun retry(ref: InfraredKeyRef) {
externalScope.launch {
fun retry(ref: InfraredKeyRef): Job = externalScope.launch {
retryInfraredKeyLearning(ref)
}
}
}
@@ -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}】尚未学习,请开启右上角学习模式进行录制")
@@ -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,
) {
@@ -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()
}