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 98e402f..3583952 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 @@ -203,6 +203,14 @@ interface InfraredDao { ) suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int + @Query( + """ + DELETE FROM remote_key_bindings + WHERE remoteId = :remoteId AND buttonId = :buttonId + """, + ) + suspend fun deleteKeyBinding(remoteId: String, buttonId: String): Int + @Query( """ SELECT b.assignedSlot FROM remote_key_bindings b diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt index 1ae92d2..73112e5 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt @@ -46,6 +46,7 @@ data class VirtualRemoteEntity( val displayName: String, val type: String, val brandModel: String?, + val layoutStyle: String?, val transmissionMode: String, val dynamicScratchSlot: String, val acPower: Boolean?, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt index de36ae2..712e679 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt @@ -115,6 +115,16 @@ class RoomVirtualRemoteRepository @Inject constructor( DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) } + override suspend fun deleteKeyBinding( + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult = try { + dao.deleteKeyBinding(remoteId.value, buttonId) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + override suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId, @@ -151,13 +161,18 @@ private fun VirtualRemoteEntity.toDomain(): VirtualRemote { ) } else null + val devType = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM) + val parsedLayout = layoutStyle?.let { runCatching { com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.valueOf(it) }.getOrNull() } + ?: VirtualRemote.defaultLayoutFor(devType) + return VirtualRemote( id = VirtualRemoteId(id), profileId = ProviderProfileId(profileId), deviceName = DeviceName(deviceName), displayName = displayName, - type = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM), + type = devType, brandModel = brandModel, + layoutStyle = parsedLayout, transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH), dynamicScratchSlot = dynamicScratchSlot, acState = ac, @@ -173,6 +188,7 @@ private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity( displayName = displayName, type = type.name, brandModel = brandModel, + layoutStyle = layoutStyle.name, transmissionMode = transmissionMode.name, dynamicScratchSlot = dynamicScratchSlot, acPower = acState?.power, 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 c0baa4d..13d499a 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 @@ -197,4 +197,33 @@ object InfraredFeatureModule { repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, ): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote = com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository) + + @Provides + @Singleton + fun provideLearnVirtualRemoteKey( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + gateway: InfraredGateway, + ): com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey = + com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey(repository, gateway) + + @Provides + @Singleton + fun provideAddVirtualRemoteKey( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey = + com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey(repository) + + @Provides + @Singleton + fun provideDeleteVirtualRemoteKey( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey = + com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey(repository) + + @Provides + @Singleton + fun provideUpdateVirtualRemoteLayout( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout = + com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout(repository) } 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 b1441bb..76f130b 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 @@ -18,29 +18,35 @@ 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.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface -import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf 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 @@ -57,6 +63,8 @@ import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.RemoteDeviceType import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.VirtualRemote @@ -76,6 +84,10 @@ fun RemotePanelRoute( onBack = onBack, onSessionInvalid = onSessionInvalid, onButtonClick = viewModel::onButtonClick, + onToggleLearnMode = viewModel::toggleLearnMode, + onAddCustomButton = viewModel::addCustomButton, + onDeleteButton = viewModel::deleteButton, + onSwitchLayout = viewModel::switchLayout, onToggleAcPower = viewModel::toggleAcPower, onAdjustAcTemp = viewModel::adjustAcTemperature, onSetAcMode = viewModel::setAcMode, @@ -94,6 +106,10 @@ internal fun RemotePanelScreen( onBack: () -> Unit, onSessionInvalid: () -> Unit, onButtonClick: (String) -> Unit, + onToggleLearnMode: () -> Unit, + onAddCustomButton: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onSwitchLayout: (PanelLayoutStyle) -> Unit, onToggleAcPower: () -> Unit, onAdjustAcTemp: (Int) -> Unit, onSetAcMode: (AcMode) -> Unit, @@ -106,6 +122,9 @@ internal fun RemotePanelScreen( val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current + var showAddKeyDialog by remember { mutableStateOf(false) } + var showMenu by remember { mutableStateOf(false) } + LaunchedEffect(state.message) { state.message?.let { msg -> when (msg) { @@ -137,16 +156,76 @@ internal fun RemotePanelScreen( topBar = { TopAppBar( title = { - Text( - text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title), - fontWeight = FontWeight.Bold, - ) + Column { + Text( + text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title), + fontWeight = FontWeight.Bold, + fontSize = 18.sp, + ) + state.remote?.let { + Text( + text = "布局: ${it.layoutStyle.name} | 模式: ${it.transmissionMode.name}", + fontSize = 11.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } }, navigationIcon = { TextButton(onClick = onBack) { Text(stringResource(R.string.infrared_back)) } }, + 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) + }, + ) + } + } + }, ) }, snackbarHost = { SnackbarHost(snackbarHostState) }, @@ -162,37 +241,91 @@ internal fun RemotePanelScreen( CircularProgressIndicator() } } else { - when (remote.type) { - RemoteDeviceType.AIR_CONDITIONER -> { - AcControlPanel( - acState = state.acState, - onTogglePower = onToggleAcPower, - onAdjustTemp = onAdjustAcTemp, - onSetMode = onSetAcMode, - onSetFanSpeed = onSetAcFanSpeed, - onToggleVSwing = onToggleAcVerticalSwing, - onToggleHSwing = onToggleAcHorizontalSwing, - onToggleEco = onToggleAcEco, - ) + Column(modifier = Modifier.fillMaxSize()) { + // 学习模式指示横幅 + if (state.isLearnMode) { + Card( + colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE)), + shape = RoundedCornerShape(0.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text("🔴", fontSize = 16.sp) + Text( + text = "学习录制模式已开启:点击下方任意按键,然后将实体遥控器对准转发器按下即可录码", + color = Color(0xFFC62828), + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + ) + } + } } - RemoteDeviceType.TV -> { - TvControlPanel( - bindings = state.bindings, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) - } - RemoteDeviceType.FAN, RemoteDeviceType.CUSTOM -> { - GenericControlPanel( - bindings = state.bindings, - sendingButtonId = state.sendingButtonId, - onButtonClick = onButtonClick, - ) + + // 面板内容区 + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + when (remote.layoutStyle) { + PanelLayoutStyle.AC_COMPACT -> { + AcControlPanel( + acState = state.acState, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetAcFanSpeed, + onToggleVSwing = onToggleAcVerticalSwing, + onToggleHSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + ) + } + PanelLayoutStyle.TV_NAV -> { + TvControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onDeleteButton = onDeleteButton, + ) + } + PanelLayoutStyle.MEDIA_PLAYER -> { + MediaPlayerControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + } + PanelLayoutStyle.GRID -> { + GenericGridControlPanel( + bindings = state.bindings, + isLearnMode = state.isLearnMode, + learningButtonId = state.learningButtonId, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + onDeleteButton = onDeleteButton, + onAddButton = { showAddKeyDialog = true }, + ) + } + } } } } } } + + if (showAddKeyDialog) { + AddCustomKeyDialog( + onDismiss = { showAddKeyDialog = false }, + onConfirm = { name -> + onAddCustomButton(name) + showAddKeyDialog = false + }, + ) + } } @Composable @@ -213,7 +346,6 @@ private fun AcControlPanel( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - // 1. 状态看板与大温度指示器 Card( modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( @@ -262,7 +394,6 @@ private fun AcControlPanel( color = MaterialTheme.colorScheme.onSurfaceVariant, ) - // 调温大按键 Row( horizontalArrangement = Arrangement.spacedBy(32.dp), verticalAlignment = Alignment.CenterVertically, @@ -285,16 +416,14 @@ private fun AcControlPanel( } } - // 2. 模式切换 Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AcMode.entries.forEach { mode -> - val selected = acState.mode == mode FilterChip( - selected = selected, + selected = acState.mode == mode, onClick = { onSetMode(mode) }, label = { Text(mode.name) }, modifier = Modifier.weight(1f), @@ -302,16 +431,14 @@ private fun AcControlPanel( } } - // 3. 风速切换 Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AcFanSpeed.entries.forEach { speed -> - val selected = acState.fanSpeed == speed FilterChip( - selected = selected, + selected = acState.fanSpeed == speed, onClick = { onSetFanSpeed(speed) }, label = { Text(speed.name) }, modifier = Modifier.weight(1f), @@ -319,7 +446,6 @@ private fun AcControlPanel( } } - // 4. 功能开关 Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -349,8 +475,11 @@ private fun AcControlPanel( @Composable private fun TvControlPanel( bindings: List, + isLearnMode: Boolean, + learningButtonId: String?, sendingButtonId: String?, onButtonClick: (String) -> Unit, + onDeleteButton: (String) -> Unit, ) { Column( modifier = Modifier @@ -365,9 +494,9 @@ private fun TvControlPanel( horizontalArrangement = Arrangement.SpaceAround, verticalAlignment = Alignment.CenterVertically, ) { - TvActionButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), onButtonClick, sendingButtonId) - TvActionButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) - TvActionButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) + 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) } Spacer(modifier = Modifier.height(8.dp)) @@ -383,25 +512,33 @@ private fun TvControlPanel( IconButton( onClick = { onButtonClick("TV_NAV_UP") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), - ) { Text("▲", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId) + } // 下 IconButton( onClick = { onButtonClick("TV_NAV_DOWN") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), - ) { Text("▼", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId) + } // 左 IconButton( onClick = { onButtonClick("TV_NAV_LEFT") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), - ) { Text("◀", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId) + } // 右 IconButton( onClick = { onButtonClick("TV_NAV_RIGHT") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), - ) { Text("▶", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + ) { + ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId) + } // OK 居中大按钮 Button( @@ -409,7 +546,7 @@ private fun TvControlPanel( shape = CircleShape, modifier = Modifier.size(76.dp).testTag("tv_ok"), ) { - Text("OK", fontWeight = FontWeight.Black, fontSize = 18.sp) + ButtonIndicatorText("TV_OK", "OK", learningButtonId, sendingButtonId) } } } @@ -419,9 +556,9 @@ private fun TvControlPanel( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround, ) { - OutlinedButton(onClick = { onButtonClick("TV_BACK") }) { Text("↩ 返回") } - OutlinedButton(onClick = { onButtonClick("TV_HOME") }) { Text("⌂ 主页") } - OutlinedButton(onClick = { onButtonClick("TV_MENU") }) { Text("☰ 菜单") } + 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) } // 底部音量与频道调节柱 @@ -434,8 +571,12 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("+", fontSize = 20.sp) } - Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("-", fontSize = 20.sp) } + Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId) + } + Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId) + } } Column( @@ -443,57 +584,236 @@ private fun TvControlPanel( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) - Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▲", fontSize = 16.sp) } - Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▼", fontSize = 16.sp) } - } - } - } -} - -@Composable -private fun TvActionButton( - buttonId: String, - label: String, - containerColor: Color, - onButtonClick: (String) -> Unit, - sendingButtonId: String?, -) { - Button( - onClick = { onButtonClick(buttonId) }, - colors = ButtonDefaults.buttonColors(containerColor = containerColor), - modifier = Modifier.testTag("tv_btn_$buttonId"), - ) { - if (sendingButtonId == buttonId) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) - } else { - Text(label, fontWeight = FontWeight.Bold) - } - } -} - -@Composable -private fun GenericControlPanel( - bindings: List, - sendingButtonId: String?, - onButtonClick: (String) -> Unit, -) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - modifier = Modifier.fillMaxSize().padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - items(bindings, key = { it.id }) { binding -> - Button( - onClick = { onButtonClick(binding.buttonId) }, - modifier = Modifier.fillMaxWidth().height(64.dp).testTag("generic_btn_${binding.buttonId}"), - ) { - if (sendingButtonId == binding.buttonId) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) - } else { - Text(binding.displayName, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId) + } + Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { + ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId) } } } } } + +@Composable +private fun MediaPlayerControlPanel( + bindings: List, + isLearnMode: Boolean, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Row( + 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) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // 播放控制中心大圆盘 + Card( + shape = CircleShape, + modifier = Modifier.size(240.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // 快进 >> + IconButton(onClick = { onButtonClick("MEDIA_FF") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) { + ButtonIndicatorText("MEDIA_FF", "⏩", learningButtonId, sendingButtonId) + } + // 快退 << + IconButton(onClick = { onButtonClick("MEDIA_REWIND") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) { + ButtonIndicatorText("MEDIA_REWIND", "⏪", learningButtonId, sendingButtonId) + } + // 上一曲 + IconButton(onClick = { onButtonClick("MEDIA_PREV") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp)) { + ButtonIndicatorText("MEDIA_PREV", "⏮", learningButtonId, sendingButtonId) + } + // 下一曲 + IconButton(onClick = { onButtonClick("MEDIA_NEXT") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp)) { + ButtonIndicatorText("MEDIA_NEXT", "⏭", learningButtonId, sendingButtonId) + } + // 播放/暂停 + Button(onClick = { onButtonClick("MEDIA_PLAY_PAUSE") }, shape = CircleShape, modifier = Modifier.size(80.dp)) { + ButtonIndicatorText("MEDIA_PLAY_PAUSE", "⏯", learningButtonId, sendingButtonId) + } + } + } + } +} + +@Composable +private fun GenericGridControlPanel( + bindings: List, + isLearnMode: Boolean, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, + onDeleteButton: (String) -> Unit, + onAddButton: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxSize().padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp) + Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) { + Text("➕ 添加按键") + } + } + + if (bindings.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) { + Text("暂无自定义按键,点击上方 ➕ 添加按键", color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize().weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(bindings, key = { it.id }) { binding -> + GridButtonCard( + binding = binding, + isLearnMode = isLearnMode, + isLearning = learningButtonId == binding.buttonId, + isSending = sendingButtonId == binding.buttonId, + onClick = { onButtonClick(binding.buttonId) }, + onDelete = { onDeleteButton(binding.buttonId) }, + ) + } + } + } + } +} + +@Composable +private fun GridButtonCard( + binding: RemoteKeyBinding, + isLearnMode: Boolean, + isLearning: Boolean, + isSending: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .height(72.dp) + .clickable(onClick = onClick) + .testTag("generic_btn_${binding.buttonId}"), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Row( + modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp) + val statusText = when { + isLearning -> "🟡 正在录制信号..." + binding.status == InfraredKeyStatus.READY -> "🟢 已录制" + 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) + } + } + } + } +} + +@Composable +private fun InteractiveRemoteButton( + buttonId: String, + label: String, + containerColor: Color, + isLearnMode: Boolean, + learningButtonId: String?, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + Button( + onClick = { onButtonClick(buttonId) }, + colors = ButtonDefaults.buttonColors( + containerColor = if (learningButtonId == buttonId) Color(0xFFFF9800) else containerColor, + ), + modifier = Modifier.testTag("tv_btn_$buttonId"), + ) { + ButtonIndicatorText(buttonId, label, learningButtonId, sendingButtonId) + } +} + +@Composable +private fun ButtonIndicatorText( + buttonId: String, + defaultLabel: String, + learningButtonId: String?, + sendingButtonId: String?, +) { + if (learningButtonId == buttonId) { + Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } else if (sendingButtonId == buttonId) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) + } else { + Text(defaultLabel, fontWeight = FontWeight.Bold) + } +} + +@Composable +private fun AddCustomKeyDialog( + onDismiss: () -> Unit, + onConfirm: (name: String) -> Unit, +) { + var name by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("添加自定义按键") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("输入按键显示名称,创建后开启右上角【学习模式】即可录制实体信号:", fontSize = 13.sp) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("按键名称(如:静音、音效切换)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"), + ) + } + }, + confirmButton = { + Button( + onClick = { + if (name.isNotBlank()) onConfirm(name.trim()) + }, + modifier = Modifier.testTag("add_key_confirm_button"), + ) { + Text("确认添加") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("取消") } + }, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt index 4252e4e..da8de39 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt @@ -17,6 +17,8 @@ data class RemotePanelUiState( val remote: VirtualRemote? = null, val bindings: List = emptyList(), val acState: AcStateSnapshot = AcStateSnapshot(), + val isLearnMode: Boolean = false, + val learningButtonId: String? = null, val sendingButtonId: String? = null, val loading: Boolean = true, val message: PanelUiMessage? = null, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt index 1333c6b..221dad3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt @@ -9,8 +9,13 @@ import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcStateSnapshot -import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository @@ -36,6 +41,10 @@ class RemotePanelViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val virtualRemoteRepository: VirtualRemoteRepository, private val sendVirtualRemoteAction: SendVirtualRemoteAction, + private val learnVirtualRemoteKey: LearnVirtualRemoteKey, + private val addVirtualRemoteKey: AddVirtualRemoteKey, + private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey, + private val updateVirtualRemoteLayout: UpdateVirtualRemoteLayout, ) : ViewModel() { private val rawProfileId: String? = savedStateHandle["profileId"] @@ -44,6 +53,8 @@ class RemotePanelViewModel @Inject constructor( private val rawRemoteId: String? = savedStateHandle["remoteId"] val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) } + private val isLearnModeFlow = MutableStateFlow(false) + private val learningButtonIdFlow = MutableStateFlow(null) private val sendingButtonIdFlow = MutableStateFlow(null) private val localAcStateFlow = MutableStateFlow(null) private val messageFlow = MutableStateFlow(null) @@ -67,21 +78,45 @@ class RemotePanelViewModel @Inject constructor( } } - val state: StateFlow = combine( - remoteFlow, - bindingsFlow, - localAcStateFlow, + private data class RemoteSnapshot( + val remote: VirtualRemote?, + val bindings: List, + ) + + private val snapshotFlow = combine(remoteFlow, bindingsFlow) { remote, bindings -> + RemoteSnapshot(remote, bindings) + } + + private data class ActionState( + val isLearnMode: Boolean, + val learningButtonId: String?, + val sendingButtonId: String?, + ) + + private val actionStateFlow = combine( + isLearnModeFlow, + learningButtonIdFlow, sendingButtonIdFlow, + ) { isLearn, learningBtn, sendingBtn -> + ActionState(isLearn, learningBtn, sendingBtn) + } + + val state: StateFlow = combine( + snapshotFlow, + localAcStateFlow, + actionStateFlow, messageFlow, - ) { remote, bindings, localAc, sendingBtn, msg -> - val resolvedAc = localAc ?: remote?.acState ?: AcStateSnapshot() + ) { snapshot, localAc, action, msg -> + val resolvedAc = localAc ?: snapshot.remote?.acState ?: AcStateSnapshot() RemotePanelUiState( profileId = profileId, - remote = remote, - bindings = bindings, + remote = snapshot.remote, + bindings = snapshot.bindings, acState = resolvedAc, - sendingButtonId = sendingBtn, - loading = remote == null, + isLearnMode = action.isLearnMode, + learningButtonId = action.learningButtonId, + sendingButtonId = action.sendingButtonId, + loading = snapshot.remote == null, message = msg, ) }.stateIn( @@ -90,10 +125,26 @@ class RemotePanelViewModel @Inject constructor( initialValue = RemotePanelUiState(profileId = profileId, loading = true), ) + fun toggleLearnMode() { + val newMode = !isLearnModeFlow.value + isLearnModeFlow.value = newMode + if (newMode) { + messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled)) + } + } + fun onButtonClick(buttonId: String) { + if (isLearnModeFlow.value) { + learnButton(buttonId) + } else { + sendButton(buttonId) + } + } + + private fun sendButton(buttonId: String) { val currentProfile = profileId ?: return val currentRemoteId = remoteId ?: return - if (state.value.sendingButtonId != null) return + if (state.value.sendingButtonId != null || state.value.learningButtonId != null) return sendingButtonIdFlow.value = buttonId viewModelScope.launch { @@ -110,6 +161,65 @@ class RemotePanelViewModel @Inject constructor( } } + fun learnButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + if (state.value.learningButtonId != null) return + + learningButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf(buttonId))) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } finally { + learningButtonIdFlow.value = null + } + } + } + + fun addCustomButton(displayName: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + viewModelScope.launch { + when (val res = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已添加按键【${displayName}】,开启学习模式即可录制")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun deleteButton(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + viewModelScope.launch { + when (val res = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("按键已删除")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun switchLayout(newLayout: PanelLayoutStyle) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + viewModelScope.launch { + when (val res = updateVirtualRemoteLayout(currentProfile, currentRemoteId, newLayout)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已切换面板布局风格")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + fun toggleAcPower() { val current = state.value.acState updateAndDebounceAc(current.copy(power = !current.power)) diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt index 52ad483..8db53e1 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt @@ -5,11 +5,16 @@ import com.flagship.abox.manager.domain.DeviceName import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey +import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle import com.flagship.abox.manager.domain.infrared.RemoteDeviceType import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository @@ -27,6 +32,7 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -44,6 +50,10 @@ class RemotePanelViewModelTest { private val gateway = FakeInfraredGateway() private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway) + private val learnKey = LearnVirtualRemoteKey(remoteRepo, gateway) + private val addKey = AddVirtualRemoteKey(remoteRepo) + private val deleteKey = DeleteVirtualRemoteKey(remoteRepo) + private val updateLayout = UpdateVirtualRemoteLayout(remoteRepo) @Before fun setup() { @@ -59,10 +69,14 @@ class RemotePanelViewModelTest { savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)), virtualRemoteRepository = remoteRepo, sendVirtualRemoteAction = sendAction, + learnVirtualRemoteKey = learnKey, + addVirtualRemoteKey = addKey, + deleteVirtualRemoteKey = deleteKey, + updateVirtualRemoteLayout = updateLayout, ) @Test - fun tvButtonTriggersInstantTransmission() = testScope.runTest { + fun tvButtonTriggersInstantTransmissionInNormalMode() = testScope.runTest { val res = createRemote( profileId = profileId, deviceName = device, @@ -86,6 +100,93 @@ class RemotePanelViewModelTest { job.cancel() } + @Test + fun learnModeToggledTriggersLearningWhenButtonIsClicked() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + // 1. Toggle learn mode ON + vm.toggleLearnMode() + runCurrent() + assertTrue(vm.state.value.isLearnMode) + + // 2. Click button -> triggers learn instead of send + vm.onButtonClick("TV_POWER") + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("1", gateway.learnedKeys.first().key) + assertEquals(0, gateway.sentKeys.size) + + job.cancel() + } + + @Test + fun addAndRemoveCustomButtonUpdatesBindings() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "Custom", + type = RemoteDeviceType.CUSTOM, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + // Add button + vm.addCustomButton("Sound Effect") + advanceUntilIdle() + + val added = vm.state.value.bindings.find { it.displayName == "Sound Effect" } + assertNotNull(added) + + // Delete button + vm.deleteButton(added!!.buttonId) + advanceUntilIdle() + + val afterDelete = vm.state.value.bindings.find { it.displayName == "Sound Effect" } + assertEquals(null, afterDelete) + + job.cancel() + } + + @Test + fun switchLayoutUpdatesRemoteLayoutStyle() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "Custom Box", + type = RemoteDeviceType.CUSTOM, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.switchLayout(PanelLayoutStyle.TV_NAV) + advanceUntilIdle() + + assertEquals(PanelLayoutStyle.TV_NAV, vm.state.value.remote?.layoutStyle) + + job.cancel() + } + @Test fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest { val res = createRemote( diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt new file mode 100644 index 0000000..26cb835 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AddVirtualRemoteKey.kt @@ -0,0 +1,53 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.UUID + +class AddVirtualRemoteKey( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + displayName: String, + ): DomainResult { + val trimmed = displayName.trim() + if (trimmed.isBlank()) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button name cannot be blank")) + } + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + + val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase() + val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) { + // Find next available slot + val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty() + val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() + val nextSlot = (1..99).firstOrNull { it !in usedSlots } + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位")) + nextSlot.toString() + } else null + + val binding = RemoteKeyBinding( + id = UUID.randomUUID().toString(), + remoteId = remoteId, + profileId = profileId, + buttonId = buttonId, + displayName = trimmed, + assignedSlot = assignedSlot, + rawHexCode = null, + status = InfraredKeyStatus.FAILED, // Initially unlearned + ) + + when (val res = repository.updateKeyBinding(binding)) { + is DomainResult.Failure -> return res + is DomainResult.Success -> Unit + } + + return DomainResult.Success(binding) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemoteKey.kt new file mode 100644 index 0000000..5ed2dc0 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemoteKey.kt @@ -0,0 +1,14 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId + +class DeleteVirtualRemoteKey( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult = repository.deleteKeyBinding(remoteId, buttonId) +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt new file mode 100644 index 0000000..0764236 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/LearnVirtualRemoteKey.kt @@ -0,0 +1,73 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class LearnVirtualRemoteKey( + private val repository: VirtualRemoteRepository, + private val gateway: InfraredGateway, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + val bindings = when (val res = repository.getKeyBindings(remoteId)) { + is DomainResult.Success -> res.value + is DomainResult.Failure -> return res + } + val binding = bindings.find { it.buttonId == buttonId } + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found")) + + val targetSlot = when (remote.transmissionMode) { + TransmissionMode.STATIC_SLOTS -> { + binding.assignedSlot ?: run { + // Find available slot from quota + "1" + } + } + TransmissionMode.DYNAMIC_SCRATCH -> remote.dynamicScratchSlot + } + + // Set status to LEARNING while hardware listens + repository.updateKeyBinding( + binding.copy( + status = InfraredKeyStatus.LEARNING, + assignedSlot = targetSlot, + lastErrorCode = null, + lastErrorDetail = null, + ), + ) + + return when (val learnResult = gateway.learnKey(profileId, remote.deviceName, targetSlot)) { + is DomainResult.Success -> { + repository.updateKeyBinding( + binding.copy( + status = InfraredKeyStatus.READY, + assignedSlot = targetSlot, + lastErrorCode = null, + lastErrorDetail = null, + ), + ) + DomainResult.Success(Unit) + } + is DomainResult.Failure -> { + repository.updateKeyBinding( + binding.copy( + status = InfraredKeyStatus.FAILED, + assignedSlot = targetSlot, + lastErrorCode = learnResult.error.externalCode, + lastErrorDetail = learnResult.error.detail, + ), + ) + learnResult + } + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/UpdateVirtualRemoteLayout.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/UpdateVirtualRemoteLayout.kt new file mode 100644 index 0000000..bd42b09 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/UpdateVirtualRemoteLayout.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId + +class UpdateVirtualRemoteLayout( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + layoutStyle: PanelLayoutStyle, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + val updated = remote.copy( + layoutStyle = layoutStyle, + updatedAtEpochMillis = System.currentTimeMillis(), + ) + return repository.updateRemote(updated) + } +} 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 dc6259e..112a29c 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 @@ -18,6 +18,13 @@ enum class TransmissionMode { DYNAMIC_SCRATCH, } +enum class PanelLayoutStyle { + TV_NAV, + GRID, + MEDIA_PLAYER, + AC_COMPACT, +} + enum class AcMode { COOL, HEAT, @@ -50,6 +57,7 @@ data class VirtualRemote( val displayName: String, val type: RemoteDeviceType, val brandModel: String? = null, + val layoutStyle: PanelLayoutStyle = defaultLayoutFor(type), val transmissionMode: TransmissionMode, val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT, val acState: AcStateSnapshot? = null, @@ -58,6 +66,13 @@ data class VirtualRemote( ) { companion object { const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100" + + fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) { + RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV + RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT + RemoteDeviceType.FAN -> PanelLayoutStyle.GRID + RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID + } } } diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt index 2279afc..fb9e622 100644 --- a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt @@ -48,6 +48,8 @@ interface VirtualRemoteRepository { suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult + suspend fun deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult + suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId, diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index c962153..0fd17cd 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -311,6 +311,17 @@ class InMemoryVirtualRemoteRepository : VirtualRemoteRepository { return DomainResult.Success(Unit) } + override suspend fun deleteKeyBinding( + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val list = bindings[remoteId] ?: return DomainResult.Success(Unit) + list.removeAll { it.buttonId == buttonId } + bindings[remoteId] = list + publish() + return DomainResult.Success(Unit) + } + override suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt index 2a3f80e..ac183cc 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt @@ -113,6 +113,17 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository { return DomainResult.Success(Unit) } + override suspend fun deleteKeyBinding( + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val list = bindings[remoteId] ?: return DomainResult.Success(Unit) + list.removeAll { it.buttonId == buttonId } + bindings[remoteId] = list + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + override suspend fun updateAcState( profileId: ProviderProfileId, remoteId: VirtualRemoteId,