From dab1bef5aa9763b8d085404a79c3953d456deecf Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:40:57 +0800 Subject: [PATCH] feat(infrared): clean up device configuration settings page and remove obsolete learned keys --- .../infrared/ui/InfraredControlScreen.kt | 485 ++++++------------ .../infrared/ui/InfraredControlUiState.kt | 13 +- .../infrared/ui/InfraredControlViewModel.kt | 202 ++------ .../src/main/res/values-zh-rCN/strings.xml | 10 +- .../infrared/src/main/res/values/strings.xml | 9 +- .../ui/InfraredControlViewModelTest.kt | 163 +----- 6 files changed, 225 insertions(+), 657 deletions(-) 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 e0b06db..29bb26b 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 @@ -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(null) } - var keyToDelete by remember { mutableStateOf(null) } + val scrollState = rememberScrollState() LaunchedEffect(state.message) { state.message?.let { msg -> @@ -141,113 +124,143 @@ internal fun InfraredControlScreen( .fillMaxSize() .padding(innerPadding), ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // 环境与设备卡片 - Card(modifier = Modifier.fillMaxWidth()) { - 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), - 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, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = state.deviceNameInput, - onValueChange = onDeviceNameChanged, - label = { Text(stringResource(R.string.infrared_device_name_label)) }, - placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) }, - singleLine = true, - modifier = Modifier - .weight(1f) - .testTag("infrared_device_name_input"), - ) - Button( - onClick = onSaveDeviceName, - modifier = Modifier.testTag("infrared_save_device_button"), - ) { - Text(stringResource(R.string.infrared_save_device)) - } - } - } + if (state.loading) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() } - - // 已学习按键列表卡片 - Card(modifier = Modifier.fillMaxWidth().weight(1f)) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + } 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 = stringResource(R.string.infrared_keys_title), + text = "运行环境与连接信息", 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, - ) { + 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 = stringResource(R.string.infrared_no_keys), - style = MaterialTheme.typography.bodyMedium, + text = "当前 Profile: $it", + fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), + } + } + + // 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), + ) { + Text( + text = stringResource(R.string.infrared_device_config_section), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + + Text( + 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( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - 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 }, - ) + OutlinedTextField( + value = state.deviceNameInput, + onValueChange = onDeviceNameChanged, + 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_device_save_changes)) + } + } + } + } + + // 3. 硬件槽位与遥控器概览 + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(R.string.infrared_device_stats_section), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + + Text( + text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个", + style = MaterialTheme.typography.bodyMedium, + ) + + QuotaDetailProgress(quota = state.slotQuota) + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.background, + ), + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + 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) } } } @@ -256,213 +269,35 @@ 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)) }, - 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"), - ) { - 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"), - ) { - Text(stringResource(R.string.infrared_dialog_confirm)) - } - }, - 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( +private fun QuotaDetailProgress(quota: SlotQuotaSummary) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}", + fontWeight = FontWeight.Medium, + fontSize = 13.sp, + ) + Text( + text = "剩余可用静态槽位: ${quota.remainingStaticCount}", + fontSize = 12.sp, + color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + + LinearProgressIndicator( + progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() }, modifier = Modifier .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = key.displayName, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = stringResource(R.string.infrared_key_slot_label, key.sdkKey), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - 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 - }, - ) - } - - 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)) - } - } - } + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt index 1231724..9371ba5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt @@ -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 = emptyList(), + val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), + val totalRemotesCount: Int = 0, val loading: Boolean = true, - val sendingKeyIds: Set = emptySet(), val message: InfraredUiMessage? = null, -) { - val hasActiveLearning: Boolean - get() = keys.any { it.status == InfraredKeyStatus.LEARNING } -} +) 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 42e4375..09fa9ea 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 @@ -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>(emptySet()) + private val deviceNameInputFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(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 = 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( - providerProfile = active, - savedDevice = deviceName, - configError = keysResult.error, - ) - is DomainResult.Success -> ServerState( - providerProfile = active, - savedDevice = deviceName, - keys = keysResult.value, - ) - } + 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, + slotQuota = quota, + remotesCount = remotes.size, + loading = false, + ) } } } @@ -110,9 +90,8 @@ class InfraredControlViewModel @Inject constructor( val state: StateFlow = 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? { - 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( - 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) 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 ef7d8bd..6e086c4 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 @@ -75,12 +75,16 @@ 电视-频道- 电视-主页 电视-返回 - 电视-菜单 - 红外按键管理 + 红外设备与环境配置 + ABSDK 设备名称配置 + ABSDK 依靠此设备名称(如 ir-1、ir-living)寻址具体的硬件转发器,请确保名称与主机端保持一致。 + 当前绑定设备:%1$s + 保存修改 + 硬件槽位与遥控器概览 运行环境:%1$s 本地体验(模拟) ABox(真实硬件) - 设备名称已保存 + 设备名称已成功保存 设备名称不能为空 按键名称不能为空 按键编号必须在 1 到 100 之间 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 9535083..fd8f204 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -77,11 +77,16 @@ TV Home TV Back TV Menu - Infrared Key Management + Device Configuration + ABSDK Device Name + ABSDK matches hardware devices by device name (e.g. ir-1, ir-living). Commands will be dispatched to this target device. + Current Bound Device: %1$s + Save Changes + Hardware Slot Budget Environment: %1$s Local Experience (Simulation) ABox (Real Hardware) - Device name saved + Device name saved successfully Device name cannot be blank Key name cannot be blank Key slot must be between 1 and 100 diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt index aca8f9b..b9b1485 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -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() }