diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt index 3583952..148040a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt @@ -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> diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 00c4b6f..b8cc1f5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -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( diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt index 71a9957..3ab0136 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -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(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(null) } - var editingRemote by remember { mutableStateOf(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(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)) + } + } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt index 5099ccd..7136f1f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -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 diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index 5c11827..55582e3 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -112,7 +112,12 @@ 硬件槽位与底层按键管理 电视机 / 电视盒子 空调设备 - 🌀 电风扇 / 空气净化器 + 电风扇 / 空气净化器 + 投影仪 + 音响 / 功放 + 机顶盒 + 流媒体设备 + 灯具 自定义面板 设定温度 运行模式 @@ -189,4 +194,55 @@ 面板布局 传输模式 删除遥控器 + 添加遥控器 + 第 %1$d 步,共 %2$d 步 + 继续 + 创建遥控器 + 你想如何添加遥控器? + 请选择红外指令的来源。两种方式会进入不同的设置流程。 + 从红外码库添加 + 选择设备类型和预设方案,下载后按键可直接使用,无需学习。 + 通过学习创建 + 创建自定义面板,再使用实体遥控器逐个录制红外指令。 + 选择设备与预设方案 + 先选择要控制的设备类型,再选择对应的红外码库方案。 + 包含导航、音量、频道、信号源等常用电视控制 + 包含温度、模式、风速、扫风和电源等状态控制 + 包含电源、风速、摇头、定时和运行模式控制 + 包含电源、信号源、菜单、导航、画面模式和投影控制 + 包含电源、音量、静音、输入源、播放和音效控制 + 包含电源、频道、导航、节目单、菜单和数字按键 + 包含电源、导航、播放、主页、返回和媒体控制 + 包含电源、亮度、颜色、场景、定时和灯效控制 + 红外码库方案 + 为所选设备下载这套红外指令 + 选择学习面板 + 面板布局决定开始学习前会预置哪些按键。 + 命名并确认 + 设置一个容易识别的名称,并在创建前检查选择结果。 + 自定义遥控器 + 剩余 %1$d 个静态槽位 + 学习遥控器使用静态硬件槽位,录制指令时会逐个分配。 + 没有可用静态槽位。请先删除不再使用的已学习指令。 + 设置摘要 + 添加方式 + 遥控器信息 + 修改遥控器列表和自动化指令中显示的名称。 + 当前配置 + 面板布局 + 学习创建的遥控器可以切换支持的按键布局,但不会变成码库遥控器。 + 码库遥控器会保留创建时的设备类型、预设方案、布局和传输方式。如需其他方案,请重新添加一个遥控器。 + 移除遥控器 + 从 App 中删除该遥控器及其保存的按键绑定。 + 删除这个遥控器? + 将删除“%1$s”及其按键配置。 + 测试这套码库 + 保存前先发送电源指令,确认设备是否响应。测试可能会打开或关闭设备。 + 测试电源指令 + 正在发送测试指令… + 指令已发送,设备是否有响应? + 有响应 + 无响应 + 码库已验证,可以创建遥控器。 + 测试指令发送失败,请检查红外控制器后重试。 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index a93e5c1..1ebca57 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -112,7 +112,12 @@ Hardware Slot / Raw Key Management Television (TV) Air Conditioner (AC) - 🌀 Fan / Air Purifier + Fan / Air Purifier + Projector + Audio / Receiver + Set-top Box + Streaming Device + Lighting Custom Panel Set temperature Operating mode @@ -189,4 +194,55 @@ Panel layout Transmission mode Delete remote + Add remote + Step %1$d of %2$d + Continue + Create remote + How do you want to add the remote? + Choose where the infrared commands come from. The two methods use different setup flows. + Add from infrared library + Choose a device type and preset. Commands are downloaded and ready to use without learning. + Create through learning + Build a custom panel, then record each command from a physical remote in Learn mode. + Choose the appliance and preset + Select the appliance type first, then choose the matching preset library. + Navigation, volume, channel, source, and common TV controls + State-based temperature, mode, fan, swing, and power controls + Power, speed, swing, timer, and operating mode controls + Power, input, menu, navigation, picture mode, and projection controls + Power, volume, mute, source, playback, and sound controls + Power, channel, navigation, guide, menu, and numeric controls + Power, navigation, playback, home, back, and media controls + Power, brightness, color, scene, timer, and effect controls + Preset library + Download this command set for the selected appliance + Choose the learning panel + The layout defines which buttons are created before you start recording commands. + Name and confirm + Give the remote a recognizable name and review the setup before creating it. + Custom remote + %1$d static slots available + Learning remotes use static hardware slots. Slots are assigned as commands are recorded. + No static slots are available. Remove unused learned commands before creating another learning remote. + Setup summary + Add method + Remote identity + Change the name shown in the remote list and automation actions. + Current configuration + Panel layout + Learning remotes can switch between supported button layouts without becoming library remotes. + Library remotes keep the appliance type, preset, layout, and transmission behavior used when they were created. Create another remote to use a different preset. + Remove remote + Removes the remote and its saved button bindings from this app. + Remove this remote? + “%1$s” and its button configuration will be removed. + Test this preset + Before saving, send the preset power command and check whether the appliance responds. The appliance may turn on or off. + Test power command + Sending the test command… + The command was sent. Did the appliance respond? + Yes, it responded + No response + Preset verified. You can create the remote. + The test command could not be sent. Check the infrared controller and try again. diff --git a/docs/third-party/flipper-irdb.md b/docs/third-party/flipper-irdb.md new file mode 100644 index 0000000..b8dfd77 --- /dev/null +++ b/docs/third-party/flipper-irdb.md @@ -0,0 +1,19 @@ +# Flipper-IRDB attribution + +The built-in open-source infrared presets in this project are derived from selected files in: + +- Project: Flipper-IRDB +- Upstream: https://github.com/Lucaslhm/Flipper-IRDB +- License: CC0 1.0 Universal +- Imported signal formats: parsed NEC and NECext only + +Raw pulse data and protocols other than NEC/NECext are intentionally excluded because the ABox gateway's `irCode` encoding contract is not publicly specified for those formats. + +Each imported preset retains its upstream relative `sourcePath` in `OpenSourceIrCatalog.kt` for traceability. The source data was converted to the four-byte hexadecimal representation already used by the ABox application: + +- NEC: address, inverted address, command, inverted command +- NECext: extended address byte 1, extended address byte 2, command byte 1, command byte 2 + +Flipper-IRDB is dedicated to the public domain under CC0 1.0. The full license text is available at: + +https://creativecommons.org/publicdomain/zero/1.0/legalcode diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/OpenSourceIrCatalog.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/OpenSourceIrCatalog.kt new file mode 100644 index 0000000..be56aef --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/OpenSourceIrCatalog.kt @@ -0,0 +1,77 @@ +package com.flagship.abox.manager.domain.infrared + +internal data class OpenSourceIrPreset( + val option: PresetBrandOption, + val buttons: List, +) + +internal object OpenSourceIrCatalog { + val presets = listOf( + preset("firetv_omni_4k", "Amazon Fire TV Omni 4K", RemoteDeviceType.TV, "TVs/Amazon/FireTV_Omni_Series_4K.ir", + "POWER" to "电源" to "0x027D46B9", "HOME" to "主页" to "0x027D9F60", "MENU" to "菜单" to "0x027D45BA", + "BACK" to "返回" to "0x027D0DF2", "VOL_UP" to "音量 +" to "0x027D0CF3", "VOL_DOWN" to "音量 -" to "0x027D19E6", + "MUTE" to "静音" to "0x027D4CB3", "UP" to "上" to "0x027D48B7", "DOWN" to "下" to "0x027D4DB2", + "LEFT" to "左" to "0x027D4EB1", "RIGHT" to "右" to "0x027D49B6", "OK" to "确定" to "0x027D4AB5"), + preset("akai_ate_22y604w", "Akai ATE-22Y604W", RemoteDeviceType.TV, "TVs/Akai/AKAI_ATE_22Y604W.ir", + "POWER" to "电源" to "0x01721EE1", "MUTE" to "静音" to "0x01725CA3", "VOL_UP" to "音量 +" to "0x01720AF5", + "VOL_DOWN" to "音量 -" to "0x017206F9", "CH_UP" to "频道 +" to "0x017248B7", "CH_DOWN" to "频道 -" to "0x017244BB", + "SOURCE" to "信号源" to "0x017207F8", "OK" to "确定" to "0x01725FA0", "UP" to "上" to "0x017203FC", "DOWN" to "下" to "0x01725EA1"), + preset("acer_h6517st", "Acer H6517ST", RemoteDeviceType.PROJECTOR, "Projectors/Acer/Acer_H6517ST.ir", + "POWER" to "电源" to "0x08138778", "SOURCE" to "信号源" to "0x08138C73", "SETUP" to "设置" to "0x08133BC4", + "MODE" to "模式" to "0x0813807F", "ZOOM" to "缩放" to "0x08138B74", "MENU" to "菜单" to "0x0813847B", + "UP" to "上" to "0x0813827D", "DOWN" to "下" to "0x0813857A", "LEFT" to "左" to "0x0813837C", "RIGHT" to "右" to "0x0813817E"), + preset("nebula_capsule", "Anker Nebula Capsule", RemoteDeviceType.PROJECTOR, "Projectors/Anker/Nebula_Capsule_Mini_Projector.ir", + "POWER" to "电源" to "0x807F51AE", "OK" to "确定" to "0x807F0DF2", "UP" to "上" to "0x807F26D9", + "DOWN" to "下" to "0x807F28D7", "LEFT" to "左" to "0x807F25DA", "RIGHT" to "右" to "0x807F27D8", + "MENU" to "菜单" to "0x807F49B6", "BACK" to "返回" to "0x807F1BE4", "HOME" to "主页" to "0x807F53AC"), + preset("bose_solo_5", "Bose Solo 5", RemoteDeviceType.AUDIO, "SoundBars/Bose/Bose_Solo_5.ir", + "POWER" to "电源" to "0xBAA04CB3", "MUTE" to "静音" to "0xBAA001FE", "VOL_UP" to "音量 +" to "0xBAA003FC", + "VOL_DOWN" to "音量 -" to "0xBAA002FD", "PLAY_PAUSE" to "播放/暂停" to "0xBAA055AA", "NEXT" to "下一曲" to "0xBAA05AA5", + "PREVIOUS" to "上一曲" to "0xBAA059A6", "BLUETOOTH" to "蓝牙" to "0xBAA0817E", "BASS" to "低音" to "0xBAA0827D"), + preset("fosi_zd3", "Fosi Audio ZD3", RemoteDeviceType.AUDIO, "Audio_and_Video_Receivers/Fosi_Audio/Fosi_Audio_ZD3.ir", + "POWER" to "电源" to "0xFA0105FA", "MUTE" to "静音" to "0xFA0102FD", "VOL_UP" to "音量 +" to "0xFA0103FC", + "VOL_DOWN" to "音量 -" to "0xFA0107F8", "PLAY_PAUSE" to "播放/暂停" to "0xFA010AF5", "NEXT" to "下一曲" to "0xFA0106F9", + "PREVIOUS" to "上一曲" to "0xFA0104FB", "OPTICAL" to "光纤" to "0xFA0108F7", "HDMI" to "HDMI" to "0xFA0113EC", "BLUETOOTH" to "蓝牙" to "0xFA0115EA"), + preset("humax_generic", "Humax Cable Box", RemoteDeviceType.SET_TOP_BOX, "Cable_Boxes/Humax/Humax_generic_cable_box.ir", + "POWER" to "电源" to "0x001000FF", "TV_RADIO" to "电视/广播" to "0x00100DF2", "SOURCE" to "信号源" to "0x001002FD", + "VOL_UP" to "音量 +" to "0x00101FE0", "VOL_DOWN" to "音量 -" to "0x001040BF", "CH_UP" to "频道 +" to "0x001010EF", "CH_DOWN" to "频道 -" to "0x00100FF0"), + preset("huawei_dn300", "Huawei DN300", RemoteDeviceType.SET_TOP_BOX, "Cable_Boxes/Huawei/Huawei_dn300_mgts(mts).ir", + "POWER" to "电源" to "0x00BF43BC", "VOL_UP" to "音量 +" to "0x00BF52AD", "VOL_DOWN" to "音量 -" to "0x00BF53AC", + "CH_UP" to "频道 +" to "0x00BF4BB4", "CH_DOWN" to "频道 -" to "0x00BF4CB3", "UP" to "上" to "0x00BF15EA", + "DOWN" to "下" to "0x00BF16E9", "LEFT" to "左" to "0x00BF17E8", "RIGHT" to "右" to "0x00BF18E7", "BACK" to "返回" to "0x00BF51AE", "OK" to "确定" to "0x00BF19E6"), + preset("firetv_stick_4k_max", "Amazon Fire TV Stick 4K Max", RemoteDeviceType.STREAMING_DEVICE, "Streaming_Devices/Amazon/FireTV_Stick_4K_Max.ir", + "POWER" to "电源" to "0x03FC1DE2", "MUTE" to "静音" to "0x007F4EB1", "VOL_UP" to "音量 +" to "0x03FC11EE", "VOL_DOWN" to "音量 -" to "0x03FC15EA"), + preset("apple_tv_4k_gen2", "Apple TV 4K Gen 2", RemoteDeviceType.STREAMING_DEVICE, "Streaming_Devices/Apple/Apple_TV_4K_Gen2.ir", + "POWER_OFF" to "关机" to "0xE5872AFE", "POWER_ON" to "开机" to "0xE5872CFE", "TV" to "电视" to "0xE58740FE", + "OK" to "确定" to "0xEE875C8C", "UP" to "上" to "0xEE870A8C", "DOWN" to "下" to "0xEE870C8C", + "LEFT" to "左" to "0xEE87098C", "RIGHT" to "右" to "0xEE87068C", "BACK" to "返回" to "0xEE87038C", "PLAY_PAUSE" to "播放/暂停" to "0xEE875F8C"), + preset("atomberg_fan", "Atomberg Fan", RemoteDeviceType.FAN, "Fans/Atomberg/Atomberg_fan.ir", + "POWER" to "电源" to "0x00F3916E", "SPEED_1" to "风速 1" to "0x00F38B74", "SPEED_2" to "风速 2" to "0x00F3906F", + "SPEED_3" to "风速 3" to "0x00F38A75", "SPEED_4" to "风速 4" to "0x00F3936C", "SPEED_5" to "风速 5" to "0x00F38877", + "BOOST" to "强劲" to "0x00F38F70", "TIMER" to "定时" to "0x00F39669", "SLEEP" to "睡眠" to "0x00F38E71"), + preset("daewoo_ddv200", "Daewoo DDV200", RemoteDeviceType.FAN, "Fans/Daewoo/Daewoo_DDV200.ir", + "POWER" to "电源" to "0x00FF45BA", "SPEED" to "风速" to "0x00FF47B8", "WIND" to "风类" to "0x00FF40BF", + "TIMER" to "定时" to "0x00FF19E6", "OSCILLATE" to "摇头" to "0x00FF1CE3"), + preset("adj_uc_ir", "ADJ UC-IR Lighting", RemoteDeviceType.LIGHT, "LED_Lighting/ADJ/ADJ_UC-IR.ir", + "POWER" to "电源" to "0x00FF47B8", "BRIGHTER" to "调亮" to "0x00FF43BC", "DIMMER" to "调暗" to "0x00FF09F6", + "FULL_ON" to "全亮" to "0x00FF44BB", "FADE" to "渐变" to "0x00FF40BF", "STROBE" to "频闪" to "0x00FF07F8", "COLOR" to "颜色" to "0x00FF15EA"), + preset("antizer_candles", "Antizer Flameless Candles", RemoteDeviceType.LIGHT, "LED_Lighting/Antizer_Flameless_Candles/Antizer_Flameless_Candles.ir", + "ON" to "开启" to "0x08B700FF", "OFF" to "关闭" to "0x08B702FD", "DIMMER" to "调暗" to "0x08B710EF", + "BRIGHTER" to "调亮" to "0x08B712ED", "CANDLE" to "烛光模式" to "0x08B70CF3", "LIGHT" to "常亮模式" to "0x08B70EF1"), + ) + + fun preset(id: String?): OpenSourceIrPreset? = presets.firstOrNull { it.option.id == id } + + private fun preset( + id: String, + name: String, + type: RemoteDeviceType, + sourcePath: String, + vararg buttons: Pair, String>, + ) = OpenSourceIrPreset( + PresetBrandOption(id, name, type, sourcePath), + buttons.map { (identity, code) -> PresetCodeLibrary.PresetButtonTemplate(identity.first, identity.second, code) }, + ) + + private infix fun Pair.to(code: String): Pair, String> = Pair(this, code) +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt index 7a521c7..3a75a9e 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt @@ -4,30 +4,21 @@ data class PresetBrandOption( val id: String, val displayName: String, val deviceType: RemoteDeviceType, + val sourcePath: String? = null, ) object PresetCodeLibrary { val availablePresets: List = listOf( - // 电视预设 - PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV), - PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV), - PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV), - PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV), - // 空调预设 PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER), PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER), PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER), PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER), - // 风扇预设 - PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN), - PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN), - // 自定义 PresetBrandOption("custom_blank", "自定义遥控器 (Custom)", RemoteDeviceType.CUSTOM), - ) + ) + OpenSourceIrCatalog.presets.map { it.option } data class PresetButtonTemplate( val buttonId: String, @@ -39,8 +30,8 @@ object PresetCodeLibrary { type: RemoteDeviceType, presetId: String? = null, layoutStyle: PanelLayoutStyle? = null, - ): List = when (type) { - RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true) + ): List = OpenSourceIrCatalog.preset(presetId)?.buttons ?: when (type) { + RemoteDeviceType.TV -> emptyList() RemoteDeviceType.AIR_CONDITIONER -> listOf( PresetButtonTemplate("AC_POWER", "电源"), PresetButtonTemplate("AC_TEMP_UP", "升温"), @@ -51,13 +42,12 @@ object PresetCodeLibrary { PresetButtonTemplate("AC_SWING_H", "左右扫风"), PresetButtonTemplate("AC_ECO", "节能"), ) - RemoteDeviceType.FAN -> listOf( - PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"), - PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"), - PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"), - PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"), - PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"), - ) + RemoteDeviceType.FAN, + RemoteDeviceType.PROJECTOR, + RemoteDeviceType.AUDIO, + RemoteDeviceType.SET_TOP_BOX, + RemoteDeviceType.STREAMING_DEVICE, + RemoteDeviceType.LIGHT -> emptyList() RemoteDeviceType.CUSTOM -> when (layoutStyle) { PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false) PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false) diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/TestPresetInfraredCode.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/TestPresetInfraredCode.kt new file mode 100644 index 0000000..f3eb037 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/TestPresetInfraredCode.kt @@ -0,0 +1,31 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class TestPresetInfraredCode( + private val gateway: InfraredGateway, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + deviceName: DeviceName, + presetId: String, + ): DomainResult { + val preset = OpenSourceIrCatalog.preset(presetId) + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Preset is not testable")) + val testButton = preset.buttons.firstOrNull { button -> + button.buttonId.contains("POWER") || button.buttonId == "ON" || button.buttonId == "OFF" + } ?: return DomainResult.Failure(DomainError.NoData(detail = "Preset has no test button")) + val code = testButton.defaultHexCode + ?: return DomainResult.Failure(DomainError.NoData(detail = "Preset test code is unavailable")) + val scratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT + return when (val download = gateway.downloadCodes(profileId, listOf(InfraredCode(deviceName, scratchSlot, code)))) { + is DomainResult.Failure -> download + is DomainResult.Success -> gateway.sendKey(profileId, deviceName, scratchSlot) + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt index 20d657c..5d9875b 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt @@ -10,6 +10,11 @@ enum class RemoteDeviceType { TV, AIR_CONDITIONER, FAN, + PROJECTOR, + AUDIO, + SET_TOP_BOX, + STREAMING_DEVICE, + LIGHT, CUSTOM, } @@ -70,7 +75,12 @@ data class VirtualRemote( fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) { RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT - RemoteDeviceType.FAN -> PanelLayoutStyle.GRID + RemoteDeviceType.FAN, + RemoteDeviceType.PROJECTOR, + RemoteDeviceType.AUDIO, + RemoteDeviceType.SET_TOP_BOX, + RemoteDeviceType.STREAMING_DEVICE, + RemoteDeviceType.LIGHT, RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID } } diff --git a/tools/infrared/convert_flipper_irdb.ps1 b/tools/infrared/convert_flipper_irdb.ps1 new file mode 100644 index 0000000..6e5b3fe --- /dev/null +++ b/tools/infrared/convert_flipper_irdb.ps1 @@ -0,0 +1,58 @@ +param( + [Parameter(Mandatory = $true)] + [string]$InputFile +) + +$lines = Get-Content -LiteralPath $InputFile +$signals = @() + +function Convert-Signal([string]$Protocol, [string]$Address, [string]$Command) { + $addressBytes = $Address -split '\s+' | Where-Object { $_ } + $commandBytes = $Command -split '\s+' | Where-Object { $_ } + + if ($Protocol -eq 'NEC') { + if ($addressBytes.Count -lt 1 -or $commandBytes.Count -lt 1) { return $null } + $addressValue = [Convert]::ToInt32($addressBytes[0], 16) + $commandValue = [Convert]::ToInt32($commandBytes[0], 16) + return '0x{0:X2}{1:X2}{2:X2}{3:X2}' -f $addressValue, (255 - $addressValue), $commandValue, (255 - $commandValue) + } + + if ($Protocol -eq 'NECext') { + if ($addressBytes.Count -lt 2 -or $commandBytes.Count -lt 2) { return $null } + return '0x{0}{1}{2}{3}' -f $addressBytes[0].ToUpper(), $addressBytes[1].ToUpper(), $commandBytes[0].ToUpper(), $commandBytes[1].ToUpper() + } + + return $null +} + +for ($index = 0; $index -lt $lines.Count; $index++) { + if ($lines[$index] -notmatch '^name:\s*(.+)$') { continue } + + $name = $matches[1].Trim() + $type = '' + $protocol = '' + $address = '' + $command = '' + + for ($lookahead = $index + 1; $lookahead -lt [Math]::Min($index + 8, $lines.Count); $lookahead++) { + if ($lines[$lookahead] -match '^name:' -or $lines[$lookahead] -eq '#') { + if ($lookahead -gt $index + 1) { break } + } + if ($lines[$lookahead] -match '^type:\s*(.+)$') { $type = $matches[1].Trim() } + if ($lines[$lookahead] -match '^protocol:\s*(.+)$') { $protocol = $matches[1].Trim() } + if ($lines[$lookahead] -match '^address:\s*(.+)$') { $address = $matches[1].Trim() } + if ($lines[$lookahead] -match '^command:\s*(.+)$') { $command = $matches[1].Trim() } + } + + if ($type -ne 'parsed' -or $protocol -notin @('NEC', 'NECext')) { continue } + $hexCode = Convert-Signal $protocol $address $command + if ($null -ne $hexCode) { + $signals += [pscustomobject]@{ + name = $name + protocol = $protocol + code = $hexCode + } + } +} + +$signals | ConvertTo-Json -Depth 3