feat(infrared): add infrared control, learning, and virtual remote module #9
+160
-325
@@ -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,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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-9
@@ -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 }
|
||||
}
|
||||
)
|
||||
|
||||
+29
-173
@@ -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(
|
||||
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<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? {
|
||||
|
|
||||
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(
|
||||
|
pr-agent-bot
commented
结果丢失
**结果丢失**
`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>
|
||||
|
||||
+18
-145
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user
容量回绕
当 1 到 100 的槽位全部被占用时,该方法仍返回已占用的槽位 1,导致新增对话框提供一个必然触发唯一约束失败的默认值。应返回无可用槽位的状态,并禁用新增操作或提示容量已满。