feat(infrared): add virtual remote learn mode, custom layout switching, and key management
This commit is contained in:
+8
@@ -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
|
||||
|
||||
+1
@@ -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?,
|
||||
|
||||
+17
-1
@@ -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<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(
|
||||
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,
|
||||
|
||||
+29
@@ -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)
|
||||
}
|
||||
|
||||
+422
-102
@@ -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<RemoteKeyBinding>,
|
||||
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<RemoteKeyBinding>,
|
||||
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<RemoteKeyBinding>,
|
||||
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<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(
|
||||
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("取消") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -17,6 +17,8 @@ data class RemotePanelUiState(
|
||||
val remote: VirtualRemote? = null,
|
||||
val bindings: List<RemoteKeyBinding> = 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,
|
||||
|
||||
+122
-12
@@ -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<String?>(null)
|
||||
private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
|
||||
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
|
||||
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
|
||||
@@ -67,21 +78,45 @@ class RemotePanelViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<RemotePanelUiState> = combine(
|
||||
remoteFlow,
|
||||
bindingsFlow,
|
||||
localAcStateFlow,
|
||||
private data class RemoteSnapshot(
|
||||
val remote: VirtualRemote?,
|
||||
val bindings: List<RemoteKeyBinding>,
|
||||
)
|
||||
|
||||
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<RemotePanelUiState> = 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))
|
||||
|
||||
+102
-1
@@ -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(
|
||||
|
||||
+53
@@ -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)
|
||||
}
|
||||
}
|
||||
+14
@@ -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)
|
||||
}
|
||||
+73
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -48,6 +48,8 @@ interface VirtualRemoteRepository {
|
||||
|
||||
suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit>
|
||||
|
||||
suspend fun deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult<Unit>
|
||||
|
||||
suspend fun updateAcState(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
|
||||
+11
@@ -311,6 +311,17 @@ class InMemoryVirtualRemoteRepository : VirtualRemoteRepository {
|
||||
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(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
|
||||
+11
@@ -113,6 +113,17 @@ class FakeVirtualRemoteRepository : VirtualRemoteRepository {
|
||||
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(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
|
||||
Reference in New Issue
Block a user