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 4498cc8..1ee37cb 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 @@ -1,5 +1,6 @@ package com.flagship.abox.manager.infrared.hub +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -10,19 +11,23 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon +import androidx.compose.material3.FilterChip import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme @@ -31,6 +36,7 @@ import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -42,6 +48,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -51,10 +59,11 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flagship.abox.manager.designsystem.ABoxSurface -import com.flagship.abox.manager.domain.ProviderType -import com.flagship.abox.manager.domain.infrared.PresetBrandOption +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary import com.flagship.abox.manager.domain.infrared.TransmissionMode import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemoteId @@ -74,12 +83,11 @@ fun InfraredHubRoute( InfraredHubScreen( state = state, onBack = onBack, + onNavigateToSettings = onOpenSettings, onOpenRemote = onOpenRemote, - onOpenSettings = onOpenSettings, - onSessionInvalid = onSessionInvalid, - onCreateRemote = viewModel::createRemote, onDeleteRemote = viewModel::deleteRemote, - onSaveDeviceName = viewModel::saveDeviceName, + onCreateRemote = viewModel::createRemote, + onSessionInvalid = onSessionInvalid, onMessageShown = viewModel::clearMessage, ) } @@ -89,12 +97,11 @@ fun InfraredHubRoute( internal fun InfraredHubScreen( state: InfraredHubUiState, onBack: () -> Unit, + onNavigateToSettings: () -> Unit, onOpenRemote: (String) -> Unit, - onOpenSettings: () -> Unit, - onSessionInvalid: () -> Unit, - onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode) -> Unit, onDeleteRemote: (VirtualRemoteId) -> Unit, - onSaveDeviceName: (String) -> Unit, + onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit, + onSessionInvalid: () -> Unit, onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } @@ -131,7 +138,21 @@ internal fun InfraredHubScreen( Scaffold( topBar = { TopAppBar( - title = { Text(stringResource(R.string.infrared_hub_title), fontWeight = FontWeight.Bold) }, + title = { + Column { + Text( + text = stringResource(R.string.infrared_hub_title), + fontWeight = FontWeight.Bold, + ) + state.providerDisplayName?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, navigationIcon = { TextButton(onClick = onBack) { Text(stringResource(R.string.infrared_back)) @@ -139,7 +160,7 @@ internal fun InfraredHubScreen( }, actions = { IconButton( - onClick = onOpenSettings, + onClick = onNavigateToSettings, modifier = Modifier.testTag("hub_settings_button"), ) { Text("⚙️", fontSize = 20.sp) @@ -160,71 +181,20 @@ internal fun InfraredHubScreen( .padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // 1. 顶部环境与 100 槽位看板卡片 - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - val envText = when (state.providerType) { - ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) - ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) - null -> state.profileId?.value ?: "" - } - Text( - text = stringResource(R.string.infrared_mode_label, envText), - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.primary, - ) + // 1. 硬件槽位配额看板 (100 槽位预算) + QuotaSummaryCard(quota = state.slotQuota) - Text( - text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value), - style = MaterialTheme.typography.bodyMedium, - ) - - // 槽位配额指示条 - val progress = state.slotQuota.usedCount.toFloat() / state.slotQuota.totalCapacity - LinearProgressIndicator( - progress = { progress.coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth().height(8.dp), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource( - R.string.infrared_hub_slot_quota, - state.slotQuota.usedCount, - state.slotQuota.totalCapacity, - state.slotQuota.remainingStaticCount, - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = stringResource(R.string.infrared_hub_dynamic_scratch, "100"), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary, - fontWeight = FontWeight.Medium, - ) - } - } - } - - // 2. 虚拟遥控器列表标题栏 + // 2. 遥控器列表标题与新建按钮 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( - text = stringResource(R.string.infrared_hub_remotes_title), - style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + text = stringResource(R.string.infrared_hub_my_remotes), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), ) + Button( onClick = { showCreateDialog = true }, modifier = Modifier.testTag("hub_add_remote_button"), @@ -268,8 +238,8 @@ internal fun InfraredHubScreen( if (showCreateDialog) { CreateVirtualRemoteDialog( onDismiss = { showCreateDialog = false }, - onConfirm = { name, type, brandModel, transMode -> - onCreateRemote(name, type, brandModel, transMode) + onConfirm = { name, type, brandModel, transMode, layoutStyle -> + onCreateRemote(name, type, brandModel, transMode, layoutStyle) showCreateDialog = false }, ) @@ -318,14 +288,65 @@ private fun VirtualRemoteCard( ) val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) { - stringResource(R.string.infrared_hub_mode_dynamic, "100") - } else when (remote.transmissionMode) { - TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_hub_mode_static, "15") - TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_hub_mode_dynamic, "100") + "❄️ 状态机动态写码" + } else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { + "🚀 静态独占槽位" + } else { + "📦 动态写码" } + Text( - text = modeText, - style = MaterialTheme.typography.bodySmall, + text = "${remote.layoutStyle.name} · $modeText", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun QuotaSummaryCard(quota: SlotQuotaSummary) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "硬件槽位配额 (总计 100 槽位)", + fontWeight = FontWeight.Bold, + fontSize = 14.sp, + ) + Text( + text = "${quota.usedCount} / ${quota.totalCapacity} 已占用", + fontWeight = FontWeight.Bold, + color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary, + fontSize = 13.sp, + ) + } + + LinearProgressIndicator( + progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() }, + modifier = Modifier + .fillMaxWidth() + .height(8.dp) + .clip(RoundedCornerShape(4.dp)), + ) + + Text( + text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个", + fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -336,15 +357,17 @@ private fun VirtualRemoteCard( @Composable private fun CreateVirtualRemoteDialog( onDismiss: () -> Unit, - onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, mode: TransmissionMode) -> Unit, + onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit, ) { 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 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 } @@ -393,6 +416,8 @@ private fun CreateVirtualRemoteDialog( if (matched != null) selectedBrand = matched if (type == RemoteDeviceType.AIR_CONDITIONER) { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH + } else if (type == RemoteDeviceType.CUSTOM) { + transmissionMode = TransmissionMode.STATIC_SLOTS } typeDropdownExpanded = false }, @@ -405,13 +430,71 @@ private fun CreateVirtualRemoteDialog( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("遥控器名称(如:客厅电视)") }, + label = { Text("遥控器名称(如:客厅电视、我的遥控器)") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"), ) - // 3. 品牌 / 码库预设 - if (filteredPresets.isNotEmpty()) { + // 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 }, @@ -441,8 +524,8 @@ private fun CreateVirtualRemoteDialog( } } - // 4. 传输模式选择(非空调设备可选) - if (selectedType != RemoteDeviceType.AIR_CONDITIONER) { + // 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) @@ -474,13 +557,13 @@ private fun CreateVirtualRemoteDialog( } } } - } else { + } else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) { Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)), modifier = Modifier.fillMaxWidth(), ) { Text( - text = "❄️ 空调多维状态(温度、扫风、风速)自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", + text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", fontSize = 12.sp, modifier = Modifier.padding(10.dp), color = MaterialTheme.colorScheme.onSecondaryContainer, @@ -492,8 +575,9 @@ private fun CreateVirtualRemoteDialog( confirmButton = { Button( onClick = { - val finalName = if (name.isNotBlank()) name.trim() else selectedBrand.displayName - onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode) + 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) }, modifier = Modifier.testTag("create_remote_confirm_button"), ) { 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 579c57f..bfdad11 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 @@ -126,6 +126,7 @@ class InfraredHubViewModel @Inject constructor( type: RemoteDeviceType, brandModel: String? = null, transmissionMode: TransmissionMode, + layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle? = null, ) { val currentProfile = profileId ?: return val currentDevice = state.value.savedDeviceName @@ -137,6 +138,7 @@ class InfraredHubViewModel @Inject constructor( type = type, brandModel = brandModel, transmissionMode = transmissionMode, + layoutStyle = layoutStyle, )) { is DomainResult.Success -> { messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已成功创建遥控器")) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt index 76f130b..34f4dd6 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -87,11 +87,10 @@ fun RemotePanelRoute( onToggleLearnMode = viewModel::toggleLearnMode, onAddCustomButton = viewModel::addCustomButton, onDeleteButton = viewModel::deleteButton, - onSwitchLayout = viewModel::switchLayout, onToggleAcPower = viewModel::toggleAcPower, onAdjustAcTemp = viewModel::adjustAcTemperature, onSetAcMode = viewModel::setAcMode, - onSetAcFanSpeed = viewModel::setAcFanSpeed, + onSetFanSpeed = viewModel::setAcFanSpeed, onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing, onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing, onToggleAcEco = viewModel::toggleAcEco, @@ -109,11 +108,10 @@ internal fun RemotePanelScreen( onToggleLearnMode: () -> Unit, onAddCustomButton: (String) -> Unit, onDeleteButton: (String) -> Unit, - onSwitchLayout: (PanelLayoutStyle) -> Unit, onToggleAcPower: () -> Unit, onAdjustAcTemp: (Int) -> Unit, onSetAcMode: (AcMode) -> Unit, - onSetAcFanSpeed: (AcFanSpeed) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, onToggleAcVerticalSwing: () -> Unit, onToggleAcHorizontalSwing: () -> Unit, onToggleAcEco: () -> Unit, @@ -123,7 +121,6 @@ internal fun RemotePanelScreen( val context = LocalContext.current var showAddKeyDialog by remember { mutableStateOf(false) } - var showMenu by remember { mutableStateOf(false) } LaunchedEffect(state.message) { state.message?.let { msg -> @@ -163,8 +160,14 @@ internal fun RemotePanelScreen( fontSize = 18.sp, ) state.remote?.let { + val layoutLabel = when (it.layoutStyle) { + PanelLayoutStyle.TV_NAV -> "电视导航布局" + PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放布局" + PanelLayoutStyle.GRID -> "自由网格布局" + PanelLayoutStyle.AC_COMPACT -> "空调全状态布局" + } Text( - text = "布局: ${it.layoutStyle.name} | 模式: ${it.transmissionMode.name}", + text = layoutLabel, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -177,53 +180,18 @@ internal fun RemotePanelScreen( } }, actions = { - // 学习模式开关 - FilterChip( - selected = state.isLearnMode, - onClick = onToggleLearnMode, - label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = Color(0xFFFF5252), - selectedLabelColor = Color.White, - ), - modifier = Modifier.testTag("learn_mode_toggle"), - ) - - // 更多选项菜单(添加按键、切换布局) - Box { - IconButton(onClick = { showMenu = true }, modifier = Modifier.testTag("panel_more_menu")) { - Text("⋮", fontSize = 22.sp, fontWeight = FontWeight.Bold) - } - DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - DropdownMenuItem( - text = { Text("➕ 添加自定义按键") }, - onClick = { - showMenu = false - showAddKeyDialog = true - }, - ) - DropdownMenuItem( - text = { Text("📺 切换为电视导航布局") }, - onClick = { - showMenu = false - onSwitchLayout(PanelLayoutStyle.TV_NAV) - }, - ) - DropdownMenuItem( - text = { Text("🎛️ 切换为网格按键布局") }, - onClick = { - showMenu = false - onSwitchLayout(PanelLayoutStyle.GRID) - }, - ) - DropdownMenuItem( - text = { Text("🎵 切换为媒体播放布局") }, - onClick = { - showMenu = false - onSwitchLayout(PanelLayoutStyle.MEDIA_PLAYER) - }, - ) - } + // 非空调面板显示学习模式开关 + if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) { + FilterChip( + selected = state.isLearnMode, + onClick = onToggleLearnMode, + label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = Color(0xFFFF5252), + selectedLabelColor = Color.White, + ), + modifier = Modifier.testTag("learn_mode_toggle"), + ) } }, ) @@ -256,7 +224,7 @@ internal fun RemotePanelScreen( ) { Text("🔴", fontSize = 16.sp) Text( - text = "学习录制模式已开启:点击下方任意按键,然后将实体遥控器对准转发器按下即可录码", + text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码", color = Color(0xFFC62828), fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -274,7 +242,7 @@ internal fun RemotePanelScreen( onTogglePower = onToggleAcPower, onAdjustTemp = onAdjustAcTemp, onSetMode = onSetAcMode, - onSetFanSpeed = onSetAcFanSpeed, + onSetFanSpeed = onSetFanSpeed, onToggleVSwing = onToggleAcVerticalSwing, onToggleHSwing = onToggleAcHorizontalSwing, onToggleEco = onToggleAcEco, @@ -287,7 +255,6 @@ internal fun RemotePanelScreen( learningButtonId = state.learningButtonId, sendingButtonId = state.sendingButtonId, onButtonClick = onButtonClick, - onDeleteButton = onDeleteButton, ) } PanelLayoutStyle.MEDIA_PLAYER -> { @@ -479,8 +446,9 @@ private fun TvControlPanel( learningButtonId: String?, sendingButtonId: String?, onButtonClick: (String) -> Unit, - onDeleteButton: (String) -> Unit, ) { + val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } } + Column( modifier = Modifier .fillMaxSize() @@ -494,9 +462,9 @@ private fun TvControlPanel( horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { - InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(8.dp)) @@ -556,9 +524,9 @@ private fun TvControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround, ) { - InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } // 底部音量与频道调节柱 @@ -603,6 +571,8 @@ private fun MediaPlayerControlPanel( sendingButtonId: String?, onButtonClick: (String) -> Unit, ) { + val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } } + Column( modifier = Modifier.fillMaxSize().padding(24.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -612,8 +582,8 @@ private fun MediaPlayerControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), isLearnMode, learningButtonId, sendingButtonId, onButtonClick) - InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) + InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick) } Spacer(modifier = Modifier.height(16.dp)) @@ -677,7 +647,7 @@ private fun GenericGridControlPanel( if (bindings.isEmpty()) { Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { - Text("暂无自定义按键,点击上方 ➕ 添加按键", color = MaterialTheme.colorScheme.onSurfaceVariant) + Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant) } } else { LazyVerticalGrid( @@ -713,13 +683,13 @@ private fun GridButtonCard( Card( modifier = Modifier .fillMaxWidth() - .height(72.dp) + .height(76.dp) .clickable(onClick = onClick) .testTag("generic_btn_${binding.buttonId}"), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), ) { Row( - modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -727,17 +697,21 @@ private fun GridButtonCard( Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp) val statusText = when { isLearning -> "🟡 正在录制信号..." - binding.status == InfraredKeyStatus.READY -> "🟢 已录制" - else -> "⚪ 未录制" + binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})" + else -> "⚪ 待录制 (点击录码)" } Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) } if (isSending || isLearning) { CircularProgressIndicator(modifier = Modifier.size(20.dp)) - } else if (isLearnMode) { - IconButton(onClick = onDelete) { - Text("✕", color = Color.Gray, fontSize = 14.sp) + } else { + // 始终提供删除操作按钮 + IconButton( + onClick = onDelete, + modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"), + ) { + Text("🗑️", fontSize = 15.sp) } } } @@ -749,15 +723,23 @@ private fun InteractiveRemoteButton( buttonId: String, label: String, containerColor: Color, + binding: RemoteKeyBinding?, isLearnMode: Boolean, learningButtonId: String?, sendingButtonId: String?, onButtonClick: (String) -> Unit, ) { + val isUnlearned = binding != null && binding.status != InfraredKeyStatus.READY + Button( onClick = { onButtonClick(buttonId) }, colors = ButtonDefaults.buttonColors( - containerColor = if (learningButtonId == buttonId) Color(0xFFFF9800) else containerColor, + containerColor = when { + learningButtonId == buttonId -> Color(0xFFFF9800) + isUnlearned -> MaterialTheme.colorScheme.surfaceVariant + else -> containerColor + }, + contentColor = if (isUnlearned) MaterialTheme.colorScheme.onSurfaceVariant else Color.White, ), modifier = Modifier.testTag("tv_btn_$buttonId"), ) { @@ -792,11 +774,11 @@ private fun AddCustomKeyDialog( title = { Text("添加自定义按键") }, text = { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text("输入按键显示名称,创建后开启右上角【学习模式】即可录制实体信号:", fontSize = 13.sp) + Text("输入按键显示名称,创建后点击按键或开启【学习模式】即可录制实体按键:", fontSize = 13.sp) OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("按键名称(如:静音、音效切换)") }, + label = { Text("按键名称(如:静音、低音增强)") }, singleLine = true, modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"), ) 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 c5be839..ef7d8bd 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 @@ -88,6 +88,7 @@ 按键已从本机删除 按键尚未就绪,无法发射 红外中枢 + 我的遥控器 硬件槽位:%1$d / %2$d 已占用(剩余 %3$d) 静态占用:%1$d 动态缓冲:Slot %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 88004a7..9535083 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -34,6 +34,7 @@ Back Infrared Remote Recording + My Remotes Learn Mode 🔴 Learn mode active: tap any button to record ❄️ Air Conditioner diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt index 3742cf3..2c5428a 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt @@ -133,4 +133,31 @@ class InfraredHubViewModelTest { job.cancel() } + + @Test + fun createCustomRemoteWithTvLayoutAssignsLayoutAndStaticSlots() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.createRemote( + name = "Custom TV Remote", + type = RemoteDeviceType.CUSTOM, + brandModel = null, + transmissionMode = TransmissionMode.STATIC_SLOTS, + layoutStyle = com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, + ) + advanceUntilIdle() + + assertEquals(1, vm.state.value.remotes.size) + val created = vm.state.value.remotes.first() + assertEquals("Custom TV Remote", created.displayName) + assertEquals(com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, created.layoutStyle) + assertEquals(TransmissionMode.STATIC_SLOTS, created.transmissionMode) + + job.cancel() + } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt index b771bea..cc2f747 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt @@ -19,7 +19,8 @@ class CreateVirtualRemote( displayName: String, type: RemoteDeviceType, brandModel: String? = null, - transmissionMode: TransmissionMode, + transmissionMode: TransmissionMode = if (type == RemoteDeviceType.CUSTOM) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH, + layoutStyle: PanelLayoutStyle? = null, ): DomainResult { val trimmedName = displayName.trim() if (trimmedName.isBlank()) { @@ -28,12 +29,14 @@ class CreateVirtualRemote( ) } - val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel) - val quotaResult = repository.getKeyBindings(VirtualRemoteId("dummy")) // or compute from quota - // Obtain slot quota - // We calculate needed static slots: - val neededStaticSlots = if (transmissionMode == TransmissionMode.STATIC_SLOTS) templates.size else 0 + val resolvedLayout = layoutStyle ?: VirtualRemote.defaultLayoutFor(type) + val resolvedTransMode = if (type == RemoteDeviceType.CUSTOM) { + TransmissionMode.STATIC_SLOTS + } else { + transmissionMode + } + val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel, resolvedLayout) val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16)) val now = clock.now() @@ -46,7 +49,8 @@ class CreateVirtualRemote( displayName = trimmedName, type = type, brandModel = brandModel, - transmissionMode = transmissionMode, + layoutStyle = resolvedLayout, + transmissionMode = resolvedTransMode, dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT, acState = initialAcState, createdAtEpochMillis = now, @@ -54,9 +58,7 @@ class CreateVirtualRemote( ) // Build key bindings - val bindings = if (transmissionMode == TransmissionMode.STATIC_SLOTS) { - // Find currently used slots by querying quota - // For now, assign slots incrementally or check + val bindings = if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) { templates.mapIndexed { index, template -> RemoteKeyBinding( id = UUID.randomUUID().toString(), @@ -64,7 +66,7 @@ class CreateVirtualRemote( profileId = profileId, buttonId = template.buttonId, displayName = template.displayName, - assignedSlot = (index + 1).toString(), // Will be allocated based on allocator + assignedSlot = (index + 1).toString(), rawHexCode = template.defaultHexCode, status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED, ) @@ -89,7 +91,7 @@ class CreateVirtualRemote( } // If static and has default hex codes, download to hardware slots - if (transmissionMode == TransmissionMode.STATIC_SLOTS) { + if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) { val codesToDownload = bindings.mapNotNull { b -> if (b.assignedSlot != null && b.rawHexCode != null) { InfraredCode(deviceName, b.assignedSlot, b.rawHexCode) 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 8785d74..7a521c7 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 @@ -26,7 +26,7 @@ object PresetCodeLibrary { PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN), // 自定义 - PresetBrandOption("custom_blank", "自定义学习遥控器 (Custom)", RemoteDeviceType.CUSTOM), + PresetBrandOption("custom_blank", "自定义遥控器 (Custom)", RemoteDeviceType.CUSTOM), ) data class PresetButtonTemplate( @@ -35,24 +35,12 @@ object PresetCodeLibrary { val defaultHexCode: String? = null, ) - fun getTemplatesForType(type: RemoteDeviceType, presetId: String? = null): List = when (type) { - RemoteDeviceType.TV -> listOf( - PresetButtonTemplate("TV_POWER", "电源", "0x00FF02FD"), - PresetButtonTemplate("TV_MUTE", "静音", "0x00FF04FB"), - PresetButtonTemplate("TV_SOURCE", "信号源", "0x00FF06F9"), - PresetButtonTemplate("TV_NAV_UP", "上", "0x00FF08F7"), - PresetButtonTemplate("TV_NAV_DOWN", "下", "0x00FF0AF5"), - PresetButtonTemplate("TV_NAV_LEFT", "左", "0x00FF0CF3"), - PresetButtonTemplate("TV_NAV_RIGHT", "右", "0x00FF0EF1"), - PresetButtonTemplate("TV_OK", "确定", "0x00FF10EF"), - PresetButtonTemplate("TV_HOME", "主页", "0x00FF12ED"), - PresetButtonTemplate("TV_BACK", "返回", "0x00FF14EB"), - PresetButtonTemplate("TV_MENU", "菜单", "0x00FF16E9"), - PresetButtonTemplate("TV_VOL_UP", "音量 +", "0x00FF18E7"), - PresetButtonTemplate("TV_VOL_DOWN", "音量 -", "0x00FF1AE5"), - PresetButtonTemplate("TV_CH_UP", "频道 +", "0x00FF1CE3"), - PresetButtonTemplate("TV_CH_DOWN", "频道 -", "0x00FF1EE1"), - ) + fun getTemplatesForType( + type: RemoteDeviceType, + presetId: String? = null, + layoutStyle: PanelLayoutStyle? = null, + ): List = when (type) { + RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true) RemoteDeviceType.AIR_CONDITIONER -> listOf( PresetButtonTemplate("AC_POWER", "电源"), PresetButtonTemplate("AC_TEMP_UP", "升温"), @@ -70,12 +58,44 @@ object PresetCodeLibrary { PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"), PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"), ) - RemoteDeviceType.CUSTOM -> listOf( - PresetButtonTemplate("CUSTOM_POWER", "电源"), - PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"), - PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"), - PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"), - PresetButtonTemplate("CUSTOM_KEY_4", "按键 4"), - ) + RemoteDeviceType.CUSTOM -> when (layoutStyle) { + PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false) + PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false) + PanelLayoutStyle.AC_COMPACT -> emptyList() + PanelLayoutStyle.GRID, null -> listOf( + PresetButtonTemplate("CUSTOM_POWER", "电源"), + PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"), + PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"), + PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"), + ) + } } + + private fun getTvTemplates(hasHexCode: Boolean): List = listOf( + PresetButtonTemplate("TV_POWER", "电源", if (hasHexCode) "0x00FF02FD" else null), + PresetButtonTemplate("TV_MUTE", "静音", if (hasHexCode) "0x00FF04FB" else null), + PresetButtonTemplate("TV_SOURCE", "信号源", if (hasHexCode) "0x00FF06F9" else null), + PresetButtonTemplate("TV_NAV_UP", "上", if (hasHexCode) "0x00FF08F7" else null), + PresetButtonTemplate("TV_NAV_DOWN", "下", if (hasHexCode) "0x00FF0AF5" else null), + PresetButtonTemplate("TV_NAV_LEFT", "左", if (hasHexCode) "0x00FF0CF3" else null), + PresetButtonTemplate("TV_NAV_RIGHT", "右", if (hasHexCode) "0x00FF0EF1" else null), + PresetButtonTemplate("TV_OK", "确定", if (hasHexCode) "0x00FF10EF" else null), + PresetButtonTemplate("TV_HOME", "主页", if (hasHexCode) "0x00FF12ED" else null), + PresetButtonTemplate("TV_BACK", "返回", if (hasHexCode) "0x00FF14EB" else null), + PresetButtonTemplate("TV_MENU", "菜单", if (hasHexCode) "0x00FF16E9" else null), + PresetButtonTemplate("TV_VOL_UP", "音量 +", if (hasHexCode) "0x00FF18E7" else null), + PresetButtonTemplate("TV_VOL_DOWN", "音量 -", if (hasHexCode) "0x00FF1AE5" else null), + PresetButtonTemplate("TV_CH_UP", "频道 +", if (hasHexCode) "0x00FF1CE3" else null), + PresetButtonTemplate("TV_CH_DOWN", "频道 -", if (hasHexCode) "0x00FF1EE1" else null), + ) + + private fun getMediaPlayerTemplates(hasHexCode: Boolean): List = listOf( + PresetButtonTemplate("MEDIA_POWER", "电源", if (hasHexCode) "0x02FD02FD" else null), + PresetButtonTemplate("MEDIA_MUTE", "静音", if (hasHexCode) "0x02FD04FB" else null), + PresetButtonTemplate("MEDIA_PLAY_PAUSE", "播放/暂停", if (hasHexCode) "0x02FD06F9" else null), + PresetButtonTemplate("MEDIA_PREV", "上一曲", if (hasHexCode) "0x02FD08F7" else null), + PresetButtonTemplate("MEDIA_NEXT", "下一曲", if (hasHexCode) "0x02FD0AF5" else null), + PresetButtonTemplate("MEDIA_REWIND", "快退", if (hasHexCode) "0x02FD0CF3" else null), + PresetButtonTemplate("MEDIA_FF", "快进", if (hasHexCode) "0x02FD0EF1" else null), + ) }