feat(infrared): expand remote library and creation flow (#13)
## Summary - replace the crowded add-remote dialog with a full-screen three-step flow - make the first step explicitly choose between adding from the infrared library and creating through learning - add a full-screen remote editor with read-only library configuration, editable custom layouts, and delete confirmation - append newly created remotes to the bottom of the management list and right side of the remote switcher - remove the fan emoji from the device type label - add a curated CC0 Flipper-IRDB preset subset with real NEC/NECext protocol codes - add projector, audio/receiver, set-top box, streaming device, and lighting categories - require a test power command and explicit device-response confirmation before creating library remotes - include source attribution and a repeatable converter for parsed NEC/NECext signals ## Compatibility scope - imported formats: parsed NEC and NECext only - excluded: raw pulse data and other protocols because the ABox irCode encoding contract is not publicly specified for them - existing devices, remotes, and scenes are not migrated or renamed ## Verification - ./gradlew.bat :android:app:assembleDebug --no-daemon - converter reproduced Reviewed-on: #13 Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #13.
This commit is contained in:
+1
-1
@@ -111,7 +111,7 @@ interface InfraredDao {
|
||||
"""
|
||||
SELECT * FROM virtual_remotes
|
||||
WHERE profileId = :profileId AND deviceName = :deviceName
|
||||
ORDER BY createdAtEpochMillis DESC
|
||||
ORDER BY createdAtEpochMillis ASC, id ASC
|
||||
""",
|
||||
)
|
||||
fun observeVirtualRemotes(profileId: String, deviceName: String): Flow<List<VirtualRemoteEntity>>
|
||||
|
||||
+7
@@ -192,6 +192,13 @@ object InfraredFeatureModule {
|
||||
): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction =
|
||||
com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideTestPresetInfraredCode(
|
||||
gateway: InfraredGateway,
|
||||
): com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode =
|
||||
com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode(gateway)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeleteVirtualRemote(
|
||||
|
||||
+628
-256
@@ -105,6 +105,7 @@ fun InfraredHubRoute(
|
||||
onOpenRemote = onOpenRemote,
|
||||
onDeleteRemote = viewModel::deleteRemote,
|
||||
onCreateRemote = viewModel::createRemote,
|
||||
onTestPreset = viewModel::testPreset,
|
||||
onSaveDeviceName = viewModel::saveDeviceName,
|
||||
onMoveRemote = viewModel::moveRemote,
|
||||
onUpdateRemote = viewModel::updateRemote,
|
||||
@@ -134,6 +135,7 @@ internal fun InfraredHubScreen(
|
||||
onOpenRemote: (String) -> Unit,
|
||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
|
||||
onTestPreset: (String, (Boolean) -> Unit) -> Unit = { _, result -> result(false) },
|
||||
onSaveDeviceName: (String) -> Unit = {},
|
||||
onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> },
|
||||
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> },
|
||||
@@ -156,6 +158,7 @@ internal fun InfraredHubScreen(
|
||||
val resources = LocalResources.current
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
var showManagement by remember { mutableStateOf(false) }
|
||||
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
@@ -303,7 +306,7 @@ internal fun InfraredHubScreen(
|
||||
onDeleteRemote = onDeleteRemote,
|
||||
onSaveDeviceName = onSaveDeviceName,
|
||||
onMoveRemote = onMoveRemote,
|
||||
onUpdateRemote = onUpdateRemote,
|
||||
onEditRemote = { editingRemote = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -311,7 +314,9 @@ internal fun InfraredHubScreen(
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateVirtualRemoteDialog(
|
||||
CreateVirtualRemoteFlow(
|
||||
slotQuota = state.slotQuota,
|
||||
onTestPreset = onTestPreset,
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onConfirm = { name, type, brandModel, transMode, layoutStyle ->
|
||||
onCreateRemote(name, type, brandModel, transMode, layoutStyle)
|
||||
@@ -320,6 +325,21 @@ internal fun InfraredHubScreen(
|
||||
)
|
||||
}
|
||||
|
||||
editingRemote?.let { remote ->
|
||||
EditRemoteScreen(
|
||||
remote = remote,
|
||||
onDismiss = { editingRemote = null },
|
||||
onSave = { name, layout, mode ->
|
||||
editingRemote = null
|
||||
onUpdateRemote(remote.id, name, layout, mode)
|
||||
},
|
||||
onDelete = {
|
||||
editingRemote = null
|
||||
onDeleteRemote(remote.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
SnackbarHost(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.fillMaxSize().padding(bottom = 16.dp),
|
||||
@@ -369,11 +389,10 @@ private fun InfraredManagementScreen(
|
||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||
onSaveDeviceName: (String) -> Unit,
|
||||
onMoveRemote: (VirtualRemoteId, Int) -> Unit,
|
||||
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit,
|
||||
onEditRemote: (VirtualRemote) -> Unit,
|
||||
) {
|
||||
var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) }
|
||||
var reorderRemoteId by remember { mutableStateOf<VirtualRemoteId?>(null) }
|
||||
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
@@ -438,7 +457,7 @@ private fun InfraredManagementScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote })
|
||||
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { onEditRemote(remote) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -466,24 +485,11 @@ private fun InfraredManagementScreen(
|
||||
}
|
||||
Spacer(Modifier.height(24.dp))
|
||||
}
|
||||
editingRemote?.let { remote ->
|
||||
EditRemoteDialog(
|
||||
remote = remote,
|
||||
onDismiss = { editingRemote = null },
|
||||
onSave = { name, layout, mode ->
|
||||
editingRemote = null
|
||||
onUpdateRemote(remote.id, name, layout, mode)
|
||||
},
|
||||
onDelete = {
|
||||
editingRemote = null
|
||||
onDeleteRemote(remote.id)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EditRemoteDialog(
|
||||
private fun EditRemoteScreen(
|
||||
remote: VirtualRemote,
|
||||
onDismiss: () -> Unit,
|
||||
onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit,
|
||||
@@ -491,39 +497,153 @@ private fun EditRemoteDialog(
|
||||
) {
|
||||
var name by remember(remote.id) { mutableStateOf(remote.displayName) }
|
||||
var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) }
|
||||
var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.infrared_edit_remote)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true)
|
||||
Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold)
|
||||
PanelLayoutStyle.entries.forEach { option ->
|
||||
Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = layout == option, onClick = { layout = option })
|
||||
Text(layoutLabel(option))
|
||||
var showDeleteConfirmation by remember(remote.id) { mutableStateOf(false) }
|
||||
val isCustom = remote.type == RemoteDeviceType.CUSTOM
|
||||
val hasChanges = name.trim() != remote.displayName || layout != remote.layoutStyle
|
||||
|
||||
ABoxBackLayer(onBack = onDismiss) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(stringResource(R.string.infrared_edit_remote))
|
||||
Text(
|
||||
remote.displayName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.BACK,
|
||||
contentDescription = stringResource(R.string.infrared_back),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(shadowElevation = 4.dp) {
|
||||
Button(
|
||||
onClick = { onSave(name.trim(), layout, remote.transmissionMode) },
|
||||
enabled = name.isNotBlank() && hasChanges,
|
||||
modifier = Modifier.fillMaxWidth().padding(16.dp).height(48.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_device_save_changes))
|
||||
}
|
||||
}
|
||||
Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold)
|
||||
TransmissionMode.entries.forEach { option ->
|
||||
Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(selected = mode == option, onClick = { mode = option })
|
||||
Text(transmissionLabel(option))
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_edit_identity_title),
|
||||
stringResource(R.string.infrared_edit_identity_desc),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.infrared_remote_name)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
RemoteConfigurationSummary(remote)
|
||||
|
||||
if (isCustom) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_edit_layout_title),
|
||||
stringResource(R.string.infrared_edit_layout_desc),
|
||||
)
|
||||
LearningLayoutOption.entries.forEach { option ->
|
||||
WizardChoice(
|
||||
title = stringResource(option.titleRes),
|
||||
description = stringResource(option.descriptionRes),
|
||||
icon = option.icon,
|
||||
selected = layout == option.layout,
|
||||
onClick = { layout = option.layout },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_edit_library_locked),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_edit_delete_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
stringResource(R.string.infrared_edit_delete_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
TextButton(
|
||||
onClick = { showDeleteConfirmation = true },
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteConfirmation) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteConfirmation = false },
|
||||
title = { Text(stringResource(R.string.infrared_edit_delete_confirm_title)) },
|
||||
text = { Text(stringResource(R.string.infrared_edit_delete_confirm_message, remote.displayName)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDelete) {
|
||||
Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteConfirmation = false }) {
|
||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) {
|
||||
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
|
||||
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
|
||||
RemoteDeviceType.PROJECTOR -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AUDIO -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.SET_TOP_BOX -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.STREAMING_DEVICE -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.LIGHT -> ABoxIconKey.EFFECTS
|
||||
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
|
||||
}
|
||||
|
||||
@@ -541,6 +661,19 @@ private fun transmissionLabel(mode: TransmissionMode): String = when (mode) {
|
||||
TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun remoteTypeLabel(type: RemoteDeviceType): String = when (type) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.PROJECTOR -> stringResource(R.string.infrared_hub_device_type_projector)
|
||||
RemoteDeviceType.AUDIO -> stringResource(R.string.infrared_hub_device_type_audio)
|
||||
RemoteDeviceType.SET_TOP_BOX -> stringResource(R.string.infrared_hub_device_type_set_top_box)
|
||||
RemoteDeviceType.STREAMING_DEVICE -> stringResource(R.string.infrared_hub_device_type_streaming)
|
||||
RemoteDeviceType.LIGHT -> stringResource(R.string.infrared_hub_device_type_light)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VirtualRemoteCard(
|
||||
remote: VirtualRemote,
|
||||
@@ -569,6 +702,11 @@ private fun VirtualRemoteCard(
|
||||
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
|
||||
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
|
||||
RemoteDeviceType.PROJECTOR -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.AUDIO -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.SET_TOP_BOX -> ABoxIconKey.DEVICES
|
||||
RemoteDeviceType.STREAMING_DEVICE -> ABoxIconKey.ACTIVITY
|
||||
RemoteDeviceType.LIGHT -> ABoxIconKey.EFFECTS
|
||||
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
|
||||
}
|
||||
ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp))
|
||||
@@ -672,239 +810,473 @@ private fun InfraredHardwareSummary(
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CreateVirtualRemoteDialog(
|
||||
private fun CreateVirtualRemoteFlow(
|
||||
slotQuota: SlotQuotaSummary,
|
||||
onTestPreset: (String, (Boolean) -> Unit) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit,
|
||||
) {
|
||||
var step by remember { mutableStateOf(CreateRemoteStep.SOURCE) }
|
||||
var source by remember { mutableStateOf<CreateRemoteSource?>(null) }
|
||||
var name by remember { mutableStateOf("") }
|
||||
var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) }
|
||||
var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) }
|
||||
var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) }
|
||||
var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) }
|
||||
var testState by remember { mutableStateOf(PresetTestState.NOT_TESTED) }
|
||||
val isLearning = source == CreateRemoteSource.LEARNING
|
||||
val effectiveType = if (isLearning) RemoteDeviceType.CUSTOM else selectedType
|
||||
val defaultName = if (isLearning) {
|
||||
stringResource(R.string.infrared_create_default_custom_name)
|
||||
} else {
|
||||
selectedBrand.displayName
|
||||
}
|
||||
val finalName = name.trim().ifBlank { defaultName }
|
||||
val canCreate = !isLearning || !slotQuota.isStaticFull
|
||||
val requiresPresetTest = !isLearning && selectedType != RemoteDeviceType.AIR_CONDITIONER
|
||||
val presetConfirmed = !requiresPresetTest || testState == PresetTestState.CONFIRMED
|
||||
|
||||
var typeDropdownExpanded by remember { mutableStateOf(false) }
|
||||
var brandDropdownExpanded by remember { mutableStateOf(false) }
|
||||
var layoutDropdownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// 1. 设备类型选择
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = typeDropdownExpanded,
|
||||
onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = when (selectedType) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
},
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.infrared_hub_device_type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = typeDropdownExpanded,
|
||||
onDismissRequest = { typeDropdownExpanded = false },
|
||||
ABoxBackLayer(onBack = {
|
||||
step = when (step) {
|
||||
CreateRemoteStep.SOURCE -> return@ABoxBackLayer onDismiss()
|
||||
CreateRemoteStep.OPTIONS -> CreateRemoteStep.SOURCE
|
||||
CreateRemoteStep.DETAILS -> CreateRemoteStep.OPTIONS
|
||||
}
|
||||
}) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(stringResource(R.string.infrared_create_title))
|
||||
Text(
|
||||
stringResource(R.string.infrared_create_step, step.ordinal + 1, 3),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
ABoxIconButton(
|
||||
key = ABoxIconKey.BACK,
|
||||
contentDescription = stringResource(R.string.infrared_back),
|
||||
onClick = {
|
||||
step = when (step) {
|
||||
CreateRemoteStep.SOURCE -> return@ABoxIconButton onDismiss()
|
||||
CreateRemoteStep.OPTIONS -> CreateRemoteStep.SOURCE
|
||||
CreateRemoteStep.DETAILS -> CreateRemoteStep.OPTIONS
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) }
|
||||
},
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
Surface(shadowElevation = 4.dp) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
RemoteDeviceType.entries.forEach { type ->
|
||||
val label = when (type) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
if (step != CreateRemoteStep.SOURCE) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
step = if (step == CreateRemoteStep.DETAILS) CreateRemoteStep.OPTIONS else CreateRemoteStep.SOURCE
|
||||
},
|
||||
modifier = Modifier.height(48.dp),
|
||||
) { Text(stringResource(R.string.infrared_back)) }
|
||||
}
|
||||
Button(
|
||||
onClick = {
|
||||
when (step) {
|
||||
CreateRemoteStep.SOURCE -> step = CreateRemoteStep.OPTIONS
|
||||
CreateRemoteStep.OPTIONS -> step = CreateRemoteStep.DETAILS
|
||||
CreateRemoteStep.DETAILS -> {
|
||||
onConfirm(
|
||||
finalName,
|
||||
effectiveType,
|
||||
if (isLearning) "custom_blank" else selectedBrand.id,
|
||||
if (isLearning) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH,
|
||||
if (isLearning) selectedCustomLayout else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
enabled = when (step) {
|
||||
CreateRemoteStep.SOURCE -> source != null
|
||||
CreateRemoteStep.OPTIONS -> source != null
|
||||
CreateRemoteStep.DETAILS -> canCreate && finalName.isNotBlank() && presetConfirmed
|
||||
},
|
||||
modifier = Modifier.weight(1f).height(48.dp).testTag("create_remote_confirm_button"),
|
||||
) {
|
||||
Text(
|
||||
if (step == CreateRemoteStep.DETAILS) stringResource(R.string.infrared_create_action)
|
||||
else stringResource(R.string.infrared_create_continue),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
when (step) {
|
||||
CreateRemoteStep.SOURCE -> {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_source_title),
|
||||
stringResource(R.string.infrared_create_source_desc),
|
||||
)
|
||||
WizardChoice(
|
||||
title = stringResource(R.string.infrared_create_library_title),
|
||||
description = stringResource(R.string.infrared_create_library_desc),
|
||||
icon = ABoxIconKey.ADD,
|
||||
selected = source == CreateRemoteSource.LIBRARY,
|
||||
onClick = {
|
||||
source = CreateRemoteSource.LIBRARY
|
||||
selectedType = RemoteDeviceType.TV
|
||||
selectedBrand = PresetCodeLibrary.availablePresets.first { it.deviceType == RemoteDeviceType.TV }
|
||||
testState = PresetTestState.NOT_TESTED
|
||||
},
|
||||
)
|
||||
WizardChoice(
|
||||
title = stringResource(R.string.infrared_create_learning_title),
|
||||
description = stringResource(R.string.infrared_create_learning_desc),
|
||||
icon = ABoxIconKey.INFRARED,
|
||||
selected = source == CreateRemoteSource.LEARNING,
|
||||
onClick = { source = CreateRemoteSource.LEARNING },
|
||||
)
|
||||
}
|
||||
CreateRemoteStep.OPTIONS -> if (isLearning) {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_layout_title),
|
||||
stringResource(R.string.infrared_create_layout_desc),
|
||||
)
|
||||
LearningLayoutOption.entries.forEach { option ->
|
||||
WizardChoice(
|
||||
title = stringResource(option.titleRes),
|
||||
description = stringResource(option.descriptionRes),
|
||||
icon = option.icon,
|
||||
selected = selectedCustomLayout == option.layout,
|
||||
onClick = { selectedCustomLayout = option.layout },
|
||||
)
|
||||
}
|
||||
SlotAvailabilityCard(slotQuota)
|
||||
} else {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_device_title),
|
||||
stringResource(R.string.infrared_create_device_desc),
|
||||
)
|
||||
PresetCodeLibrary.availablePresets
|
||||
.map { it.deviceType }
|
||||
.filter { it != RemoteDeviceType.CUSTOM }
|
||||
.distinct()
|
||||
.forEach { type ->
|
||||
WizardChoice(
|
||||
title = remoteTypeLabel(type),
|
||||
description = remoteTypeDescription(type),
|
||||
icon = remoteIcon(type),
|
||||
selected = selectedType == type,
|
||||
onClick = {
|
||||
selectedType = type
|
||||
val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type }
|
||||
if (matched != null) selectedBrand = matched
|
||||
if (type == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH
|
||||
} else if (type == RemoteDeviceType.CUSTOM) {
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS
|
||||
selectedBrand = PresetCodeLibrary.availablePresets.first { it.deviceType == type }
|
||||
testState = PresetTestState.NOT_TESTED
|
||||
},
|
||||
)
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.infrared_create_preset_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }.forEach { preset ->
|
||||
WizardChoice(
|
||||
title = preset.displayName,
|
||||
description = stringResource(R.string.infrared_create_preset_desc),
|
||||
icon = ABoxIconKey.ADD,
|
||||
selected = selectedBrand.id == preset.id,
|
||||
onClick = {
|
||||
selectedBrand = preset
|
||||
testState = PresetTestState.NOT_TESTED
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
CreateRemoteStep.DETAILS -> {
|
||||
WizardHeading(
|
||||
stringResource(R.string.infrared_create_name_title),
|
||||
stringResource(R.string.infrared_create_name_desc),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text(stringResource(R.string.infrared_remote_name)) },
|
||||
placeholder = { Text(defaultName) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
||||
)
|
||||
CreateRemoteSummary(
|
||||
source = source,
|
||||
type = effectiveType,
|
||||
presetName = selectedBrand.displayName,
|
||||
layout = selectedCustomLayout,
|
||||
finalName = finalName,
|
||||
)
|
||||
if (requiresPresetTest) {
|
||||
PresetTestCard(
|
||||
state = testState,
|
||||
onTest = {
|
||||
testState = PresetTestState.TESTING
|
||||
onTestPreset(selectedBrand.id) { successful ->
|
||||
testState = if (successful) PresetTestState.WAITING_CONFIRMATION else PresetTestState.FAILED
|
||||
}
|
||||
typeDropdownExpanded = false
|
||||
},
|
||||
onConfirmed = { testState = PresetTestState.CONFIRMED },
|
||||
onFailed = { testState = PresetTestState.NOT_TESTED },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 遥控器名称输入
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("遥控器名称(如:电视、我的遥控器)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
||||
)
|
||||
|
||||
// 3. 自定义遥控器时:选择初始布局模板
|
||||
if (selectedType == RemoteDeviceType.CUSTOM) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = layoutDropdownExpanded,
|
||||
onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = when (selectedCustomLayout) {
|
||||
PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)"
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)"
|
||||
PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)"
|
||||
PanelLayoutStyle.AC_COMPACT -> "空调布局"
|
||||
},
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("选择初始布局模板") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = layoutDropdownExpanded,
|
||||
onDismissRequest = { layoutDropdownExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("电视导航布局(内置方向盘、音量、频道)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.TV_NAV
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("媒体播放器布局(播放、快进、切歌)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("自由网格布局(可自由增删按键)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.GRID
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。",
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (filteredPresets.isNotEmpty()) {
|
||||
// 码库预设设备:选择品牌型号
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = brandDropdownExpanded,
|
||||
onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedBrand.displayName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.infrared_hub_brand_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = brandDropdownExpanded,
|
||||
onDismissRequest = { brandDropdownExpanded = false },
|
||||
) {
|
||||
filteredPresets.forEach { preset ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(preset.displayName) },
|
||||
onClick = {
|
||||
selectedBrand = preset
|
||||
brandDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 传输模式选择(仅对非空调、非纯自定义设备可选)
|
||||
if (selectedType != RemoteDeviceType.AIR_CONDITIONER && selectedType != RemoteDeviceType.CUSTOM) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
||||
) {
|
||||
RadioButton(
|
||||
selected = transmissionMode == TransmissionMode.STATIC_SLOTS,
|
||||
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
||||
)
|
||||
Column {
|
||||
Text("静态独占槽位模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
||||
) {
|
||||
RadioButton(
|
||||
selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH,
|
||||
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
||||
)
|
||||
Column {
|
||||
Text("动态写码模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。",
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
if (isLearning) SlotAvailabilityCard(slotQuota)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val finalName = if (name.isNotBlank()) name.trim() else if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName
|
||||
val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null
|
||||
onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class CreateRemoteStep { SOURCE, OPTIONS, DETAILS }
|
||||
private enum class CreateRemoteSource { LIBRARY, LEARNING }
|
||||
private enum class PresetTestState { NOT_TESTED, TESTING, WAITING_CONFIRMATION, CONFIRMED, FAILED }
|
||||
|
||||
private enum class LearningLayoutOption(
|
||||
val layout: PanelLayoutStyle,
|
||||
val titleRes: Int,
|
||||
val descriptionRes: Int,
|
||||
val icon: ABoxIconKey,
|
||||
) {
|
||||
TV(PanelLayoutStyle.TV_NAV, R.string.infrared_layout_tv, R.string.infrared_layout_tv_desc, ABoxIconKey.DEVICES),
|
||||
MEDIA(PanelLayoutStyle.MEDIA_PLAYER, R.string.infrared_layout_media, R.string.infrared_layout_media_desc, ABoxIconKey.ACTIVITY),
|
||||
GRID(PanelLayoutStyle.GRID, R.string.infrared_layout_grid, R.string.infrared_layout_grid_desc, ABoxIconKey.DEVICES),
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WizardHeading(title: String, description: String) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
|
||||
Text(description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WizardChoice(
|
||||
title: String,
|
||||
description: String,
|
||||
icon: ABoxIconKey,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
border = BorderStroke(
|
||||
width = if (selected) 2.dp else 1.dp,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant,
|
||||
) {
|
||||
ABoxIcon(
|
||||
key = icon,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.padding(10.dp).size(24.dp),
|
||||
)
|
||||
}
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
RadioButton(selected = selected, onClick = onClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SlotAvailabilityCard(quota: SlotQuotaSummary) {
|
||||
val available = quota.remainingStaticCount
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (available > 0) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_create_slots_available, available),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (available > 0) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Text(
|
||||
if (available > 0) stringResource(R.string.infrared_create_slots_desc)
|
||||
else stringResource(R.string.infrared_create_slots_full),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (available > 0) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PresetTestCard(
|
||||
state: PresetTestState,
|
||||
onTest: () -> Unit,
|
||||
onConfirmed: () -> Unit,
|
||||
onFailed: () -> Unit,
|
||||
) {
|
||||
val successful = state == PresetTestState.CONFIRMED
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = when (state) {
|
||||
PresetTestState.CONFIRMED -> MaterialTheme.colorScheme.secondaryContainer
|
||||
PresetTestState.FAILED -> MaterialTheme.colorScheme.errorContainer
|
||||
else -> MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.infrared_preset_test_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
when (state) {
|
||||
PresetTestState.NOT_TESTED -> stringResource(R.string.infrared_preset_test_desc)
|
||||
PresetTestState.TESTING -> stringResource(R.string.infrared_preset_test_sending)
|
||||
PresetTestState.WAITING_CONFIRMATION -> stringResource(R.string.infrared_preset_test_confirm_prompt)
|
||||
PresetTestState.CONFIRMED -> stringResource(R.string.infrared_preset_test_confirmed)
|
||||
PresetTestState.FAILED -> stringResource(R.string.infrared_preset_test_failed)
|
||||
},
|
||||
modifier = Modifier.testTag("create_remote_confirm_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_dialog_confirm))
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when {
|
||||
successful -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
state == PresetTestState.FAILED -> MaterialTheme.colorScheme.onErrorContainer
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
when (state) {
|
||||
PresetTestState.NOT_TESTED, PresetTestState.FAILED -> Button(
|
||||
onClick = onTest,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { Text(stringResource(R.string.infrared_preset_test_action)) }
|
||||
PresetTestState.TESTING -> Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||
Text(stringResource(R.string.infrared_preset_test_sending), modifier = Modifier.padding(start = 10.dp))
|
||||
}
|
||||
PresetTestState.WAITING_CONFIRMATION -> Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
TextButton(onClick = onFailed, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.infrared_preset_test_no_response))
|
||||
}
|
||||
Button(onClick = onConfirmed, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.infrared_preset_test_responded))
|
||||
}
|
||||
}
|
||||
PresetTestState.CONFIRMED -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CreateRemoteSummary(
|
||||
source: CreateRemoteSource?,
|
||||
type: RemoteDeviceType,
|
||||
presetName: String,
|
||||
layout: PanelLayoutStyle,
|
||||
finalName: String,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.infrared_create_summary_title), fontWeight = FontWeight.Bold)
|
||||
SummaryRow(stringResource(R.string.infrared_remote_name), finalName)
|
||||
SummaryRow(
|
||||
stringResource(R.string.infrared_create_source_label),
|
||||
if (source == CreateRemoteSource.LEARNING) stringResource(R.string.infrared_create_learning_title)
|
||||
else stringResource(R.string.infrared_create_library_title),
|
||||
)
|
||||
if (source == CreateRemoteSource.LEARNING) {
|
||||
SummaryRow(stringResource(R.string.infrared_layout), layoutLabel(layout))
|
||||
} else {
|
||||
SummaryRow(stringResource(R.string.infrared_hub_device_type_label), remoteTypeLabel(type))
|
||||
SummaryRow(stringResource(R.string.infrared_hub_brand_label), presetName)
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SummaryRow(label: String, value: String) {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(label, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text(value, fontWeight = FontWeight.Medium, modifier = Modifier.padding(start = 16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun remoteTypeDescription(type: RemoteDeviceType): String = when (type) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_create_device_tv_desc)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_create_device_ac_desc)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_create_device_fan_desc)
|
||||
RemoteDeviceType.PROJECTOR -> stringResource(R.string.infrared_create_device_projector_desc)
|
||||
RemoteDeviceType.AUDIO -> stringResource(R.string.infrared_create_device_audio_desc)
|
||||
RemoteDeviceType.SET_TOP_BOX -> stringResource(R.string.infrared_create_device_set_top_box_desc)
|
||||
RemoteDeviceType.STREAMING_DEVICE -> stringResource(R.string.infrared_create_device_streaming_desc)
|
||||
RemoteDeviceType.LIGHT -> stringResource(R.string.infrared_create_device_light_desc)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_create_learning_desc)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RemoteConfigurationSummary(remote: VirtualRemote) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text(stringResource(R.string.infrared_edit_configuration_title), fontWeight = FontWeight.Bold)
|
||||
SummaryRow(stringResource(R.string.infrared_hub_device_type_label), remoteTypeLabel(remote.type))
|
||||
if (remote.type == RemoteDeviceType.CUSTOM) {
|
||||
SummaryRow(
|
||||
stringResource(R.string.infrared_create_source_label),
|
||||
stringResource(R.string.infrared_create_learning_title),
|
||||
)
|
||||
} else {
|
||||
SummaryRow(
|
||||
stringResource(R.string.infrared_hub_brand_label),
|
||||
remote.brandModel ?: stringResource(R.string.infrared_custom),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
SummaryRow(stringResource(R.string.infrared_transmission_mode), transmissionLabel(remote.transmissionMode))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -65,6 +65,7 @@ class InfraredHubViewModel @Inject constructor(
|
||||
private val addVirtualRemoteKey: AddVirtualRemoteKey,
|
||||
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
|
||||
private val activityWriter: ActivityEventWriter,
|
||||
private val testPresetInfraredCode: com.flagship.abox.manager.domain.infrared.TestPresetInfraredCode? = null,
|
||||
) : ViewModel() {
|
||||
|
||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||
@@ -244,6 +245,15 @@ class InfraredHubViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun testPreset(presetId: String, onResult: (Boolean) -> Unit) {
|
||||
val currentProfile = profileId ?: return onResult(false)
|
||||
val tester = testPresetInfraredCode ?: return onResult(false)
|
||||
val currentDevice = state.value.savedDeviceName
|
||||
viewModelScope.launch {
|
||||
onResult(tester(currentProfile, currentDevice, presetId) is DomainResult.Success)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRemote(remoteId: VirtualRemoteId) {
|
||||
selectedRemoteIdFlow.value = remoteId
|
||||
localAcStateFlow.value = null
|
||||
|
||||
@@ -112,7 +112,12 @@
|
||||
<string name="infrared_hub_raw_keys_button">硬件槽位与底层按键管理</string>
|
||||
<string name="infrared_hub_device_type_tv">电视机 / 电视盒子</string>
|
||||
<string name="infrared_hub_device_type_ac">空调设备</string>
|
||||
<string name="infrared_hub_device_type_fan">🌀 电风扇 / 空气净化器</string>
|
||||
<string name="infrared_hub_device_type_fan">电风扇 / 空气净化器</string>
|
||||
<string name="infrared_hub_device_type_projector">投影仪</string>
|
||||
<string name="infrared_hub_device_type_audio">音响 / 功放</string>
|
||||
<string name="infrared_hub_device_type_set_top_box">机顶盒</string>
|
||||
<string name="infrared_hub_device_type_streaming">流媒体设备</string>
|
||||
<string name="infrared_hub_device_type_light">灯具</string>
|
||||
<string name="infrared_hub_device_type_custom">自定义面板</string>
|
||||
<string name="infrared_set_temperature">设定温度</string>
|
||||
<string name="infrared_running_mode">运行模式</string>
|
||||
@@ -189,4 +194,55 @@
|
||||
<string name="infrared_layout">面板布局</string>
|
||||
<string name="infrared_transmission_mode">传输模式</string>
|
||||
<string name="infrared_delete_remote">删除遥控器</string>
|
||||
<string name="infrared_create_title">添加遥控器</string>
|
||||
<string name="infrared_create_step">第 %1$d 步,共 %2$d 步</string>
|
||||
<string name="infrared_create_continue">继续</string>
|
||||
<string name="infrared_create_action">创建遥控器</string>
|
||||
<string name="infrared_create_source_title">你想如何添加遥控器?</string>
|
||||
<string name="infrared_create_source_desc">请选择红外指令的来源。两种方式会进入不同的设置流程。</string>
|
||||
<string name="infrared_create_library_title">从红外码库添加</string>
|
||||
<string name="infrared_create_library_desc">选择设备类型和预设方案,下载后按键可直接使用,无需学习。</string>
|
||||
<string name="infrared_create_learning_title">通过学习创建</string>
|
||||
<string name="infrared_create_learning_desc">创建自定义面板,再使用实体遥控器逐个录制红外指令。</string>
|
||||
<string name="infrared_create_device_title">选择设备与预设方案</string>
|
||||
<string name="infrared_create_device_desc">先选择要控制的设备类型,再选择对应的红外码库方案。</string>
|
||||
<string name="infrared_create_device_tv_desc">包含导航、音量、频道、信号源等常用电视控制</string>
|
||||
<string name="infrared_create_device_ac_desc">包含温度、模式、风速、扫风和电源等状态控制</string>
|
||||
<string name="infrared_create_device_fan_desc">包含电源、风速、摇头、定时和运行模式控制</string>
|
||||
<string name="infrared_create_device_projector_desc">包含电源、信号源、菜单、导航、画面模式和投影控制</string>
|
||||
<string name="infrared_create_device_audio_desc">包含电源、音量、静音、输入源、播放和音效控制</string>
|
||||
<string name="infrared_create_device_set_top_box_desc">包含电源、频道、导航、节目单、菜单和数字按键</string>
|
||||
<string name="infrared_create_device_streaming_desc">包含电源、导航、播放、主页、返回和媒体控制</string>
|
||||
<string name="infrared_create_device_light_desc">包含电源、亮度、颜色、场景、定时和灯效控制</string>
|
||||
<string name="infrared_create_preset_title">红外码库方案</string>
|
||||
<string name="infrared_create_preset_desc">为所选设备下载这套红外指令</string>
|
||||
<string name="infrared_create_layout_title">选择学习面板</string>
|
||||
<string name="infrared_create_layout_desc">面板布局决定开始学习前会预置哪些按键。</string>
|
||||
<string name="infrared_create_name_title">命名并确认</string>
|
||||
<string name="infrared_create_name_desc">设置一个容易识别的名称,并在创建前检查选择结果。</string>
|
||||
<string name="infrared_create_default_custom_name">自定义遥控器</string>
|
||||
<string name="infrared_create_slots_available">剩余 %1$d 个静态槽位</string>
|
||||
<string name="infrared_create_slots_desc">学习遥控器使用静态硬件槽位,录制指令时会逐个分配。</string>
|
||||
<string name="infrared_create_slots_full">没有可用静态槽位。请先删除不再使用的已学习指令。</string>
|
||||
<string name="infrared_create_summary_title">设置摘要</string>
|
||||
<string name="infrared_create_source_label">添加方式</string>
|
||||
<string name="infrared_edit_identity_title">遥控器信息</string>
|
||||
<string name="infrared_edit_identity_desc">修改遥控器列表和自动化指令中显示的名称。</string>
|
||||
<string name="infrared_edit_configuration_title">当前配置</string>
|
||||
<string name="infrared_edit_layout_title">面板布局</string>
|
||||
<string name="infrared_edit_layout_desc">学习创建的遥控器可以切换支持的按键布局,但不会变成码库遥控器。</string>
|
||||
<string name="infrared_edit_library_locked">码库遥控器会保留创建时的设备类型、预设方案、布局和传输方式。如需其他方案,请重新添加一个遥控器。</string>
|
||||
<string name="infrared_edit_delete_title">移除遥控器</string>
|
||||
<string name="infrared_edit_delete_desc">从 App 中删除该遥控器及其保存的按键绑定。</string>
|
||||
<string name="infrared_edit_delete_confirm_title">删除这个遥控器?</string>
|
||||
<string name="infrared_edit_delete_confirm_message">将删除“%1$s”及其按键配置。</string>
|
||||
<string name="infrared_preset_test_title">测试这套码库</string>
|
||||
<string name="infrared_preset_test_desc">保存前先发送电源指令,确认设备是否响应。测试可能会打开或关闭设备。</string>
|
||||
<string name="infrared_preset_test_action">测试电源指令</string>
|
||||
<string name="infrared_preset_test_sending">正在发送测试指令…</string>
|
||||
<string name="infrared_preset_test_confirm_prompt">指令已发送,设备是否有响应?</string>
|
||||
<string name="infrared_preset_test_responded">有响应</string>
|
||||
<string name="infrared_preset_test_no_response">无响应</string>
|
||||
<string name="infrared_preset_test_confirmed">码库已验证,可以创建遥控器。</string>
|
||||
<string name="infrared_preset_test_failed">测试指令发送失败,请检查红外控制器后重试。</string>
|
||||
</resources>
|
||||
|
||||
@@ -112,7 +112,12 @@
|
||||
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
|
||||
<string name="infrared_hub_device_type_tv">Television (TV)</string>
|
||||
<string name="infrared_hub_device_type_ac">Air Conditioner (AC)</string>
|
||||
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
|
||||
<string name="infrared_hub_device_type_fan">Fan / Air Purifier</string>
|
||||
<string name="infrared_hub_device_type_projector">Projector</string>
|
||||
<string name="infrared_hub_device_type_audio">Audio / Receiver</string>
|
||||
<string name="infrared_hub_device_type_set_top_box">Set-top Box</string>
|
||||
<string name="infrared_hub_device_type_streaming">Streaming Device</string>
|
||||
<string name="infrared_hub_device_type_light">Lighting</string>
|
||||
<string name="infrared_hub_device_type_custom">Custom Panel</string>
|
||||
<string name="infrared_set_temperature">Set temperature</string>
|
||||
<string name="infrared_running_mode">Operating mode</string>
|
||||
@@ -189,4 +194,55 @@
|
||||
<string name="infrared_layout">Panel layout</string>
|
||||
<string name="infrared_transmission_mode">Transmission mode</string>
|
||||
<string name="infrared_delete_remote">Delete remote</string>
|
||||
<string name="infrared_create_title">Add remote</string>
|
||||
<string name="infrared_create_step">Step %1$d of %2$d</string>
|
||||
<string name="infrared_create_continue">Continue</string>
|
||||
<string name="infrared_create_action">Create remote</string>
|
||||
<string name="infrared_create_source_title">How do you want to add the remote?</string>
|
||||
<string name="infrared_create_source_desc">Choose where the infrared commands come from. The two methods use different setup flows.</string>
|
||||
<string name="infrared_create_library_title">Add from infrared library</string>
|
||||
<string name="infrared_create_library_desc">Choose a device type and preset. Commands are downloaded and ready to use without learning.</string>
|
||||
<string name="infrared_create_learning_title">Create through learning</string>
|
||||
<string name="infrared_create_learning_desc">Build a custom panel, then record each command from a physical remote in Learn mode.</string>
|
||||
<string name="infrared_create_device_title">Choose the appliance and preset</string>
|
||||
<string name="infrared_create_device_desc">Select the appliance type first, then choose the matching preset library.</string>
|
||||
<string name="infrared_create_device_tv_desc">Navigation, volume, channel, source, and common TV controls</string>
|
||||
<string name="infrared_create_device_ac_desc">State-based temperature, mode, fan, swing, and power controls</string>
|
||||
<string name="infrared_create_device_fan_desc">Power, speed, swing, timer, and operating mode controls</string>
|
||||
<string name="infrared_create_device_projector_desc">Power, input, menu, navigation, picture mode, and projection controls</string>
|
||||
<string name="infrared_create_device_audio_desc">Power, volume, mute, source, playback, and sound controls</string>
|
||||
<string name="infrared_create_device_set_top_box_desc">Power, channel, navigation, guide, menu, and numeric controls</string>
|
||||
<string name="infrared_create_device_streaming_desc">Power, navigation, playback, home, back, and media controls</string>
|
||||
<string name="infrared_create_device_light_desc">Power, brightness, color, scene, timer, and effect controls</string>
|
||||
<string name="infrared_create_preset_title">Preset library</string>
|
||||
<string name="infrared_create_preset_desc">Download this command set for the selected appliance</string>
|
||||
<string name="infrared_create_layout_title">Choose the learning panel</string>
|
||||
<string name="infrared_create_layout_desc">The layout defines which buttons are created before you start recording commands.</string>
|
||||
<string name="infrared_create_name_title">Name and confirm</string>
|
||||
<string name="infrared_create_name_desc">Give the remote a recognizable name and review the setup before creating it.</string>
|
||||
<string name="infrared_create_default_custom_name">Custom remote</string>
|
||||
<string name="infrared_create_slots_available">%1$d static slots available</string>
|
||||
<string name="infrared_create_slots_desc">Learning remotes use static hardware slots. Slots are assigned as commands are recorded.</string>
|
||||
<string name="infrared_create_slots_full">No static slots are available. Remove unused learned commands before creating another learning remote.</string>
|
||||
<string name="infrared_create_summary_title">Setup summary</string>
|
||||
<string name="infrared_create_source_label">Add method</string>
|
||||
<string name="infrared_edit_identity_title">Remote identity</string>
|
||||
<string name="infrared_edit_identity_desc">Change the name shown in the remote list and automation actions.</string>
|
||||
<string name="infrared_edit_configuration_title">Current configuration</string>
|
||||
<string name="infrared_edit_layout_title">Panel layout</string>
|
||||
<string name="infrared_edit_layout_desc">Learning remotes can switch between supported button layouts without becoming library remotes.</string>
|
||||
<string name="infrared_edit_library_locked">Library remotes keep the appliance type, preset, layout, and transmission behavior used when they were created. Create another remote to use a different preset.</string>
|
||||
<string name="infrared_edit_delete_title">Remove remote</string>
|
||||
<string name="infrared_edit_delete_desc">Removes the remote and its saved button bindings from this app.</string>
|
||||
<string name="infrared_edit_delete_confirm_title">Remove this remote?</string>
|
||||
<string name="infrared_edit_delete_confirm_message">“%1$s” and its button configuration will be removed.</string>
|
||||
<string name="infrared_preset_test_title">Test this preset</string>
|
||||
<string name="infrared_preset_test_desc">Before saving, send the preset power command and check whether the appliance responds. The appliance may turn on or off.</string>
|
||||
<string name="infrared_preset_test_action">Test power command</string>
|
||||
<string name="infrared_preset_test_sending">Sending the test command…</string>
|
||||
<string name="infrared_preset_test_confirm_prompt">The command was sent. Did the appliance respond?</string>
|
||||
<string name="infrared_preset_test_responded">Yes, it responded</string>
|
||||
<string name="infrared_preset_test_no_response">No response</string>
|
||||
<string name="infrared_preset_test_confirmed">Preset verified. You can create the remote.</string>
|
||||
<string name="infrared_preset_test_failed">The test command could not be sent. Check the infrared controller and try again.</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user