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
+}