feat: build custom remote visual components and Compose panels

This commit is contained in:
2026-08-24 15:25:32 +08:00
parent 56917e3c8e
commit b022ff0b0a
5 changed files with 1031 additions and 0 deletions
@@ -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)
}
}
@@ -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<String, SavedInfraredKey>,
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<String, SavedInfraredKey>,
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,
)
}
}
}
@@ -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))
}
}
}
}
@@ -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,
)
}
}
}
@@ -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<String, SavedInfraredKey>,
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<String, SavedInfraredKey>,
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,
)
}