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:
2026-08-28 09:23:06 +08:00
committed by KremeCN
parent 387ecacef5
commit ad0dd2cd5b
12 changed files with 966 additions and 280 deletions
@@ -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>>
@@ -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(
@@ -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))
}
}
}
@@ -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>