From 9dc5226d83a8ada9f4f67d4d9f89ea57c0b06632 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 15:25:32 +0800 Subject: [PATCH] feat: build custom remote visual components and Compose panels --- .../remote/InfraredRemoteScreenTest.kt | 45 ++ .../infrared/remote/ui/AcRemotePanel.kt | 172 ++++++++ .../remote/ui/InfraredRemoteScreen.kt | 246 +++++++++++ .../infrared/remote/ui/RemoteComponents.kt | 407 ++++++++++++++++++ .../infrared/remote/ui/TvRemotePanel.kt | 161 +++++++ 5 files changed, 1031 insertions(+) create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt new file mode 100644 index 0000000..64f9a7b --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -0,0 +1,45 @@ +package com.flagship.abox.manager.infrared.remote + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredRemoteScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun rendersAcPanelAndTvPanelCorrectly() { + val state = InfraredRemoteUiState( + selectedScene = RemoteScene.AIR_CONDITIONER, + savedDeviceName = DeviceName("ir-1"), + ) + var clickedSpec: RemoteButtonSpec? = null + + composeTestRule.setContent { + InfraredRemoteScreen( + state = state, + onBack = {}, + onOpenSettings = {}, + onSessionInvalid = {}, + onSceneSelected = {}, + onToggleLearningMode = {}, + onButtonClick = { clickedSpec = it }, + onMessageShown = {}, + ) + } + + composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick() + assertEquals("AC_POWER", clickedSpec?.id) + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt new file mode 100644 index 0000000..d691ae2 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt @@ -0,0 +1,172 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec +import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig + +@Composable +fun AcRemotePanel( + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, + modifier: Modifier = Modifier, +) { + RemoteChassis(modifier = modifier) { + // 顶部虚拟 LCD 液晶显示区 + AcLcdDisplay() + + // 电源与温度控制行 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // 电源按键 + val powerSpec = RemoteSceneConfig.AirConditioner.POWER + val isPowerLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY + val isPowerLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot + + RemoteCircleButton( + spec = powerSpec, + isLearned = isPowerLearned, + isLoading = isPowerLoading, + isLearningMode = isLearningMode, + onClick = { onButtonClick(powerSpec) }, + size = 72.dp, + containerColor = Color(0xFFE53935), + contentColor = Color.White, + ) + + // 垂直胶囊温度调节器 + val tempUpSpec = RemoteSceneConfig.AirConditioner.TEMP_UP + val tempDownSpec = RemoteSceneConfig.AirConditioner.TEMP_DOWN + + VerticalCapsuleRocker( + upSpec = tempUpSpec, + downSpec = tempDownSpec, + isUpLearned = learnedSlotMap[tempUpSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[tempDownSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == tempUpSpec.defaultSlot || sendingKeySlot == tempUpSpec.defaultSlot, + isDownLoading = activeLearningSlot == tempDownSpec.defaultSlot || sendingKeySlot == tempDownSpec.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(tempUpSpec) }, + onDownClick = { onButtonClick(tempDownSpec) }, + label = "温度调节", + ) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 功能按键网格 (模式 / 风速 / 扫风 / 定时) + Column( + verticalArrangement = Arrangement.spacedBy(14.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AcGridButton(RemoteSceneConfig.AirConditioner.MODE, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + AcGridButton(RemoteSceneConfig.AirConditioner.FAN, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + AcGridButton(RemoteSceneConfig.AirConditioner.SWING, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + AcGridButton(RemoteSceneConfig.AirConditioner.TIMER, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + } + } + } +} + +@Composable +private fun AcGridButton( + spec: RemoteButtonSpec, + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, +) { + RemoteRectButton( + spec = spec, + isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(spec) }, + width = 120.dp, + height = 52.dp, + ) +} + +@Composable +private fun AcLcdDisplay() { + Card( + modifier = Modifier + .fillMaxWidth() + .height(96.dp) + .shadow(4.dp, shape = RoundedCornerShape(16.dp)), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = Color(0xFF1E293B), + ), + ) { + Row( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = "❄️ 制冷模式", + color = Color(0xFF38BDF8), + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Text( + text = "💨 自动风速 | ↔ 扫风", + color = Color(0xFF94A3B8), + fontSize = 12.sp, + ) + } + + Text( + text = "26°C", + color = Color(0xFF4ADE80), + fontSize = 38.sp, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Bold, + ) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt new file mode 100644 index 0000000..bd3e38e --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -0,0 +1,246 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +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.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState +import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec +import com.flagship.abox.manager.infrared.remote.RemoteScene +import com.flagship.abox.manager.infrared.remote.RemoteUiMessage + +@Composable +fun InfraredRemoteRoute( + onBack: () -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: InfraredRemoteViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InfraredRemoteScreen( + state = state, + onBack = onBack, + onOpenSettings = onOpenSettings, + onSessionInvalid = onSessionInvalid, + onSceneSelected = viewModel::switchScene, + onToggleLearningMode = viewModel::toggleLearningMode, + onButtonClick = viewModel::onButtonClick, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredRemoteScreen( + state: InfraredRemoteUiState, + onBack: () -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + onSceneSelected: (RemoteScene) -> Unit, + onToggleLearningMode: (Boolean) -> Unit, + onButtonClick: (RemoteButtonSpec) -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is RemoteUiMessage.Info -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is RemoteUiMessage.Error -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is RemoteUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = "红外遥控器", + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + ) + }, + navigationIcon = { + TextButton(onClick = onBack) { + Text("返回") + } + }, + actions = { + // 学习模式开关 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = if (state.isLearningMode) "录制中" else "学习模式", + fontSize = 12.sp, + fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal, + color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + Switch( + checked = state.isLearningMode, + onCheckedChange = onToggleLearningMode, + modifier = Modifier.testTag("remote_learning_mode_switch"), + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.error, + checkedTrackColor = MaterialTheme.colorScheme.errorContainer, + ), + ) + } + + // 设置齿轮按钮 + IconButton( + onClick = onOpenSettings, + modifier = Modifier.testTag("remote_settings_button"), + ) { + Text("⚙️", fontSize = 20.sp) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 学习模式顶部横幅提示 + if (state.isLearningMode) { + Box( + modifier = Modifier + .fillMaxWidth() + .background( + color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f), + shape = RoundedCornerShape(12.dp), + ) + .padding(vertical = 8.dp, horizontal = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "🔴 红外学习模式已开启:点击任意按键即可录制信号", + style = MaterialTheme.typography.bodyMedium.copy( + color = MaterialTheme.colorScheme.onErrorContainer, + fontWeight = FontWeight.SemiBold, + ), + ) + } + } + + // 场景切换 Tab (空调 | 电视) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + FilterChip( + selected = state.selectedScene == RemoteScene.AIR_CONDITIONER, + onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) }, + label = { Text("❄️ 空调遥控") }, + modifier = Modifier + .padding(end = 8.dp) + .testTag("scene_tab_ac"), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + FilterChip( + selected = state.selectedScene == RemoteScene.TELEVISION, + onClick = { onSceneSelected(RemoteScene.TELEVISION) }, + label = { Text("📺 电视遥控") }, + modifier = Modifier.testTag("scene_tab_tv"), + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + + // 对应场景遥控器面板 + when (state.selectedScene) { + RemoteScene.AIR_CONDITIONER -> { + AcRemotePanel( + learnedSlotMap = state.learnedSlotMap, + activeLearningSlot = state.activeLearningSlot, + sendingKeySlot = state.sendingKeySlot, + isLearningMode = state.isLearningMode, + onButtonClick = onButtonClick, + ) + } + RemoteScene.TELEVISION -> { + TvRemotePanel( + learnedSlotMap = state.learnedSlotMap, + activeLearningSlot = state.activeLearningSlot, + sendingKeySlot = state.sendingKeySlot, + isLearningMode = state.isLearningMode, + onButtonClick = onButtonClick, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + } + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt new file mode 100644 index 0000000..3dc9e39 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt @@ -0,0 +1,407 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Divider +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec + +@Composable +fun RemoteChassis( + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .shadow(12.dp, shape = RoundedCornerShape(32.dp)), + shape = RoundedCornerShape(32.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f), + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + content() + } + } +} + +@Composable +fun RemoteCircleButton( + spec: RemoteButtonSpec, + isLearned: Boolean, + isLoading: Boolean, + isLearningMode: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + size: Dp = 64.dp, + containerColor: Color? = null, + contentColor: Color? = null, +) { + val defaultContainer = when { + isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + isLearned -> containerColor ?: MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surface + } + + val defaultContent = when { + isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer + isLearned -> contentColor ?: MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + } + + val borderModifier = if (isLearningMode) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape) + } else if (!isLearned) { + Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape) + } else { + Modifier + } + + Surface( + modifier = modifier + .size(size) + .then(borderModifier) + .testTag("remote_button_${spec.id}"), + shape = CircleShape, + color = defaultContainer, + contentColor = defaultContent, + shadowElevation = if (isLearned) 4.dp else 1.dp, + onClick = onClick, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(size * 0.45f), + strokeWidth = 2.5.dp, + color = defaultContent, + ) + } else { + Text( + text = spec.symbol, + style = MaterialTheme.typography.titleMedium.copy( + fontSize = if (spec.symbol.length <= 2) 22.sp else 14.sp, + fontWeight = FontWeight.Bold, + ), + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +fun RemoteRectButton( + spec: RemoteButtonSpec, + isLearned: Boolean, + isLoading: Boolean, + isLearningMode: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + width: Dp = 100.dp, + height: Dp = 48.dp, +) { + val containerColor = when { + isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + isLearned -> MaterialTheme.colorScheme.secondaryContainer + else -> MaterialTheme.colorScheme.surface + } + val contentColor = when { + isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer + isLearned -> MaterialTheme.colorScheme.onSecondaryContainer + else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + } + + val borderModifier = if (isLearningMode) { + Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp)) + } else if (!isLearned) { + Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(16.dp)) + } else { + Modifier + } + + Surface( + modifier = modifier + .width(width) + .height(height) + .then(borderModifier) + .testTag("remote_button_${spec.id}"), + shape = RoundedCornerShape(16.dp), + color = containerColor, + contentColor = contentColor, + shadowElevation = if (isLearned) 3.dp else 1.dp, + onClick = onClick, + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = contentColor, + ) + } else { + Text( + text = spec.symbol, + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +fun VerticalCapsuleRocker( + upSpec: RemoteButtonSpec, + downSpec: RemoteButtonSpec, + isUpLearned: Boolean, + isDownLearned: Boolean, + isUpLoading: Boolean, + isDownLoading: Boolean, + isLearningMode: Boolean, + onUpClick: () -> Unit, + onDownClick: () -> Unit, + modifier: Modifier = Modifier, + label: String = "", +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Card( + modifier = Modifier + .width(68.dp) + .height(136.dp) + .shadow(4.dp, shape = RoundedCornerShape(34.dp)), + shape = RoundedCornerShape(34.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + // Top half + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp)) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(onClick = onUpClick) + .testTag("remote_button_${upSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isUpLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text( + text = upSpec.symbol, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + // Bottom half + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .clip(RoundedCornerShape(bottomStart = 34.dp, bottomEnd = 34.dp)) + .background( + if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f) + else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) + else Color.Transparent, + ) + .clickable(onClick = onDownClick) + .testTag("remote_button_${downSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isDownLoading) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text( + text = downSpec.symbol, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + } + } + } + } + if (label.isNotBlank()) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +fun RemoteDpadWheel( + upSpec: RemoteButtonSpec, + downSpec: RemoteButtonSpec, + leftSpec: RemoteButtonSpec, + rightSpec: RemoteButtonSpec, + okSpec: RemoteButtonSpec, + isUpLearned: Boolean, + isDownLearned: Boolean, + isLeftLearned: Boolean, + isRightLearned: Boolean, + isOkLearned: Boolean, + isUpLoading: Boolean, + isDownLoading: Boolean, + isLeftLoading: Boolean, + isRightLoading: Boolean, + isOkLoading: Boolean, + isLearningMode: Boolean, + onUpClick: () -> Unit, + onDownClick: () -> Unit, + onLeftClick: () -> Unit, + onRightClick: () -> Unit, + onOkClick: () -> Unit, + modifier: Modifier = Modifier, + wheelSize: Dp = 190.dp, +) { + Card( + modifier = modifier + .size(wheelSize) + .shadow(6.dp, shape = CircleShape), + shape = CircleShape, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + // Up Button + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .padding(top = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onUpClick) + .testTag("remote_button_${upSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isUpLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(upSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Down Button + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onDownClick) + .testTag("remote_button_${downSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isDownLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(downSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Left Button + Box( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onLeftClick) + .testTag("remote_button_${leftSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isLeftLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(leftSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Right Button + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .padding(end = 10.dp) + .size(54.dp) + .clip(CircleShape) + .clickable(onClick = onRightClick) + .testTag("remote_button_${rightSpec.id}"), + contentAlignment = Alignment.Center, + ) { + if (isRightLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp)) + else Text(rightSpec.symbol, style = MaterialTheme.typography.titleLarge) + } + + // Center OK Button + RemoteCircleButton( + spec = okSpec, + isLearned = isOkLearned, + isLoading = isOkLoading, + isLearningMode = isLearningMode, + onClick = onOkClick, + size = 62.dp, + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt new file mode 100644 index 0000000..2c80c9d --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt @@ -0,0 +1,161 @@ +package com.flagship.abox.manager.infrared.remote.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec +import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig + +@Composable +fun TvRemotePanel( + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, + modifier: Modifier = Modifier, +) { + RemoteChassis(modifier = modifier) { + // 顶部电源与静音行 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val powerSpec = RemoteSceneConfig.Television.POWER + RemoteCircleButton( + spec = powerSpec, + isLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(powerSpec) }, + size = 56.dp, + containerColor = Color(0xFFE53935), + contentColor = Color.White, + ) + + val muteSpec = RemoteSceneConfig.Television.MUTE + RemoteCircleButton( + spec = muteSpec, + isLearned = learnedSlotMap[muteSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == muteSpec.defaultSlot || sendingKeySlot == muteSpec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(muteSpec) }, + size = 56.dp, + ) + } + + // 中心圆形方向轮盘 (D-Pad Wheel) + val upSpec = RemoteSceneConfig.Television.NAV_UP + val downSpec = RemoteSceneConfig.Television.NAV_DOWN + val leftSpec = RemoteSceneConfig.Television.NAV_LEFT + val rightSpec = RemoteSceneConfig.Television.NAV_RIGHT + val okSpec = RemoteSceneConfig.Television.OK + + RemoteDpadWheel( + upSpec = upSpec, + downSpec = downSpec, + leftSpec = leftSpec, + rightSpec = rightSpec, + okSpec = okSpec, + isUpLearned = learnedSlotMap[upSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[downSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLeftLearned = learnedSlotMap[leftSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isRightLearned = learnedSlotMap[rightSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isOkLearned = learnedSlotMap[okSpec.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == upSpec.defaultSlot || sendingKeySlot == upSpec.defaultSlot, + isDownLoading = activeLearningSlot == downSpec.defaultSlot || sendingKeySlot == downSpec.defaultSlot, + isLeftLoading = activeLearningSlot == leftSpec.defaultSlot || sendingKeySlot == leftSpec.defaultSlot, + isRightLoading = activeLearningSlot == rightSpec.defaultSlot || sendingKeySlot == rightSpec.defaultSlot, + isOkLoading = activeLearningSlot == okSpec.defaultSlot || sendingKeySlot == okSpec.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(upSpec) }, + onDownClick = { onButtonClick(downSpec) }, + onLeftClick = { onButtonClick(leftSpec) }, + onRightClick = { onButtonClick(rightSpec) }, + onOkClick = { onButtonClick(okSpec) }, + ) + + // 系统功能按键行 (返回 / 主页 / 菜单) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + TvActionButton(RemoteSceneConfig.Television.BACK, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + TvActionButton(RemoteSceneConfig.Television.HOME, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + TvActionButton(RemoteSceneConfig.Television.MENU, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick) + } + + Spacer(modifier = Modifier.height(4.dp)) + + // 音量与频道双摇杆胶囊 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + // 音量控制 + val volUp = RemoteSceneConfig.Television.VOL_UP + val volDown = RemoteSceneConfig.Television.VOL_DOWN + VerticalCapsuleRocker( + upSpec = volUp, + downSpec = volDown, + isUpLearned = learnedSlotMap[volUp.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[volDown.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == volUp.defaultSlot || sendingKeySlot == volUp.defaultSlot, + isDownLoading = activeLearningSlot == volDown.defaultSlot || sendingKeySlot == volDown.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(volUp) }, + onDownClick = { onButtonClick(volDown) }, + label = "音量 VOL", + ) + + // 频道控制 + val chUp = RemoteSceneConfig.Television.CH_UP + val chDown = RemoteSceneConfig.Television.CH_DOWN + VerticalCapsuleRocker( + upSpec = chUp, + downSpec = chDown, + isUpLearned = learnedSlotMap[chUp.defaultSlot]?.status == InfraredKeyStatus.READY, + isDownLearned = learnedSlotMap[chDown.defaultSlot]?.status == InfraredKeyStatus.READY, + isUpLoading = activeLearningSlot == chUp.defaultSlot || sendingKeySlot == chUp.defaultSlot, + isDownLoading = activeLearningSlot == chDown.defaultSlot || sendingKeySlot == chDown.defaultSlot, + isLearningMode = isLearningMode, + onUpClick = { onButtonClick(chUp) }, + onDownClick = { onButtonClick(chDown) }, + label = "频道 CH", + ) + } + } +} + +@Composable +private fun TvActionButton( + spec: RemoteButtonSpec, + learnedSlotMap: Map, + activeLearningSlot: String?, + sendingKeySlot: String?, + isLearningMode: Boolean, + onButtonClick: (RemoteButtonSpec) -> Unit, +) { + RemoteRectButton( + spec = spec, + isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY, + isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot, + isLearningMode = isLearningMode, + onClick = { onButtonClick(spec) }, + width = 84.dp, + height = 44.dp, + ) +}