feat(infrared): add virtual remote learn mode, custom layout switching, and key management

This commit is contained in:
2026-08-25 14:20:30 +08:00
parent 7f9fb8c2b6
commit 63cac722c2
16 changed files with 907 additions and 116 deletions
@@ -203,6 +203,14 @@ interface InfraredDao {
) )
suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int 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( @Query(
""" """
SELECT b.assignedSlot FROM remote_key_bindings b SELECT b.assignedSlot FROM remote_key_bindings b
@@ -46,6 +46,7 @@ data class VirtualRemoteEntity(
val displayName: String, val displayName: String,
val type: String, val type: String,
val brandModel: String?, val brandModel: String?,
val layoutStyle: String?,
val transmissionMode: String, val transmissionMode: String,
val dynamicScratchSlot: String, val dynamicScratchSlot: String,
val acPower: Boolean?, val acPower: Boolean?,
@@ -115,6 +115,16 @@ class RoomVirtualRemoteRepository @Inject constructor(
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
} }
override suspend fun deleteKeyBinding(
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> = 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( override suspend fun updateAcState(
profileId: ProviderProfileId, profileId: ProviderProfileId,
remoteId: VirtualRemoteId, remoteId: VirtualRemoteId,
@@ -151,13 +161,18 @@ private fun VirtualRemoteEntity.toDomain(): VirtualRemote {
) )
} else null } 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( return VirtualRemote(
id = VirtualRemoteId(id), id = VirtualRemoteId(id),
profileId = ProviderProfileId(profileId), profileId = ProviderProfileId(profileId),
deviceName = DeviceName(deviceName), deviceName = DeviceName(deviceName),
displayName = displayName, displayName = displayName,
type = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM), type = devType,
brandModel = brandModel, brandModel = brandModel,
layoutStyle = parsedLayout,
transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH), transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH),
dynamicScratchSlot = dynamicScratchSlot, dynamicScratchSlot = dynamicScratchSlot,
acState = ac, acState = ac,
@@ -173,6 +188,7 @@ private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity(
displayName = displayName, displayName = displayName,
type = type.name, type = type.name,
brandModel = brandModel, brandModel = brandModel,
layoutStyle = layoutStyle.name,
transmissionMode = transmissionMode.name, transmissionMode = transmissionMode.name,
dynamicScratchSlot = dynamicScratchSlot, dynamicScratchSlot = dynamicScratchSlot,
acPower = acState?.power, acPower = acState?.power,
@@ -197,4 +197,33 @@ object InfraredFeatureModule {
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote = ): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote =
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository) 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)
} }
@@ -18,29 +18,35 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot 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.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemote
@@ -76,6 +84,10 @@ fun RemotePanelRoute(
onBack = onBack, onBack = onBack,
onSessionInvalid = onSessionInvalid, onSessionInvalid = onSessionInvalid,
onButtonClick = viewModel::onButtonClick, onButtonClick = viewModel::onButtonClick,
onToggleLearnMode = viewModel::toggleLearnMode,
onAddCustomButton = viewModel::addCustomButton,
onDeleteButton = viewModel::deleteButton,
onSwitchLayout = viewModel::switchLayout,
onToggleAcPower = viewModel::toggleAcPower, onToggleAcPower = viewModel::toggleAcPower,
onAdjustAcTemp = viewModel::adjustAcTemperature, onAdjustAcTemp = viewModel::adjustAcTemperature,
onSetAcMode = viewModel::setAcMode, onSetAcMode = viewModel::setAcMode,
@@ -94,6 +106,10 @@ internal fun RemotePanelScreen(
onBack: () -> Unit, onBack: () -> Unit,
onSessionInvalid: () -> Unit, onSessionInvalid: () -> Unit,
onButtonClick: (String) -> Unit, onButtonClick: (String) -> Unit,
onToggleLearnMode: () -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onSwitchLayout: (PanelLayoutStyle) -> Unit,
onToggleAcPower: () -> Unit, onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit, onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit, onSetAcMode: (AcMode) -> Unit,
@@ -106,6 +122,9 @@ internal fun RemotePanelScreen(
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current val context = LocalContext.current
var showAddKeyDialog by remember { mutableStateOf(false) }
var showMenu by remember { mutableStateOf(false) }
LaunchedEffect(state.message) { LaunchedEffect(state.message) {
state.message?.let { msg -> state.message?.let { msg ->
when (msg) { when (msg) {
@@ -137,16 +156,76 @@ internal fun RemotePanelScreen(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Column {
Text( Text(
text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title), text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title),
fontWeight = FontWeight.Bold, 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 = { navigationIcon = {
TextButton(onClick = onBack) { TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back)) 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) }, snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -162,8 +241,34 @@ internal fun RemotePanelScreen(
CircularProgressIndicator() CircularProgressIndicator()
} }
} else { } else {
when (remote.type) { Column(modifier = Modifier.fillMaxSize()) {
RemoteDeviceType.AIR_CONDITIONER -> { // 学习模式指示横幅
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,
)
}
}
}
// 面板内容区
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> {
AcControlPanel( AcControlPanel(
acState = state.acState, acState = state.acState,
onTogglePower = onToggleAcPower, onTogglePower = onToggleAcPower,
@@ -175,25 +280,53 @@ internal fun RemotePanelScreen(
onToggleEco = onToggleAcEco, onToggleEco = onToggleAcEco,
) )
} }
RemoteDeviceType.TV -> { PanelLayoutStyle.TV_NAV -> {
TvControlPanel( TvControlPanel(
bindings = state.bindings, bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId, sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick, onButtonClick = onButtonClick,
onDeleteButton = onDeleteButton,
) )
} }
RemoteDeviceType.FAN, RemoteDeviceType.CUSTOM -> { PanelLayoutStyle.MEDIA_PLAYER -> {
GenericControlPanel( MediaPlayerControlPanel(
bindings = state.bindings, bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId, sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick, 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 @Composable
private fun AcControlPanel( private fun AcControlPanel(
@@ -213,7 +346,6 @@ private fun AcControlPanel(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
// 1. 状态看板与大温度指示器
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
@@ -262,7 +394,6 @@ private fun AcControlPanel(
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
// 调温大按键
Row( Row(
horizontalArrangement = Arrangement.spacedBy(32.dp), horizontalArrangement = Arrangement.spacedBy(32.dp),
verticalAlignment = Alignment.CenterVertically, 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)) Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
AcMode.entries.forEach { mode -> AcMode.entries.forEach { mode ->
val selected = acState.mode == mode
FilterChip( FilterChip(
selected = selected, selected = acState.mode == mode,
onClick = { onSetMode(mode) }, onClick = { onSetMode(mode) },
label = { Text(mode.name) }, label = { Text(mode.name) },
modifier = Modifier.weight(1f), 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)) Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
AcFanSpeed.entries.forEach { speed -> AcFanSpeed.entries.forEach { speed ->
val selected = acState.fanSpeed == speed
FilterChip( FilterChip(
selected = selected, selected = acState.fanSpeed == speed,
onClick = { onSetFanSpeed(speed) }, onClick = { onSetFanSpeed(speed) },
label = { Text(speed.name) }, label = { Text(speed.name) },
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -319,7 +446,6 @@ private fun AcControlPanel(
} }
} }
// 4. 功能开关
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -349,8 +475,11 @@ private fun AcControlPanel(
@Composable @Composable
private fun TvControlPanel( private fun TvControlPanel(
bindings: List<RemoteKeyBinding>, bindings: List<RemoteKeyBinding>,
isLearnMode: Boolean,
learningButtonId: String?,
sendingButtonId: String?, sendingButtonId: String?,
onButtonClick: (String) -> Unit, onButtonClick: (String) -> Unit,
onDeleteButton: (String) -> Unit,
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
@@ -365,9 +494,9 @@ private fun TvControlPanel(
horizontalArrangement = Arrangement.SpaceAround, horizontalArrangement = Arrangement.SpaceAround,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
TvActionButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), onButtonClick, sendingButtonId) InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
TvActionButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
TvActionButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -383,25 +512,33 @@ private fun TvControlPanel(
IconButton( IconButton(
onClick = { onButtonClick("TV_NAV_UP") }, onClick = { onButtonClick("TV_NAV_UP") },
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("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( IconButton(
onClick = { onButtonClick("TV_NAV_DOWN") }, onClick = { onButtonClick("TV_NAV_DOWN") },
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("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( IconButton(
onClick = { onButtonClick("TV_NAV_LEFT") }, onClick = { onButtonClick("TV_NAV_LEFT") },
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("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( IconButton(
onClick = { onButtonClick("TV_NAV_RIGHT") }, onClick = { onButtonClick("TV_NAV_RIGHT") },
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("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 居中大按钮 // OK 居中大按钮
Button( Button(
@@ -409,7 +546,7 @@ private fun TvControlPanel(
shape = CircleShape, shape = CircleShape,
modifier = Modifier.size(76.dp).testTag("tv_ok"), 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(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround, horizontalArrangement = Arrangement.SpaceAround,
) { ) {
OutlinedButton(onClick = { onButtonClick("TV_BACK") }) { Text("↩ 返回") } InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
OutlinedButton(onClick = { onButtonClick("TV_HOME") }) { Text("⌂ 主页") } InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
OutlinedButton(onClick = { onButtonClick("TV_MENU") }) { Text("☰ 菜单") } InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
} }
// 底部音量与频道调节柱 // 底部音量与频道调节柱
@@ -434,8 +571,12 @@ private fun TvControlPanel(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) 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_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("-", fontSize = 20.sp) } 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( Column(
@@ -443,57 +584,236 @@ private fun TvControlPanel(
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) 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_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▼", fontSize = 16.sp) } 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 @Composable
private fun TvActionButton( private fun MediaPlayerControlPanel(
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<RemoteKeyBinding>, bindings: List<RemoteKeyBinding>,
isLearnMode: Boolean,
learningButtonId: String?,
sendingButtonId: String?, sendingButtonId: String?,
onButtonClick: (String) -> Unit, 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<RemoteKeyBinding>,
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( LazyVerticalGrid(
columns = GridCells.Fixed(2), columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
items(bindings, key = { it.id }) { binding -> items(bindings, key = { it.id }) { binding ->
Button( GridButtonCard(
binding = binding,
isLearnMode = isLearnMode,
isLearning = learningButtonId == binding.buttonId,
isSending = sendingButtonId == binding.buttonId,
onClick = { onButtonClick(binding.buttonId) }, onClick = { onButtonClick(binding.buttonId) },
modifier = Modifier.fillMaxWidth().height(64.dp).testTag("generic_btn_${binding.buttonId}"), onDelete = { onDeleteButton(binding.buttonId) },
)
}
}
}
}
}
@Composable
private fun GridButtonCard(
binding: RemoteKeyBinding,
isLearnMode: Boolean,
isLearning: Boolean,
isSending: Boolean,
onClick: () -> Unit,
onDelete: () -> Unit,
) { ) {
if (sendingButtonId == binding.buttonId) { Card(
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) 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 { } else {
Text(binding.displayName, fontSize = 16.sp, fontWeight = FontWeight.Bold) 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("取消") }
},
)
} }
@@ -17,6 +17,8 @@ data class RemotePanelUiState(
val remote: VirtualRemote? = null, val remote: VirtualRemote? = null,
val bindings: List<RemoteKeyBinding> = emptyList(), val bindings: List<RemoteKeyBinding> = emptyList(),
val acState: AcStateSnapshot = AcStateSnapshot(), val acState: AcStateSnapshot = AcStateSnapshot(),
val isLearnMode: Boolean = false,
val learningButtonId: String? = null,
val sendingButtonId: String? = null, val sendingButtonId: String? = null,
val loading: Boolean = true, val loading: Boolean = true,
val message: PanelUiMessage? = null, val message: PanelUiMessage? = null,
@@ -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.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot 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.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
import com.flagship.abox.manager.domain.infrared.VirtualRemote import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
@@ -36,6 +41,10 @@ class RemotePanelViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val virtualRemoteRepository: VirtualRemoteRepository, private val virtualRemoteRepository: VirtualRemoteRepository,
private val sendVirtualRemoteAction: SendVirtualRemoteAction, private val sendVirtualRemoteAction: SendVirtualRemoteAction,
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
private val addVirtualRemoteKey: AddVirtualRemoteKey,
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
private val updateVirtualRemoteLayout: UpdateVirtualRemoteLayout,
) : ViewModel() { ) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"] private val rawProfileId: String? = savedStateHandle["profileId"]
@@ -44,6 +53,8 @@ class RemotePanelViewModel @Inject constructor(
private val rawRemoteId: String? = savedStateHandle["remoteId"] private val rawRemoteId: String? = savedStateHandle["remoteId"]
val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) } val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) }
private val isLearnModeFlow = MutableStateFlow(false)
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
private val sendingButtonIdFlow = MutableStateFlow<String?>(null) private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null) private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null) private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
@@ -67,21 +78,45 @@ class RemotePanelViewModel @Inject constructor(
} }
} }
val state: StateFlow<RemotePanelUiState> = combine( private data class RemoteSnapshot(
remoteFlow, val remote: VirtualRemote?,
bindingsFlow, val bindings: List<RemoteKeyBinding>,
localAcStateFlow, )
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, sendingButtonIdFlow,
) { isLearn, learningBtn, sendingBtn ->
ActionState(isLearn, learningBtn, sendingBtn)
}
val state: StateFlow<RemotePanelUiState> = combine(
snapshotFlow,
localAcStateFlow,
actionStateFlow,
messageFlow, messageFlow,
) { remote, bindings, localAc, sendingBtn, msg -> ) { snapshot, localAc, action, msg ->
val resolvedAc = localAc ?: remote?.acState ?: AcStateSnapshot() val resolvedAc = localAc ?: snapshot.remote?.acState ?: AcStateSnapshot()
RemotePanelUiState( RemotePanelUiState(
profileId = profileId, profileId = profileId,
remote = remote, remote = snapshot.remote,
bindings = bindings, bindings = snapshot.bindings,
acState = resolvedAc, acState = resolvedAc,
sendingButtonId = sendingBtn, isLearnMode = action.isLearnMode,
loading = remote == null, learningButtonId = action.learningButtonId,
sendingButtonId = action.sendingButtonId,
loading = snapshot.remote == null,
message = msg, message = msg,
) )
}.stateIn( }.stateIn(
@@ -90,10 +125,26 @@ class RemotePanelViewModel @Inject constructor(
initialValue = RemotePanelUiState(profileId = profileId, loading = true), 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) { fun onButtonClick(buttonId: String) {
if (isLearnModeFlow.value) {
learnButton(buttonId)
} else {
sendButton(buttonId)
}
}
private fun sendButton(buttonId: String) {
val currentProfile = profileId ?: return val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return val currentRemoteId = remoteId ?: return
if (state.value.sendingButtonId != null) return if (state.value.sendingButtonId != null || state.value.learningButtonId != null) return
sendingButtonIdFlow.value = buttonId sendingButtonIdFlow.value = buttonId
viewModelScope.launch { 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() { fun toggleAcPower() {
val current = state.value.acState val current = state.value.acState
updateAndDebounceAc(current.copy(power = !current.power)) updateAndDebounceAc(current.copy(power = !current.power))
@@ -5,11 +5,16 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.AcFanSpeed import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode 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.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.EpochMillisClock 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.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.TransmissionMode 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.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
@@ -27,6 +32,7 @@ import kotlinx.coroutines.test.setMain
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
@@ -44,6 +50,10 @@ class RemotePanelViewModelTest {
private val gateway = FakeInfraredGateway() private val gateway = FakeInfraredGateway()
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway) 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 @Before
fun setup() { fun setup() {
@@ -59,10 +69,14 @@ class RemotePanelViewModelTest {
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)), savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)),
virtualRemoteRepository = remoteRepo, virtualRemoteRepository = remoteRepo,
sendVirtualRemoteAction = sendAction, sendVirtualRemoteAction = sendAction,
learnVirtualRemoteKey = learnKey,
addVirtualRemoteKey = addKey,
deleteVirtualRemoteKey = deleteKey,
updateVirtualRemoteLayout = updateLayout,
) )
@Test @Test
fun tvButtonTriggersInstantTransmission() = testScope.runTest { fun tvButtonTriggersInstantTransmissionInNormalMode() = testScope.runTest {
val res = createRemote( val res = createRemote(
profileId = profileId, profileId = profileId,
deviceName = device, deviceName = device,
@@ -86,6 +100,93 @@ class RemotePanelViewModelTest {
job.cancel() 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 @Test
fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest { fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest {
val res = createRemote( val res = createRemote(
@@ -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<RemoteKeyBinding> {
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)
}
}
@@ -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<Unit> = repository.deleteKeyBinding(remoteId, buttonId)
}
@@ -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<Unit> {
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
}
}
}
}
@@ -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<Unit> {
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)
}
}
@@ -18,6 +18,13 @@ enum class TransmissionMode {
DYNAMIC_SCRATCH, DYNAMIC_SCRATCH,
} }
enum class PanelLayoutStyle {
TV_NAV,
GRID,
MEDIA_PLAYER,
AC_COMPACT,
}
enum class AcMode { enum class AcMode {
COOL, COOL,
HEAT, HEAT,
@@ -50,6 +57,7 @@ data class VirtualRemote(
val displayName: String, val displayName: String,
val type: RemoteDeviceType, val type: RemoteDeviceType,
val brandModel: String? = null, val brandModel: String? = null,
val layoutStyle: PanelLayoutStyle = defaultLayoutFor(type),
val transmissionMode: TransmissionMode, val transmissionMode: TransmissionMode,
val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT, val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT,
val acState: AcStateSnapshot? = null, val acState: AcStateSnapshot? = null,
@@ -58,6 +66,13 @@ data class VirtualRemote(
) { ) {
companion object { companion object {
const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100" 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
}
} }
} }
@@ -48,6 +48,8 @@ interface VirtualRemoteRepository {
suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit>
suspend fun deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult<Unit>
suspend fun updateAcState( suspend fun updateAcState(
profileId: ProviderProfileId, profileId: ProviderProfileId,
remoteId: VirtualRemoteId, remoteId: VirtualRemoteId,
@@ -311,6 +311,17 @@ class InMemoryVirtualRemoteRepository : VirtualRemoteRepository {
return DomainResult.Success(Unit) return DomainResult.Success(Unit)
} }
override suspend fun deleteKeyBinding(
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
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( override suspend fun updateAcState(
profileId: ProviderProfileId, profileId: ProviderProfileId,
remoteId: VirtualRemoteId, remoteId: VirtualRemoteId,
@@ -113,6 +113,17 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository {
return DomainResult.Success(Unit) return DomainResult.Success(Unit)
} }
override suspend fun deleteKeyBinding(
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
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( override suspend fun updateAcState(
profileId: ProviderProfileId, profileId: ProviderProfileId,
remoteId: VirtualRemoteId, remoteId: VirtualRemoteId,