feat(infrared): clean up device configuration settings page and remove obsolete learned keys
This commit is contained in:
+105
-270
@@ -9,16 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -29,23 +29,21 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
import com.flagship.abox.manager.infrared.R
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
|
|
||||||
@@ -63,13 +61,7 @@ fun InfraredControlRoute(
|
|||||||
onSessionInvalid = onSessionInvalid,
|
onSessionInvalid = onSessionInvalid,
|
||||||
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
||||||
onSaveDeviceName = viewModel::saveDeviceName,
|
onSaveDeviceName = viewModel::saveDeviceName,
|
||||||
onLearn = viewModel::learnKey,
|
|
||||||
onRetry = viewModel::retryLearning,
|
|
||||||
onSend = viewModel::sendKey,
|
|
||||||
onRename = viewModel::renameKey,
|
|
||||||
onDelete = viewModel::deleteKey,
|
|
||||||
onMessageShown = viewModel::clearMessage,
|
onMessageShown = viewModel::clearMessage,
|
||||||
nextSlot = viewModel.nextAvailableKeySlot(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,20 +73,11 @@ internal fun InfraredControlScreen(
|
|||||||
onSessionInvalid: () -> Unit,
|
onSessionInvalid: () -> Unit,
|
||||||
onDeviceNameChanged: (String) -> Unit,
|
onDeviceNameChanged: (String) -> Unit,
|
||||||
onSaveDeviceName: () -> Unit,
|
onSaveDeviceName: () -> Unit,
|
||||||
onLearn: (name: String, slot: String?) -> Unit,
|
|
||||||
onRetry: (InfraredKeyId) -> Unit,
|
|
||||||
onSend: (InfraredKeyId) -> Unit,
|
|
||||||
onRename: (InfraredKeyId, String) -> Unit,
|
|
||||||
onDelete: (InfraredKeyId) -> Unit,
|
|
||||||
onMessageShown: () -> Unit,
|
onMessageShown: () -> Unit,
|
||||||
nextSlot: Int?,
|
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
var showLearnDialog by remember { mutableStateOf(false) }
|
|
||||||
var keyToRename by remember { mutableStateOf<SavedInfraredKey?>(null) }
|
|
||||||
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
LaunchedEffect(state.message) {
|
||||||
state.message?.let { msg ->
|
state.message?.let { msg ->
|
||||||
@@ -141,34 +124,82 @@ internal fun InfraredControlScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
|
if (state.loading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.verticalScroll(scrollState)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
// 环境与设备卡片
|
// 1. 当前环境卡片
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
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(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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(
|
||||||
text = stringResource(R.string.infrared_mode_label, envText),
|
text = stringResource(R.string.infrared_device_config_section),
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value),
|
text = stringResource(R.string.infrared_device_config_desc),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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(
|
Row(
|
||||||
@@ -179,290 +210,94 @@ internal fun InfraredControlScreen(
|
|||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.deviceNameInput,
|
value = state.deviceNameInput,
|
||||||
onValueChange = onDeviceNameChanged,
|
onValueChange = onDeviceNameChanged,
|
||||||
label = { Text(stringResource(R.string.infrared_device_name_label)) },
|
label = { Text("修改设备名(如:ir-1, ir-living)") },
|
||||||
placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.testTag("infrared_device_name_input"),
|
.testTag("infrared_device_name_input"),
|
||||||
)
|
)
|
||||||
|
|
||||||
Button(
|
Button(
|
||||||
onClick = onSaveDeviceName,
|
onClick = onSaveDeviceName,
|
||||||
|
enabled = state.deviceNameInput.isNotBlank() && state.deviceNameInput != state.savedDeviceName.value,
|
||||||
modifier = Modifier.testTag("infrared_save_device_button"),
|
modifier = Modifier.testTag("infrared_save_device_button"),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.infrared_save_device))
|
Text(stringResource(R.string.infrared_device_save_changes))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 已学习按键列表卡片
|
// 3. 硬件槽位与遥控器概览
|
||||||
Card(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.infrared_keys_title),
|
text = stringResource(R.string.infrared_device_stats_section),
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
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(
|
||||||
text = stringResource(R.string.infrared_no_keys),
|
text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
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) {
|
QuotaDetailProgress(quota = state.slotQuota)
|
||||||
var keyName by remember { mutableStateOf("") }
|
|
||||||
var keySlot by remember { mutableStateOf(nextSlot?.toString() ?: "") }
|
|
||||||
|
|
||||||
AlertDialog(
|
Card(
|
||||||
onDismissRequest = { showLearnDialog = false },
|
colors = CardDefaults.cardColors(
|
||||||
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
text = {
|
),
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
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))
|
Column(
|
||||||
}
|
modifier = Modifier.padding(12.dp),
|
||||||
},
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
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))
|
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
|
@Composable
|
||||||
private fun InfraredKeyRow(
|
private fun QuotaDetailProgress(quota: SlotQuotaSummary) {
|
||||||
key: SavedInfraredKey,
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
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),
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
Column {
|
|
||||||
Text(
|
Text(
|
||||||
text = key.displayName,
|
text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontSize = 13.sp,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
|
text = "剩余可用静态槽位: ${quota.remainingStaticCount}",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
fontSize = 12.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
val statusLabel = when (key.status) {
|
LinearProgressIndicator(
|
||||||
InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning)
|
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
|
||||||
InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready)
|
modifier = Modifier
|
||||||
InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed)
|
.fillMaxWidth()
|
||||||
}
|
.height(8.dp)
|
||||||
Text(
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+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.DeviceName
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
|
|
||||||
sealed interface InfraredUiMessage {
|
sealed interface InfraredUiMessage {
|
||||||
@@ -20,11 +18,8 @@ data class InfraredControlUiState(
|
|||||||
val providerDisplayName: String? = null,
|
val providerDisplayName: String? = null,
|
||||||
val deviceNameInput: String = "",
|
val deviceNameInput: String = "",
|
||||||
val savedDeviceName: DeviceName = DeviceName("ir-1"),
|
val savedDeviceName: DeviceName = DeviceName("ir-1"),
|
||||||
val keys: List<SavedInfraredKey> = emptyList(),
|
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||||
|
val totalRemotesCount: Int = 0,
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
|
|
||||||
val message: InfraredUiMessage? = null,
|
val message: InfraredUiMessage? = null,
|
||||||
) {
|
)
|
||||||
val hasActiveLearning: Boolean
|
|
||||||
get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
|
|
||||||
}
|
|
||||||
|
|||||||
+26
-170
@@ -8,18 +8,10 @@ import com.flagship.abox.manager.domain.DomainError
|
|||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
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.InfraredDeviceConfigurationRepository
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||||
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.infrared.R
|
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.infrared.remote.UiText
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -30,7 +22,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -39,34 +30,22 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class InfraredControlViewModel @Inject constructor(
|
class InfraredControlViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val keyRepository: InfraredKeyRepository,
|
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||||
|
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||||
private val providerProfileStore: ProviderProfileStore,
|
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() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||||
|
|
||||||
private val deviceNameInputFlow = MutableStateFlow("")
|
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
|
||||||
private val sendingKeyIdsFlow = MutableStateFlow<Set<InfraredKeyId>>(emptySet())
|
|
||||||
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
||||||
|
|
||||||
init {
|
|
||||||
if (profileId != null) {
|
|
||||||
val startupCutoff = System.currentTimeMillis()
|
|
||||||
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ServerState(
|
private data class ServerState(
|
||||||
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
|
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
|
||||||
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
||||||
val keys: List<SavedInfraredKey> = emptyList(),
|
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||||
|
val remotesCount: Int = 0,
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val configError: DomainError? = null,
|
val configError: DomainError? = null,
|
||||||
)
|
)
|
||||||
@@ -87,19 +66,20 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
||||||
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
|
combine(
|
||||||
when (keysResult) {
|
virtualRemoteRepository.observeRemotes(profileId, deviceName),
|
||||||
is DomainResult.Failure -> ServerState(
|
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,
|
providerProfile = active,
|
||||||
savedDevice = deviceName,
|
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(
|
val state: StateFlow<InfraredControlUiState> = combine(
|
||||||
serverStateFlow,
|
serverStateFlow,
|
||||||
deviceNameInputFlow,
|
deviceNameInputFlow,
|
||||||
sendingKeyIdsFlow,
|
|
||||||
messageFlow,
|
messageFlow,
|
||||||
) { serverState, input, sendingIds, msg ->
|
) { serverState, input, msg ->
|
||||||
val resolvedMessage = msg ?: serverState.configError?.let { error ->
|
val resolvedMessage = msg ?: serverState.configError?.let { error ->
|
||||||
when (error) {
|
when (error) {
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
|
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
|
||||||
@@ -125,15 +104,18 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val effectiveInput = input ?: serverState.savedDevice.value
|
||||||
|
|
||||||
InfraredControlUiState(
|
InfraredControlUiState(
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
providerType = serverState.providerProfile?.type,
|
providerType = serverState.providerProfile?.type,
|
||||||
providerDisplayName = serverState.providerProfile?.displayName,
|
providerDisplayName = serverState.providerProfile?.displayName,
|
||||||
deviceNameInput = input,
|
deviceNameInput = effectiveInput,
|
||||||
savedDeviceName = serverState.savedDevice,
|
savedDeviceName = serverState.savedDevice,
|
||||||
keys = serverState.keys,
|
slotQuota = serverState.slotQuota,
|
||||||
|
totalRemotesCount = serverState.remotesCount,
|
||||||
loading = serverState.loading,
|
loading = serverState.loading,
|
||||||
sendingKeyIds = sendingIds,
|
|
||||||
message = resolvedMessage,
|
message = resolvedMessage,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@@ -148,7 +130,8 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun saveDeviceName() {
|
fun saveDeviceName() {
|
||||||
val currentProfile = profileId ?: return
|
val currentProfile = profileId ?: return
|
||||||
val trimmed = deviceNameInputFlow.value.trim()
|
val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value
|
||||||
|
val trimmed = input.trim()
|
||||||
if (trimmed.isBlank()) {
|
if (trimmed.isBlank()) {
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
messageFlow.value = InfraredUiMessage.Error(
|
||||||
UiText.StringResource(R.string.infrared_msg_device_name_blank),
|
UiText.StringResource(R.string.infrared_msg_device_name_blank),
|
||||||
@@ -161,133 +144,6 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
messageFlow.value = InfraredUiMessage.Info(
|
messageFlow.value = InfraredUiMessage.Info(
|
||||||
UiText.StringResource(R.string.infrared_msg_device_name_saved),
|
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 -> {
|
is DomainResult.Failure -> {
|
||||||
handleDomainError(result.error)
|
handleDomainError(result.error)
|
||||||
|
|||||||
@@ -75,12 +75,16 @@
|
|||||||
<string name="infrared_key_tv_ch_down">电视-频道-</string>
|
<string name="infrared_key_tv_ch_down">电视-频道-</string>
|
||||||
<string name="infrared_key_tv_home">电视-主页</string>
|
<string name="infrared_key_tv_home">电视-主页</string>
|
||||||
<string name="infrared_key_tv_back">电视-返回</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_label">运行环境:%1$s</string>
|
||||||
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
|
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
|
||||||
<string name="infrared_mode_abox_real">ABox(真实硬件)</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_device_name_blank">设备名称不能为空</string>
|
||||||
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
|
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
|
||||||
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</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_home">TV Home</string>
|
||||||
<string name="infrared_key_tv_back">TV Back</string>
|
<string name="infrared_key_tv_back">TV Back</string>
|
||||||
<string name="infrared_key_tv_menu">TV Menu</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_label">Environment: %1$s</string>
|
||||||
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
|
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
|
||||||
<string name="infrared_mode_abox_real">ABox (Real Hardware)</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_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_key_name_blank">Key name cannot be blank</string>
|
||||||
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</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 androidx.lifecycle.SavedStateHandle
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
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.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
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.EpochMillisClock
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||||
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.infrared.R
|
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.infrared.remote.UiText
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
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.FakeProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -36,8 +26,6 @@ import kotlinx.coroutines.test.runTest
|
|||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -55,25 +43,15 @@ class InfraredControlViewModelTest {
|
|||||||
private val device = DeviceName("ir-living")
|
private val device = DeviceName("ir-living")
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
private val clock = EpochMillisClock { 10_000L }
|
||||||
|
|
||||||
private val keyRepo = FakeInfraredKeyRepository()
|
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||||
|
private val remoteRepo = FakeVirtualRemoteRepository()
|
||||||
private val profileStore = FakeProviderProfileStore()
|
private val profileStore = FakeProviderProfileStore()
|
||||||
private val gateway = FakeInfraredGateway()
|
private val gateway = FakeInfraredGateway()
|
||||||
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
|
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, 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
|
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -84,34 +62,16 @@ class InfraredControlViewModelTest {
|
|||||||
private fun createViewModel(): InfraredControlViewModel {
|
private fun createViewModel(): InfraredControlViewModel {
|
||||||
return InfraredControlViewModel(
|
return InfraredControlViewModel(
|
||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
||||||
keyRepository = keyRepo,
|
|
||||||
deviceConfigurationRepository = deviceRepo,
|
deviceConfigurationRepository = deviceRepo,
|
||||||
|
virtualRemoteRepository = remoteRepo,
|
||||||
providerProfileStore = profileStore,
|
providerProfileStore = profileStore,
|
||||||
learningCoordinator = coordinator,
|
|
||||||
sendInfraredKey = send,
|
|
||||||
renameInfraredKey = rename,
|
|
||||||
deleteInfraredKey = delete,
|
|
||||||
recoverInterruptedInfraredLearning = recover,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun initialStateLoadsKeysAndDeviceNameForProfile() = testScope.runTest {
|
fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = testScope.runTest {
|
||||||
profileStore.saveProfile(providerProfile)
|
profileStore.saveProfile(providerProfile)
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
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 vm = createViewModel()
|
||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
@@ -119,8 +79,8 @@ class InfraredControlViewModelTest {
|
|||||||
|
|
||||||
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
||||||
assertEquals(device, vm.state.value.savedDeviceName)
|
assertEquals(device, vm.state.value.savedDeviceName)
|
||||||
assertEquals(1, vm.state.value.keys.size)
|
assertEquals("ir-living", vm.state.value.deviceNameInput)
|
||||||
assertEquals("Power", vm.state.value.keys.first().displayName)
|
assertEquals(0, vm.state.value.totalRemotesCount)
|
||||||
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
@@ -165,111 +125,24 @@ class InfraredControlViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun learnKeyTriggersCoordinator() = testScope.runTest {
|
fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest {
|
||||||
profileStore.saveProfile(providerProfile)
|
profileStore.saveProfile(providerProfile)
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
deviceRepo.setDeviceName(profileId, device)
|
||||||
val vm = createViewModel()
|
createRemote(
|
||||||
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"),
|
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
deviceName = device,
|
deviceName = device,
|
||||||
displayName = "Power",
|
displayName = "Living TV",
|
||||||
sdkKey = "1",
|
type = RemoteDeviceType.TV,
|
||||||
status = InfraredKeyStatus.READY,
|
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
)
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
vm.sendKey(InfraredKeyId("k1"))
|
assertEquals(1, vm.state.value.totalRemotesCount)
|
||||||
advanceUntilIdle()
|
assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size)
|
||||||
|
assertEquals(16, vm.state.value.slotQuota.usedCount)
|
||||||
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)
|
|
||||||
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user