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
6 changed files with 225 additions and 657 deletions
Showing only changes of commit dab1bef5aa - Show all commits
@@ -9,16 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
@@ -29,23 +29,21 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
@@ -63,13 +61,7 @@ fun InfraredControlRoute(
onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged,
onSaveDeviceName = viewModel::saveDeviceName,
onLearn = viewModel::learnKey,
onRetry = viewModel::retryLearning,
onSend = viewModel::sendKey,
onRename = viewModel::renameKey,
onDelete = viewModel::deleteKey,
onMessageShown = viewModel::clearMessage,
nextSlot = viewModel.nextAvailableKeySlot(),
)
}
@@ -81,20 +73,11 @@ internal fun InfraredControlScreen(
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onLearn: (name: String, slot: String?) -> Unit,
onRetry: (InfraredKeyId) -> Unit,
onSend: (InfraredKeyId) -> Unit,
onRename: (InfraredKeyId, String) -> Unit,
onDelete: (InfraredKeyId) -> Unit,
onMessageShown: () -> Unit,
nextSlot: Int?,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
var showLearnDialog by remember { mutableStateOf(false) }
var keyToRename by remember { mutableStateOf<SavedInfraredKey?>(null) }
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(null) }
val scrollState = rememberScrollState()
LaunchedEffect(state.message) {
state.message?.let { msg ->
@@ -141,34 +124,82 @@ internal fun InfraredControlScreen(
.fillMaxSize()
.padding(innerPadding),
) {
if (state.loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 环境与设备卡片
// 1. 当前环境卡片
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "运行环境与连接信息",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
val envText = when (state.providerType) {
ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation)
ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real)
null -> state.profileId?.value ?: "未知环境"
}
Text(
text = stringResource(R.string.infrared_mode_label, envText),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
state.providerDisplayName?.let {
Text(
text = "当前 Profile: $it",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// 2. ABSDK 硬件设备名称配置(修改当前设备名)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
val envText = when (state.providerType) {
ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation)
ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real)
null -> state.profileId?.value ?: ""
}
Text(
text = stringResource(R.string.infrared_mode_label, envText),
text = stringResource(R.string.infrared_device_config_section),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value),
style = MaterialTheme.typography.bodyMedium,
text = stringResource(R.string.infrared_device_config_desc),
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.infrared_device_name_current, state.savedDeviceName.value),
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
fontSize = 14.sp,
)
Row(
@@ -179,290 +210,94 @@ internal fun InfraredControlScreen(
OutlinedTextField(
value = state.deviceNameInput,
onValueChange = onDeviceNameChanged,
label = { Text(stringResource(R.string.infrared_device_name_label)) },
placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) },
label = { Text("修改设备名(如:ir-1, ir-living)") },
singleLine = true,
modifier = Modifier
.weight(1f)
.testTag("infrared_device_name_input"),
)
Button(
onClick = onSaveDeviceName,
enabled = state.deviceNameInput.isNotBlank() && state.deviceNameInput != state.savedDeviceName.value,
modifier = Modifier.testTag("infrared_save_device_button"),
) {
Text(stringResource(R.string.infrared_save_device))
Text(stringResource(R.string.infrared_device_save_changes))
}
}
}
}
// 已学习按键列表卡片
Card(modifier = Modifier.fillMaxWidth().weight(1f)) {
// 3. 硬件槽位与遥控器概览
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_keys_title),
text = stringResource(R.string.infrared_device_stats_section),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Button(
onClick = { showLearnDialog = true },
modifier = Modifier.testTag("infrared_learn_new_key_button"),
) {
Text(stringResource(R.string.infrared_learn_new_key))
}
}
if (state.keys.isEmpty()) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_no_keys),
text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.keys, key = { it.id.value }) { key ->
InfraredKeyRow(
key = key,
isSending = state.sendingKeyIds.contains(key.id),
onSend = { onSend(key.id) },
onRetry = { onRetry(key.id) },
onRename = { keyToRename = key },
onDelete = { keyToDelete = key },
)
}
}
}
}
}
}
}
}
if (showLearnDialog) {
var keyName by remember { mutableStateOf("") }
var keySlot by remember { mutableStateOf(nextSlot?.toString() ?: "") }
QuotaDetailProgress(quota = state.slotQuota)
AlertDialog(
onDismissRequest = { showLearnDialog = false },
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.infrared_learn_dialog_prompt))
OutlinedTextField(
value = keyName,
onValueChange = { keyName = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("infrared_learn_name_input"),
)
OutlinedTextField(
value = keySlot,
onValueChange = { keySlot = it },
label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("infrared_learn_slot_input"),
)
}
},
confirmButton = {
Button(
onClick = {
onLearn(keyName, keySlot)
showLearnDialog = false
},
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
modifier = Modifier.testTag("infrared_learn_dialog_confirm"),
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.background,
),
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.infrared_learn_dialog_start))
}
},
dismissButton = {
TextButton(onClick = { showLearnDialog = false }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToRename?.let { targetKey ->
var newName by remember { mutableStateOf(targetKey.displayName) }
AlertDialog(
onDismissRequest = { keyToRename = null },
title = { Text(stringResource(R.string.infrared_rename_dialog_title)) },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("infrared_rename_input"),
)
},
confirmButton = {
Button(
onClick = {
onRename(targetKey.id, newName)
keyToRename = null
},
enabled = newName.isNotBlank(),
modifier = Modifier.testTag("infrared_rename_confirm"),
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(stringResource(R.string.infrared_dialog_confirm))
Text("💡 槽位工作原理:", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Text("• 静态独占槽位 (Slot 1..99):用于物理学习录制的按键或预设静态按键,硬件直发低延迟(~20ms)。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("• 动态共享缓冲 (Slot 100):用于空调全状态算法写码与动态模式遥控器,按需现场写入发射,不消耗独占槽位。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
},
dismissButton = {
TextButton(onClick = { keyToRename = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
keyToDelete?.let { targetKey ->
AlertDialog(
onDismissRequest = { keyToDelete = null },
title = { Text(stringResource(R.string.infrared_delete_dialog_title)) },
text = { Text(stringResource(R.string.infrared_delete_dialog_message)) },
confirmButton = {
Button(
onClick = {
onDelete(targetKey.id)
keyToDelete = null
},
modifier = Modifier.testTag("infrared_delete_confirm"),
) {
Text(stringResource(R.string.infrared_dialog_confirm))
}
},
dismissButton = {
TextButton(onClick = { keyToDelete = null }) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
}
@Composable
private fun InfraredKeyRow(
key: SavedInfraredKey,
isSending: Boolean,
onSend: () -> Unit,
onRetry: () -> Unit,
onRename: () -> Unit,
onDelete: () -> Unit,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.testTag("infrared_key_${key.id.value}"),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
private fun QuotaDetailProgress(quota: SlotQuotaSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = key.displayName,
style = MaterialTheme.typography.titleMedium,
text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}",
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
)
Text(
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = "剩余可用静态槽位: ${quota.remainingStaticCount}",
fontSize = 12.sp,
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
)
}
val statusLabel = when (key.status) {
InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning)
InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready)
InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed)
}
Text(
text = statusLabel,
style = MaterialTheme.typography.bodyMedium,
color = when (key.status) {
InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary
InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary
InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error
},
LinearProgressIndicator(
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
if (key.status == InfraredKeyStatus.READY) {
Button(
onClick = onSend,
enabled = !isSending,
modifier = Modifier.testTag("infrared_send_${key.id.value}"),
) {
if (isSending) {
Text(stringResource(R.string.infrared_sending))
} else {
Text(stringResource(R.string.infrared_send))
}
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status == InfraredKeyStatus.FAILED) {
Button(
onClick = onRetry,
modifier = Modifier.testTag("infrared_retry_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_retry))
}
Spacer(modifier = Modifier.width(8.dp))
}
if (key.status != InfraredKeyStatus.LEARNING) {
OutlinedButton(
onClick = onRename,
modifier = Modifier.testTag("infrared_rename_btn_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_rename))
}
Spacer(modifier = Modifier.width(8.dp))
OutlinedButton(
onClick = onDelete,
modifier = Modifier.testTag("infrared_delete_btn_${key.id.value}"),
) {
Text(stringResource(R.string.infrared_delete))
}
} else {
CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp))
}
}
}
}
}
@@ -3,9 +3,7 @@ package com.flagship.abox.manager.infrared.ui
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.infrared.remote.UiText
sealed interface InfraredUiMessage {
@@ -20,11 +18,8 @@ data class InfraredControlUiState(
val providerDisplayName: String? = null,
val deviceNameInput: String = "",
val savedDeviceName: DeviceName = DeviceName("ir-1"),
val keys: List<SavedInfraredKey> = emptyList(),
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val totalRemotesCount: Int = 0,
val loading: Boolean = true,
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
val message: InfraredUiMessage? = null,
) {
val hasActiveLearning: Boolean
get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
}
)
@@ -8,18 +8,10 @@ import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.remote.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -30,7 +22,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -39,34 +30,22 @@ import javax.inject.Inject
@HiltViewModel
class InfraredControlViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val providerProfileStore: ProviderProfileStore,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
private val renameInfraredKey: RenameInfraredKey,
private val deleteInfraredKey: DeleteInfraredKey,
private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val deviceNameInputFlow = MutableStateFlow("")
private val sendingKeyIdsFlow = MutableStateFlow<Set<InfraredKeyId>>(emptySet())
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
init {
if (profileId != null) {
val startupCutoff = System.currentTimeMillis()
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
}
}
private data class ServerState(
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
val keys: List<SavedInfraredKey> = emptyList(),
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val remotesCount: Int = 0,
val loading: Boolean = false,
val configError: DomainError? = null,
)
@@ -87,19 +66,20 @@ class InfraredControlViewModel @Inject constructor(
)
is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
when (keysResult) {
is DomainResult.Failure -> ServerState(
combine(
virtualRemoteRepository.observeRemotes(profileId, deviceName),
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
) { remotesResult, quotaResult ->
val remotes = (remotesResult as? DomainResult.Success)?.value ?: emptyList()
val quota = (quotaResult as? DomainResult.Success)?.value
?: SlotQuotaSummary()
ServerState(
providerProfile = active,
savedDevice = deviceName,
configError = keysResult.error,
slotQuota = quota,
remotesCount = remotes.size,
loading = false,
)
is DomainResult.Success -> ServerState(
providerProfile = active,
savedDevice = deviceName,
keys = keysResult.value,
)
}
}
}
}
@@ -110,9 +90,8 @@ class InfraredControlViewModel @Inject constructor(
val state: StateFlow<InfraredControlUiState> = combine(
serverStateFlow,
deviceNameInputFlow,
sendingKeyIdsFlow,
messageFlow,
) { serverState, input, sendingIds, msg ->
) { serverState, input, msg ->
val resolvedMessage = msg ?: serverState.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
@@ -125,15 +104,18 @@ class InfraredControlViewModel @Inject constructor(
)
}
}
val effectiveInput = input ?: serverState.savedDevice.value
InfraredControlUiState(
profileId = profileId,
providerType = serverState.providerProfile?.type,
providerDisplayName = serverState.providerProfile?.displayName,
deviceNameInput = input,
deviceNameInput = effectiveInput,
savedDeviceName = serverState.savedDevice,
keys = serverState.keys,
slotQuota = serverState.slotQuota,
totalRemotesCount = serverState.remotesCount,
loading = serverState.loading,
sendingKeyIds = sendingIds,
message = resolvedMessage,
)
}.stateIn(
@@ -148,7 +130,8 @@ class InfraredControlViewModel @Inject constructor(
fun saveDeviceName() {
val currentProfile = profileId ?: return
val trimmed = deviceNameInputFlow.value.trim()
val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value
val trimmed = input.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_name_blank),
@@ -161,133 +144,6 @@ class InfraredControlViewModel @Inject constructor(
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_device_name_saved),
)
deviceNameInputFlow.value = ""
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun nextAvailableKeySlot(): Int? {
Review

容量回绕

当 1 到 100 的槽位全部被占用时,该方法仍返回已占用的槽位 1,导致新增对话框提供一个必然触发唯一约束失败的默认值。应返回无可用槽位的状态,并禁用新增操作或提示容量已满。

**容量回绕** 当 1 到 100 的槽位全部被占用时,该方法仍返回已占用的槽位 1,导致新增对话框提供一个必然触发唯一约束失败的默认值。应返回无可用槽位的状态,并禁用新增操作或提示容量已满。 <!-- pr-agent-dedup: 8d26af909fc3 --> <!-- pr-agent-key-issue-location: c6973e0768bd -->
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
return (1..100).firstOrNull { it !in usedSlots }
}
fun learnKey(displayName: String, keySlotInput: String? = null) {
val currentProfile = profileId ?: return
val savedDevice = state.value.savedDeviceName
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_key_name_blank),
)
return
}
val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) {
val slotInt = keySlotInput.trim().toIntOrNull()
if (slotInt == null || slotInt !in 1..100) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_slot_invalid),
)
return
}
slotInt.toString()
} else {
val autoSlot = nextAvailableKeySlot()
if (autoSlot == null) {
messageFlow.value = InfraredUiMessage.Error(
Review

结果丢失

learningCoordinator.learn() 和 retry() 返回的异步结果被直接丢弃。遇到重复槽位、设备离线或认证失效时,不会调用 handleDomainError 或触发会话失效处理;如果新记录尚未插入,列表也不会变化,用户只会看到对话框关闭。应在 viewModelScope 中等待结果并统一处理失败。

**结果丢失** `learningCoordinator.learn()` 和 `retry()` 返回的异步结果被直接丢弃。遇到重复槽位、设备离线或认证失效时,不会调用 `handleDomainError` 或触发会话失效处理;如果新记录尚未插入,列表也不会变化,用户只会看到对话框关闭。应在 `viewModelScope` 中等待结果并统一处理失败。 <!-- pr-agent-dedup: a29431c5b567 --> <!-- pr-agent-key-issue-location: 76371e98dff6 -->
UiText.DynamicString("硬件按键槽位已满(上限 100 个),无法自动分配槽位"),
)
return
}
autoSlot.toString()
}
viewModelScope.launch {
when (val res = learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString).await()) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_learned_success, listOf(trimmedName)),
)
}
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun retryLearning(keyId: InfraredKeyId) {
val currentProfile = profileId ?: return
viewModelScope.launch {
when (val res = learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)).await()) {
is DomainResult.Success -> Unit
is DomainResult.Failure -> handleDomainError(res.error)
}
}
}
fun sendKey(keyId: InfraredKeyId) {
val currentProfile = profileId ?: return
if (state.value.sendingKeyIds.contains(keyId)) return
val key = state.value.keys.find { it.id == keyId }
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_key_not_ready),
)
return
}
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(key.displayName)),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId
}
}
}
fun renameKey(keyId: InfraredKeyId, displayName: String) {
val currentProfile = profileId ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_key_name_blank),
)
return
}
viewModelScope.launch {
when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_key_renamed),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun deleteKey(keyId: InfraredKeyId) {
val currentProfile = profileId ?: return
viewModelScope.launch {
when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_key_deleted),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
@@ -75,12 +75,16 @@
<string name="infrared_key_tv_ch_down">电视-频道-</string>
<string name="infrared_key_tv_home">电视-主页</string>
<string name="infrared_key_tv_back">电视-返回</string>
<string name="infrared_key_tv_menu">电视-菜单</string>
<string name="infrared_control_title">红外按键管理</string>
<string name="infrared_control_title">红外设备与环境配置</string>
<string name="infrared_device_config_section">ABSDK 设备名称配置</string>
<string name="infrared_device_config_desc">ABSDK 依靠此设备名称(如 ir-1、ir-living)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string>
<string name="infrared_device_name_current">当前绑定设备:%1$s</string>
<string name="infrared_device_save_changes">保存修改</string>
<string name="infrared_device_stats_section">硬件槽位与遥控器概览</string>
<string name="infrared_mode_label">运行环境:%1$s</string>
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
<string name="infrared_msg_device_name_saved">设备名称已保存</string>
<string name="infrared_msg_device_name_saved">设备名称已成功保存</string>
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
@@ -77,11 +77,16 @@
<string name="infrared_key_tv_home">TV Home</string>
<string name="infrared_key_tv_back">TV Back</string>
<string name="infrared_key_tv_menu">TV Menu</string>
<string name="infrared_control_title">Infrared Key Management</string>
<string name="infrared_control_title">Device Configuration</string>
<string name="infrared_device_config_section">ABSDK Device Name</string>
<string name="infrared_device_config_desc">ABSDK matches hardware devices by device name (e.g. ir-1, ir-living). Commands will be dispatched to this target device.</string>
<string name="infrared_device_name_current">Current Bound Device: %1$s</string>
<string name="infrared_device_save_changes">Save Changes</string>
<string name="infrared_device_stats_section">Hardware Slot Budget</string>
<string name="infrared_mode_label">Environment: %1$s</string>
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
<string name="infrared_msg_device_name_saved">Device name saved</string>
<string name="infrared_msg_device_name_saved">Device name saved successfully</string>
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
@@ -2,29 +2,19 @@ package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.remote.UiText
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
@@ -36,8 +26,6 @@ import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@@ -55,25 +43,15 @@ class InfraredControlViewModelTest {
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val remoteRepo = FakeVirtualRemoteRepository()
private val profileStore = FakeProviderProfileStore()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private val rename = RenameInfraredKey(keyRepo, clock)
private val delete = DeleteInfraredKey(keyRepo)
private val recover = RecoverInterruptedInfraredLearning(keyRepo, clock)
private lateinit var coordinator: InfraredLearningCoordinator
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
}
@After
@@ -84,34 +62,16 @@ class InfraredControlViewModelTest {
private fun createViewModel(): InfraredControlViewModel {
return InfraredControlViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
virtualRemoteRepository = remoteRepo,
providerProfileStore = profileStore,
learningCoordinator = coordinator,
sendInfraredKey = send,
renameInfraredKey = rename,
deleteInfraredKey = delete,
recoverInterruptedInfraredLearning = recover,
)
}
@Test
fun initialStateLoadsKeysAndDeviceNameForProfile() = testScope.runTest {
fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profileId,
deviceName = device,
displayName = "Power",
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 {} }
@@ -119,8 +79,8 @@ class InfraredControlViewModelTest {
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
assertEquals(device, vm.state.value.savedDeviceName)
assertEquals(1, vm.state.value.keys.size)
assertEquals("Power", vm.state.value.keys.first().displayName)
assertEquals("ir-living", vm.state.value.deviceNameInput)
assertEquals(0, vm.state.value.totalRemotesCount)
job.cancel()
}
@@ -165,111 +125,24 @@ class InfraredControlViewModelTest {
}
@Test
fun learnKeyTriggersCoordinator() = testScope.runTest {
fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.learnKey("Volume Up", "5")
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("5", gateway.learnedKeys.first().key)
job.cancel()
}
@Test
fun sendKeyTriggersSendInfraredKeyWhenReady() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
createRemote(
profileId = profileId,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
displayName = "Living TV",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.sendKey(InfraredKeyId("k1"))
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun renameKeyUpdatesKeyName() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profileId,
deviceName = device,
displayName = "Power",
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.renameKey(InfraredKeyId("k1"), "Main Power")
advanceUntilIdle()
val updated = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals("Main Power", updated.displayName)
job.cancel()
}
@Test
fun deleteKeyRemovesKey() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profileId,
deviceName = device,
displayName = "Power",
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.deleteKey(InfraredKeyId("k1"))
advanceUntilIdle()
val result = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value
assertEquals(null, result)
assertEquals(1, vm.state.value.totalRemotesCount)
assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size)
assertEquals(16, vm.state.value.slotQuota.usedCount)
job.cancel()
}