feat(infrared): add infrared control, learning, and virtual remote module #9
+8
@@ -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
|
||||||
|
|||||||
+1
@@ -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?,
|
||||||
|
|||||||
+17
-1
@@ -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,
|
||||||
|
|||||||
+29
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+381
-61
@@ -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,24 +280,52 @@ 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
|
||||||
@@ -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) },
|
||||||
) {
|
)
|
||||||
if (sendingButtonId == binding.buttonId) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White)
|
|
||||||
} else {
|
|
||||||
Text(binding.displayName, fontSize = 16.sp, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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 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,
|
||||||
|
|||||||
+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.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))
|
||||||
|
|||||||
+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.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(
|
||||||
|
|||||||
+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,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
@@ -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,
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
|||||||
+11
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user