feat: support 1-100 numeric infrared key slots and gateway login entry
This commit is contained in:
@@ -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}"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
-6
@@ -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 = { _, _ -> },
|
||||
|
||||
+7
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-5
@@ -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,
|
||||
)
|
||||
|
||||
+17
-2
@@ -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) {
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<string name="infrared_learn_dialog_title">学习红外按键</string>
|
||||
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string>
|
||||
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
|
||||
<string name="infrared_learn_dialog_slot_label">按键编号(1-100)</string>
|
||||
<string name="infrared_key_slot_label">按键编号:%1$s</string>
|
||||
<string name="infrared_learn_dialog_start">开始学习</string>
|
||||
<string name="infrared_rename_dialog_title">重命名按键</string>
|
||||
<string name="infrared_rename_dialog_input_label">新按键名称</string>
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
<string name="infrared_learn_dialog_title">Learn Infrared Key</string>
|
||||
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
|
||||
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
|
||||
<string name="infrared_learn_dialog_slot_label">Key Slot (1-100)</string>
|
||||
<string name="infrared_key_slot_label">Key Slot: %1$s</string>
|
||||
<string name="infrared_learn_dialog_start">Start Learning</string>
|
||||
<string name="infrared_rename_dialog_title">Rename Key</string>
|
||||
<string name="infrared_rename_dialog_input_label">New Display Name</string>
|
||||
|
||||
+16
-1
@@ -21,6 +21,7 @@ class InfraredKeyFactory(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
displayName: String,
|
||||
sdkKey: String? = null,
|
||||
): DomainResult<SavedInfraredKey> {
|
||||
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,
|
||||
|
||||
+2
-1
@@ -16,8 +16,9 @@ class LearnInfraredKey(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
displayName: String,
|
||||
sdkKey: String? = null,
|
||||
): DomainResult<InfraredKeyId> {
|
||||
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
|
||||
}
|
||||
|
||||
+19
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user