diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index d997aeb..9a2dc11 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -75,5 +75,3 @@ private fun AppSessionUiState.screenKey(): String = when (this) { is AppSessionUiState.Login -> "login:${profile.id.value}" is AppSessionUiState.Authenticated -> "authenticated:${profile.id.value}" } - -} diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt index 211cefe..e670e3f 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -3,7 +3,6 @@ package com.flagship.abox.manager.infrared.ui import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import com.flagship.abox.manager.domain.DeviceName @@ -13,7 +12,6 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus import com.flagship.abox.manager.domain.infrared.SavedInfraredKey import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -36,11 +34,12 @@ class InfraredControlScreenTest { composeTestRule.setContent { InfraredControlScreen( state = state, + nextSlot = 1, onBack = {}, onSessionInvalid = {}, onDeviceNameChanged = {}, onSaveDeviceName = {}, - onLearn = {}, + onLearn = { _, _ -> }, onRetry = {}, onSend = {}, onRename = { _, _ -> }, @@ -63,7 +62,7 @@ class InfraredControlScreenTest { profileId = ProviderProfileId("abox"), deviceName = DeviceName("ir-1"), displayName = "Power Key", - sdkKey = "app_ir_kready", + sdkKey = "1", status = InfraredKeyStatus.READY, lastErrorCode = null, lastErrorDetail = null, @@ -75,7 +74,7 @@ class InfraredControlScreenTest { profileId = ProviderProfileId("abox"), deviceName = DeviceName("ir-1"), displayName = "Volume Up", - sdkKey = "app_ir_kfailed", + sdkKey = "2", status = InfraredKeyStatus.FAILED, lastErrorCode = "20206", lastErrorDetail = null, @@ -96,11 +95,12 @@ class InfraredControlScreenTest { composeTestRule.setContent { InfraredControlScreen( state = state, + nextSlot = 3, onBack = {}, onSessionInvalid = {}, onDeviceNameChanged = {}, onSaveDeviceName = {}, - onLearn = {}, + onLearn = { _, _ -> }, onRetry = { retriedKeyId = it }, onSend = { sentKeyId = it }, onRename = { _, _ -> }, 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 8dbc7ef..a191321 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 @@ -13,9 +13,14 @@ class InfraredLearningCoordinator( private val retryInfraredKeyLearning: RetryInfraredKeyLearning, private val externalScope: CoroutineScope, ) { - fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) { + fun learn( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + sdkKey: String? = null, + ) { externalScope.launch { - learnInfraredKey(profileId, deviceName, displayName) + learnInfraredKey(profileId, deviceName, displayName, sdkKey) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index 61b94b1..6f575ce 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -1,6 +1,5 @@ package com.flagship.abox.manager.infrared.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -58,6 +57,7 @@ fun InfraredControlRoute( InfraredControlScreen( state = state, + nextSlot = viewModel.nextAvailableKeySlot(), onBack = onBack, onSessionInvalid = onSessionInvalid, onDeviceNameChanged = viewModel::onDeviceNameChanged, @@ -76,11 +76,12 @@ fun InfraredControlRoute( @Composable internal fun InfraredControlScreen( state: InfraredControlUiState, + nextSlot: Int, onBack: () -> Unit, onSessionInvalid: () -> Unit, onDeviceNameChanged: (String) -> Unit, onSaveDeviceName: () -> Unit, - onLearn: (String) -> Unit, + onLearn: (String, String?) -> Unit, onRetry: (InfraredKeyId) -> Unit, onSend: (InfraredKeyId) -> Unit, onRename: (InfraredKeyId, String) -> Unit, @@ -265,6 +266,8 @@ internal fun InfraredControlScreen( if (showLearnDialog) { var keyName by remember { mutableStateOf("") } + var keySlot by remember { mutableStateOf(nextSlot.toString()) } + AlertDialog( onDismissRequest = { showLearnDialog = false }, title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, @@ -278,15 +281,22 @@ internal fun InfraredControlScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + OutlinedTextField( + value = keySlot, + onValueChange = { keySlot = it }, + label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) } }, confirmButton = { Button( onClick = { - onLearn(keyName) + onLearn(keyName, keySlot) showLearnDialog = false }, - enabled = keyName.isNotBlank(), + enabled = keyName.isNotBlank() && keySlot.isNotBlank(), ) { Text(stringResource(R.string.infrared_learn_dialog_start)) } @@ -387,7 +397,7 @@ private fun InfraredKeyRow( style = MaterialTheme.typography.titleMedium, ) Text( - text = "SDK: ${key.sdkKey}", + text = stringResource(R.string.infrared_key_slot_label, key.sdkKey), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 7b5eeeb..d1ee47f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -192,7 +192,12 @@ class InfraredControlViewModel @Inject constructor( } } - fun learnKey(displayName: String) { + fun nextAvailableKeySlot(): Int { + val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() + return (1..100).firstOrNull { it !in usedSlots } ?: 1 + } + + fun learnKey(displayName: String, keySlotInput: String? = null) { val currentProfile = state.value.effectiveProfileId ?: return val savedDevice = state.value.savedDeviceName if (savedDevice == null) { @@ -204,7 +209,17 @@ class InfraredControlViewModel @Inject constructor( messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank") return } - learningCoordinator.learn(currentProfile, savedDevice, trimmedName) + val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) { + val slotInt = keySlotInput.trim().toIntOrNull() + if (slotInt == null || slotInt !in 1..100) { + messageFlow.value = InfraredUiMessage.Error("Key slot must be an integer between 1 and 100") + return + } + slotInt.toString() + } else { + nextAvailableKeySlot().toString() + } + learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) } fun retryLearning(keyId: InfraredKeyId) { 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 8592155..c166919 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 @@ -22,6 +22,8 @@ 学习红外按键 请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。 按键名称(如:电源) + 按键编号(1-100) + 按键编号:%1$s 开始学习 重命名按键 新按键名称 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 42bd2f1..95304d2 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -22,6 +22,8 @@ Learn Infrared Key Prepare the physical remote. After starting, point it at the infrared controller and press the target button. Key Name (e.g. Power) + Key Slot (1-100) + Key Slot: %1$s Start Learning Rename Key New Display Name diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt index 0040df9..c83bd96 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactory.kt @@ -21,6 +21,7 @@ class InfraredKeyFactory( profileId: ProviderProfileId, deviceName: DeviceName, displayName: String, + sdkKey: String? = null, ): DomainResult { val normalizedName = displayName.trim() if ( @@ -39,6 +40,20 @@ class InfraredKeyFactory( ?: return DomainResult.Failure( DomainError.InvalidRequest(detail = "Infrared key id is invalid"), ) + + val finalSdkKey = if (sdkKey != null) { + val trimmed = sdkKey.trim() + val intVal = trimmed.toIntOrNull() + if (intVal == null || intVal !in 1..100) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"), + ) + } + intVal.toString() + } else { + "app_ir_$canonicalId" + } + val now = clock.now() return DomainResult.Success( SavedInfraredKey( @@ -46,7 +61,7 @@ class InfraredKeyFactory( profileId = profileId, deviceName = deviceName, displayName = normalizedName, - sdkKey = "app_ir_$canonicalId", + sdkKey = finalSdkKey, status = InfraredKeyStatus.LEARNING, lastErrorCode = null, lastErrorDetail = null, @@ -68,4 +83,4 @@ class InfraredKeyFactory( "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$", ) } -} \ No newline at end of file +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt index 9f6155b..394557d 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnInfraredKey.kt @@ -16,8 +16,9 @@ class LearnInfraredKey( profileId: ProviderProfileId, deviceName: DeviceName, displayName: String, + sdkKey: String? = null, ): DomainResult { - val key = when (val result = factory.create(profileId, deviceName, displayName)) { + val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) { is DomainResult.Success -> result.value is DomainResult.Failure -> return result } diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt index dcc17e0..95c7b6b 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InfraredKeyFactoryTest.kt @@ -32,6 +32,25 @@ class InfraredKeyFactoryTest { assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis) } + @Test + fun createsLearningKeyWithCustomNumericSlot() { + val result = factory.create( + ProviderProfileId("abox"), + DeviceName("ir-1"), + "空调开", + sdkKey = "1", + ) as DomainResult.Success + + assertEquals("1", result.value.sdkKey) + } + + @Test + fun rejectsInvalidNumericSlots() { + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure) + assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure) + } + @Test fun rejectsBlankDeviceOrDisplayName() { assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure) @@ -67,4 +86,4 @@ class InfraredKeyFactoryTest { DeviceName("ir-1"), "Power", ) -} \ No newline at end of file +}