feat(ui): complete app experience redesign

This commit is contained in:
2026-08-27 11:45:30 +08:00
parent 816606234b
commit 54aa74126f
70 changed files with 4510 additions and 985 deletions
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.infrared.hub
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredHubScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun rendersHardwareSummaryAndRemoteCollection() {
val profileId = ProviderProfileId("p1")
val remote = VirtualRemote(
id = VirtualRemoteId("remote-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
displayName = "电视遥控器",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 1L,
)
composeRule.setContent {
InfraredHubScreen(
state = InfraredHubUiState(
profileId = profileId,
savedDeviceName = DeviceName("ir-1"),
remotes = listOf(remote),
loading = false,
),
onBack = {},
onNavigateToSettings = {},
onOpenRemote = {},
onDeleteRemote = {},
onCreateRemote = { _, _, _, _, _ -> },
onSessionInvalid = {},
onMessageShown = {},
)
}
composeRule.onNodeWithTag("infrared_hardware_summary").assertIsDisplayed()
composeRule.onNodeWithTag("infrared_remote_collection").assertIsDisplayed()
}
}
@@ -0,0 +1,66 @@
package com.flagship.abox.manager.infrared.panel
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RemotePanelScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun acPanelUsesTemperatureStepperSurface() {
composeRule.setContent {
RemotePanelScreen(
state = RemotePanelUiState(remote = remote(PanelLayoutStyle.AC_COMPACT, RemoteDeviceType.AIR_CONDITIONER), loading = false),
onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {},
onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {},
onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {},
onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {},
)
}
composeRule.onNodeWithTag("ac_temperature_stepper").assertIsDisplayed()
composeRule.onNodeWithTag("ac_mode_selector").assertIsDisplayed()
}
@Test
fun tvPanelUsesDirectionalPadAndRockers() {
composeRule.setContent {
RemotePanelScreen(
state = RemotePanelUiState(remote = remote(PanelLayoutStyle.TV_NAV, RemoteDeviceType.TV), loading = false),
onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {},
onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {},
onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {},
onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {},
)
}
composeRule.onNodeWithTag("tv_direction_pad").assertIsDisplayed()
composeRule.onNodeWithTag("tv_volume_channel_rockers").assertIsDisplayed()
}
private fun remote(layout: PanelLayoutStyle, type: RemoteDeviceType) = VirtualRemote(
id = VirtualRemoteId("remote-${layout.name}"),
profileId = ProviderProfileId("p1"),
deviceName = DeviceName("ir-1"),
displayName = "测试遥控器",
type = type,
layoutStyle = layout,
transmissionMode = if (layout == PanelLayoutStyle.AC_COMPACT) TransmissionMode.DYNAMIC_SCRATCH else TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 1L,
)
}
@@ -211,11 +211,26 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi
displayName = displayName,
assignedSlot = assignedSlot,
rawHexCode = rawHexCode,
status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY),
status = resolvedStatus(),
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
)
private fun RemoteKeyBindingEntity.resolvedStatus(): InfraredKeyStatus {
val parsed = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.UNLEARNED)
return if (
parsed == InfraredKeyStatus.FAILED &&
lastErrorCode == null &&
lastErrorDetail == null &&
assignedSlot == null &&
rawHexCode == null
) {
InfraredKeyStatus.UNLEARNED
} else {
parsed
}
}
private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity(
id = id,
remoteId = remoteId.value,
@@ -0,0 +1,414 @@
package com.flagship.abox.manager.infrared.hub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.KeyboardArrowUp
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material.icons.outlined.VolumeOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
@Composable
internal fun InfraredCenterControlSurface(
state: InfraredHubUiState,
onButtonClick: (String) -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleAcVerticalSwing: () -> Unit,
onToggleAcHorizontalSwing: () -> Unit,
onToggleAcEco: () -> Unit,
modifier: Modifier = Modifier,
) {
val remote = state.selectedRemote ?: return
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> CenterAcPanel(
state = state,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVerticalSwing = onToggleAcVerticalSwing,
onToggleHorizontalSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
modifier = modifier,
)
PanelLayoutStyle.TV_NAV -> CenterTvPanel(state, onButtonClick, modifier)
PanelLayoutStyle.MEDIA_PLAYER -> CenterMediaPanel(state, onButtonClick, modifier)
PanelLayoutStyle.GRID -> CenterGridPanel(
remote = remote,
bindings = state.bindings,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
onAddButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
modifier = modifier,
)
}
}
@Composable
private fun CenterAcPanel(
state: InfraredHubUiState,
onTogglePower: () -> Unit,
onAdjustTemp: (Int) -> Unit,
onSetMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleVerticalSwing: () -> Unit,
onToggleHorizontalSwing: () -> Unit,
onToggleEco: () -> Unit,
modifier: Modifier,
) {
val ac = state.acState
Column(
modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Box(Modifier.fillMaxWidth().padding(20.dp)) {
IconButton(
onClick = onTogglePower,
modifier = Modifier.align(Alignment.TopEnd).background(
if (ac.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
CircleShape,
),
) {
Icon(Icons.Outlined.PowerSettingsNew, "空调电源", tint = Color.White)
}
Row(
Modifier.fillMaxWidth().padding(top = 38.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
StepButton(Icons.Outlined.Remove, "降低温度") { onAdjustTemp(-1) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("${ac.temperature}°", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.infrared_set_temperature), style = MaterialTheme.typography.labelMedium)
}
StepButton(Icons.Outlined.Add, "升高温度") { onAdjustTemp(1) }
}
}
}
CompactOptionGroup(stringResource(R.string.infrared_running_mode)) {
AcMode.entries.forEach { mode ->
FilterChip(ac.mode == mode, { onSetMode(mode) }, { Text(mode.centerLabel()) })
}
}
CompactOptionGroup(stringResource(R.string.infrared_fan_speed)) {
AcFanSpeed.entries.forEach { speed ->
FilterChip(ac.fanSpeed == speed, { onSetFanSpeed(speed) }, { Text(speed.centerLabel()) })
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CompactToggle(stringResource(R.string.infrared_vertical_swing), ac.verticalSwing, onToggleVerticalSwing, Modifier.weight(1f))
CompactToggle(stringResource(R.string.infrared_horizontal_swing), ac.horizontalSwing, onToggleHorizontalSwing, Modifier.weight(1f))
CompactToggle(stringResource(R.string.infrared_eco), ac.eco, onToggleEco, Modifier.weight(1f))
}
Text(
stringResource(R.string.infrared_transmission_notice),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun CenterTvPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) {
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteIconKey("TV_POWER", stringResource(R.string.infrared_key_tv_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick)
RemoteIconKey("TV_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick)
RemoteIconKey("TV_SOURCE", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Tv, state, onButtonClick)
}
Card(
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth(),
) {
Box(Modifier.fillMaxWidth().height(260.dp), contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(220.dp)) {
Box(Modifier.size(220.dp), contentAlignment = Alignment.Center) {
DirectionKey("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, stringResource(R.string.infrared_up), Alignment.TopCenter, state, onButtonClick)
DirectionKey("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, stringResource(R.string.infrared_down), Alignment.BottomCenter, state, onButtonClick)
DirectionKey("TV_NAV_LEFT", Icons.AutoMirrored.Outlined.KeyboardArrowLeft, stringResource(R.string.infrared_left), Alignment.CenterStart, state, onButtonClick)
DirectionKey("TV_NAV_RIGHT", Icons.AutoMirrored.Outlined.KeyboardArrowRight, stringResource(R.string.infrared_right), Alignment.CenterEnd, state, onButtonClick)
Button(onClick = { onButtonClick("TV_OK") }, shape = CircleShape, modifier = Modifier.size(72.dp)) { Text("OK") }
}
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteTextKey("TV_BACK", stringResource(R.string.infrared_key_tv_back), state, onButtonClick, Modifier.weight(1f))
RemoteIconTextKey("TV_HOME", stringResource(R.string.infrared_key_tv_home), Icons.Outlined.Home, state, onButtonClick, Modifier.weight(1f))
RemoteIconTextKey("TV_MENU", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Menu, state, onButtonClick, Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Rocker(stringResource(R.string.infrared_volume), "TV_VOL_UP", "TV_VOL_DOWN", state, onButtonClick, Modifier.weight(1f))
Rocker(stringResource(R.string.infrared_channel), "TV_CH_UP", "TV_CH_DOWN", state, onButtonClick, Modifier.weight(1f))
}
}
}
@Composable
private fun CenterMediaPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) {
Column(modifier.padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteIconKey("MEDIA_POWER", stringResource(R.string.infrared_key_ac_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick)
RemoteIconKey("MEDIA_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick)
}
Text(stringResource(R.string.infrared_media_panel), style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.CenterHorizontally))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("MEDIA_PREV" to "上一项", "MEDIA_PLAY_PAUSE" to "播放/暂停", "MEDIA_NEXT" to "下一项").forEach { (id, label) ->
RemoteTextKey(id, label, state, onButtonClick, Modifier.weight(1f))
}
}
}
}
@Composable
private fun CenterGridPanel(
remote: VirtualRemote,
bindings: List<RemoteKeyBinding>,
learningButtonId: String?,
sendingButtonId: String?,
onButtonClick: (String) -> Unit,
onAddButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
modifier: Modifier,
) {
var showAddDialog by remember(remote.id) { mutableStateOf(false) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.infrared_remote_keys, remote.displayName),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Button(onClick = { showAddDialog = true }, shape = RoundedCornerShape(6.dp)) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.infrared_add_button), modifier = Modifier.padding(start = 6.dp))
}
}
if (bindings.isEmpty()) {
Text(stringResource(R.string.infrared_no_buttons_manage), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
LazyVerticalGrid(columns = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(bindings, key = { it.id }) { binding ->
Card(
modifier = Modifier.fillMaxWidth().clickable { onButtonClick(binding.buttonId) },
shape = RoundedCornerShape(6.dp),
) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
binding.displayName,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
when {
learningButtonId == binding.buttonId -> stringResource(R.string.infrared_button_learning)
sendingButtonId == binding.buttonId -> stringResource(R.string.infrared_button_sending)
binding.status == InfraredKeyStatus.READY -> stringResource(R.string.infrared_button_ready)
binding.status == InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_button_failed)
binding.status == InfraredKeyStatus.UNLEARNED -> stringResource(R.string.infrared_button_unlearned)
else -> stringResource(R.string.infrared_button_unlearned)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = { onDeleteButton(binding.buttonId) },
modifier = Modifier.size(48.dp),
) {
Icon(Icons.Outlined.Remove, contentDescription = stringResource(R.string.infrared_delete_button))
}
}
}
}
}
}
}
if (showAddDialog) {
AddGridButtonDialog(
onDismiss = { showAddDialog = false },
onConfirm = { name ->
onAddButton(name)
showAddDialog = false
},
)
}
}
@Composable
private fun AddGridButtonDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_add_button_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
stringResource(R.string.infrared_add_button_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.infrared_button_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
Text(stringResource(R.string.infrared_add_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
@Composable private fun StepButton(icon: ImageVector, description: String, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(52.dp).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(6.dp))) {
Icon(icon, description)
}
}
@Composable private fun CompactOptionGroup(title: String, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
Card(shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), content = content)
}
}
}
@Composable private fun CompactToggle(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
Surface(
modifier = modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(6.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
) { Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 14.dp)) }
}
@Composable private fun RemoteIconKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(84.dp).clickable { onClick(id) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(20.dp)) else Icon(icon, label)
Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp))
}
}
}
@Composable private fun BoxScope.DirectionKey(id: String, icon: ImageVector, description: String, alignment: Alignment, state: InfraredHubUiState, onClick: (String) -> Unit) {
IconButton(onClick = { onClick(id) }, modifier = Modifier.align(alignment).padding(10.dp)) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Icon(icon, description, Modifier.size(30.dp))
}
}
@Composable private fun RemoteTextKey(id: String, label: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Text(label)
}
}
@Composable private fun RemoteIconTextKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) {
Icon(icon, label, Modifier.size(18.dp)); Text(label, Modifier.padding(start = 5.dp))
}
}
@Composable private fun Rocker(title: String, upId: String, downId: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Card(modifier, shape = RoundedCornerShape(6.dp)) {
Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
IconButton({ onClick(upId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Add, "提高$title") }
IconButton({ onClick(downId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Remove, "降低$title") }
}
}
}
private fun AcMode.centerLabel() = when (this) { AcMode.COOL -> "制冷"; AcMode.HEAT -> "制热"; AcMode.AUTO -> "自动"; AcMode.DRY -> "除湿"; AcMode.FAN -> "送风" }
private fun AcFanSpeed.centerLabel() = when (this) { AcFanSpeed.AUTO -> "自动"; AcFanSpeed.LOW -> "低速"; AcFanSpeed.MEDIUM -> "中速"; AcFanSpeed.HIGH -> "高速" }
@@ -2,6 +2,8 @@ package com.flagship.abox.manager.infrared.hub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,6 +18,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
@@ -24,6 +30,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
@@ -40,6 +48,9 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SwapVert
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -50,7 +61,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -60,6 +72,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
@@ -71,6 +85,8 @@ import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
@Composable
fun InfraredHubRoute(
@@ -89,6 +105,21 @@ fun InfraredHubRoute(
onOpenRemote = onOpenRemote,
onDeleteRemote = viewModel::deleteRemote,
onCreateRemote = viewModel::createRemote,
onSaveDeviceName = viewModel::saveDeviceName,
onMoveRemote = viewModel::moveRemote,
onUpdateRemote = viewModel::updateRemote,
onSelectRemote = viewModel::selectRemote,
onToggleLearnMode = viewModel::toggleLearnMode,
onButtonClick = viewModel::onButtonClick,
onAddCustomButton = viewModel::addCustomButton,
onDeleteButton = viewModel::deleteButton,
onToggleAcPower = viewModel::toggleAcPower,
onAdjustAcTemp = viewModel::adjustAcTemperature,
onSetAcMode = viewModel::setAcMode,
onSetFanSpeed = viewModel::setAcFanSpeed,
onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing,
onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing,
onToggleAcEco = viewModel::toggleAcEco,
onSessionInvalid = onSessionInvalid,
onMessageShown = viewModel::clearMessage,
)
@@ -103,12 +134,30 @@ internal fun InfraredHubScreen(
onOpenRemote: (String) -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit,
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
onSaveDeviceName: (String) -> Unit = {},
onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> },
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> },
onSessionInvalid: () -> Unit,
onMessageShown: () -> Unit,
onSelectRemote: (VirtualRemoteId) -> Unit = {},
onToggleLearnMode: () -> Unit = {},
onButtonClick: (String) -> Unit = {},
onAddCustomButton: (String) -> Unit = {},
onDeleteButton: (String) -> Unit = {},
onToggleAcPower: () -> Unit = {},
onAdjustAcTemp: (Int) -> Unit = {},
onSetAcMode: (AcMode) -> Unit = {},
onSetFanSpeed: (AcFanSpeed) -> Unit = {},
onToggleAcVerticalSwing: () -> Unit = {},
onToggleAcHorizontalSwing: () -> Unit = {},
onToggleAcEco: () -> Unit = {},
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
var showCreateDialog by remember { mutableStateOf(false) }
var showManagement by remember { mutableStateOf(false) }
BackHandler(enabled = showManagement) { showManagement = false }
LaunchedEffect(state.message) {
state.message?.let { msg ->
@@ -116,7 +165,7 @@ internal fun InfraredHubScreen(
is HubUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -124,7 +173,7 @@ internal fun InfraredHubScreen(
is HubUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -137,99 +186,114 @@ internal fun InfraredHubScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Column {
Text(
text = stringResource(R.string.infrared_hub_title),
fontWeight = FontWeight.Bold,
)
state.providerDisplayName?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
ABoxIconButton(ABoxIconKey.BACK, "返回", onBack)
},
actions = {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = "打开红外设置",
onClick = onNavigateToSettings,
modifier = Modifier.testTag("hub_settings_button"),
)
},
)
ABoxDeviceDetailScaffold(
title = state.savedDeviceName.value,
subtitle = if (showManagement) stringResource(R.string.infrared_settings_subtitle) else stringResource(R.string.infrared_center_subtitle),
icon = ABoxIconKey.INFRARED,
onBack = if (showManagement) ({ showManagement = false }) else onBack,
action = {
if (!showManagement) {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = stringResource(R.string.infrared_manage_action),
onClick = { showManagement = true },
modifier = Modifier.testTag("hub_settings_button"),
)
}
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
if (showManagement) {
InfraredManagementScreen(
state = state,
onAddRemote = { showCreateDialog = true },
onDeleteRemote = onDeleteRemote,
onSaveDeviceName = onSaveDeviceName,
onMoveRemote = onMoveRemote,
onUpdateRemote = onUpdateRemote,
)
} else Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// 1. 硬件槽位配额看板 (100 槽位预算)
QuotaSummaryCard(quota = state.slotQuota)
// 2. 遥控器列表标题与新建按钮
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.infrared_hub_my_remotes),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
Button(
onClick = { showCreateDialog = true },
modifier = Modifier.testTag("hub_add_remote_button"),
) {
Text(stringResource(R.string.infrared_hub_add_remote))
}
}
// 3. 遥控器网格卡片
if (state.remotes.isEmpty()) {
Box(
modifier = Modifier.fillMaxWidth().weight(1f),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_hub_no_remotes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
ABoxIcon(ABoxIconKey.INFRARED, stringResource(R.string.infrared_hub_no_remotes), modifier = Modifier.size(48.dp))
Text(stringResource(R.string.infrared_hub_no_remotes), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.infrared_no_remotes_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = { showManagement = true }) { Text(stringResource(R.string.infrared_open_management)) }
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxWidth().weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
LazyRow(
modifier = Modifier.fillMaxWidth().testTag("infrared_remote_collection"),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.remotes, key = { it.id.value }) { remote ->
VirtualRemoteCard(
remote = remote,
onClick = { onOpenRemote(remote.id.value) },
onDelete = { onDeleteRemote(remote.id) },
FilterChip(
selected = state.selectedRemoteId == remote.id,
onClick = { onSelectRemote(remote.id) },
label = { Text(remote.displayName) },
leadingIcon = {
ABoxIcon(
key = remoteIcon(remote.type),
contentDescription = remote.displayName,
modifier = Modifier.size(18.dp),
)
},
)
}
}
state.selectedRemote?.let { remote ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(remote.displayName, fontWeight = FontWeight.Bold)
Text(
text = "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (remote.type == RemoteDeviceType.CUSTOM) {
FilterChip(
selected = state.isLearnMode,
onClick = onToggleLearnMode,
label = { Text(if (state.isLearnMode) stringResource(R.string.infrared_recording) else stringResource(R.string.infrared_learn_mode)) },
)
}
}
}
InfraredCenterControlSurface(
state = state,
onButtonClick = onButtonClick,
onAddCustomButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
onToggleAcPower = onToggleAcPower,
onAdjustAcTemp = onAdjustAcTemp,
onSetAcMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleAcVerticalSwing = onToggleAcVerticalSwing,
onToggleAcHorizontalSwing = onToggleAcHorizontalSwing,
onToggleAcEco = onToggleAcEco,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
}
}
@@ -244,6 +308,226 @@ internal fun InfraredHubScreen(
},
)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.fillMaxSize().padding(bottom = 16.dp),
)
}
@Composable
private fun StableSingleLineField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun InfraredManagementScreen(
state: InfraredHubUiState,
onAddRemote: () -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit,
onSaveDeviceName: (String) -> Unit,
onMoveRemote: (VirtualRemoteId, Int) -> Unit,
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit,
) {
var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) }
var reorderRemoteId by remember { mutableStateOf<VirtualRemoteId?>(null) }
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.infrared_hardware_slots_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
InfraredHardwareSummary(
deviceName = state.savedDeviceName.value,
remoteCount = state.remotes.size,
quota = state.slotQuota,
)
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.infrared_hub_remotes_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.infrared_remote_count, state.remotes.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(onClick = onAddRemote) { Text(stringResource(R.string.infrared_add_remote)) }
}
state.remotes.forEach { remote ->
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) {
ABoxIcon(
remoteIcon(remote.type),
remote.displayName,
modifier = Modifier.padding(9.dp).size(24.dp),
)
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(remote.displayName, fontWeight = FontWeight.Bold)
Text(
"${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { reorderRemoteId = remote.id }) {
Icon(Icons.Outlined.SwapVert, contentDescription = stringResource(R.string.infrared_move_remote))
}
DropdownMenu(
expanded = reorderRemoteId == remote.id,
onDismissRequest = { reorderRemoteId = null },
) {
val index = state.remotes.indexOf(remote)
DropdownMenuItem(
text = { Text(stringResource(R.string.infrared_move_up)) },
enabled = index > 0,
onClick = { reorderRemoteId = null; onMoveRemote(remote.id, -1) },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.infrared_move_down)) },
enabled = index < state.remotes.lastIndex,
onClick = { reorderRemoteId = null; onMoveRemote(remote.id, 1) },
)
}
}
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote })
}
}
}
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.infrared_gateway_name_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.infrared_gateway_name_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StableSingleLineField(
value = deviceName,
onValueChange = { deviceName = it },
label = stringResource(R.string.infrared_device_name_label),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { onSaveDeviceName(deviceName.trim()) },
enabled = deviceName.isNotBlank() && deviceName != state.savedDeviceName.value,
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.infrared_save_name)) }
}
}
Spacer(Modifier.height(24.dp))
}
editingRemote?.let { remote ->
EditRemoteDialog(
remote = remote,
onDismiss = { editingRemote = null },
onSave = { name, layout, mode ->
editingRemote = null
onUpdateRemote(remote.id, name, layout, mode)
},
onDelete = {
editingRemote = null
onDeleteRemote(remote.id)
},
)
}
}
@Composable
private fun EditRemoteDialog(
remote: VirtualRemote,
onDismiss: () -> Unit,
onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit,
onDelete: () -> Unit,
) {
var name by remember(remote.id) { mutableStateOf(remote.displayName) }
var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) }
var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_edit_remote)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true)
Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold)
PanelLayoutStyle.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = layout == option, onClick = { layout = option })
Text(layoutLabel(option))
}
}
Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold)
TransmissionMode.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = mode == option, onClick = { mode = option })
Text(transmissionLabel(option))
}
}
TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } },
)
}
private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) {
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
@Composable
private fun layoutLabel(layout: PanelLayoutStyle): String = when (layout) {
PanelLayoutStyle.TV_NAV -> stringResource(R.string.infrared_layout_tv)
PanelLayoutStyle.AC_COMPACT -> stringResource(R.string.infrared_layout_ac)
PanelLayoutStyle.MEDIA_PLAYER -> stringResource(R.string.infrared_layout_media)
PanelLayoutStyle.GRID -> stringResource(R.string.infrared_layout_grid)
}
@Composable
private fun transmissionLabel(mode: TransmissionMode): String = when (mode) {
TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_static_slots)
TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes)
}
@Composable
@@ -271,15 +555,18 @@ private fun VirtualRemoteCard(
verticalAlignment = Alignment.CenterVertically,
) {
val icon = when (remote.type) {
RemoteDeviceType.TV -> "电视"
RemoteDeviceType.AIR_CONDITIONER -> "空调"
RemoteDeviceType.FAN -> "🌀"
RemoteDeviceType.CUSTOM -> "自定义"
}
Text(icon, fontSize = 28.sp)
IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) {
Text(stringResource(R.string.infrared_delete))
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp))
ABoxIconButton(
key = ABoxIconKey.CLOSE,
contentDescription = "删除${remote.displayName}",
onClick = onDelete,
modifier = Modifier.testTag("delete_remote_${remote.id.value}"),
)
}
Text(
@@ -305,9 +592,13 @@ private fun VirtualRemoteCard(
}
@Composable
private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
private fun InfraredHardwareSummary(
deviceName: String,
remoteCount: Int,
quota: SlotQuotaSummary,
) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag("infrared_hardware_summary"),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
@@ -323,13 +614,28 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
ABoxIcon(
key = ABoxIconKey.INFRARED,
contentDescription = "红外硬件",
modifier = Modifier.padding(10.dp).size(26.dp),
)
}
Column {
Text(deviceName, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(
text = "红外控制器 · $remoteCount 个虚拟遥控器",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = "硬件槽位配额 (总计 100 槽位)",
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Text(
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用",
text = "${quota.usedCount}/${quota.totalCapacity}",
fontWeight = FontWeight.Bold,
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
@@ -345,7 +651,7 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
)
Text(
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个",
text = "静态槽位 ${quota.assignedStaticSlots.size} 个 · 动态缓冲槽位 100 · 剩余 ${quota.remainingStaticCount} 个",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -4,7 +4,10 @@ import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.infrared.remote.UiText
sealed interface HubUiMessage {
@@ -19,6 +22,13 @@ data class InfraredHubUiState(
val providerDisplayName: String? = null,
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
val remotes: List<VirtualRemote> = emptyList(),
val selectedRemoteId: VirtualRemoteId? = null,
val selectedRemote: VirtualRemote? = null,
val bindings: List<RemoteKeyBinding> = emptyList(),
val acState: AcStateSnapshot = AcStateSnapshot(),
val isLearnMode: Boolean = false,
val learningButtonId: String? = null,
val sendingButtonId: String? = null,
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val loading: Boolean = true,
val message: HubUiMessage? = null,
@@ -9,9 +9,23 @@ import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.TransmissionMode
@@ -30,7 +44,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -43,12 +60,23 @@ class InfraredHubViewModel @Inject constructor(
private val virtualRemoteRepository: VirtualRemoteRepository,
private val createVirtualRemote: CreateVirtualRemote,
private val deleteVirtualRemote: DeleteVirtualRemote,
private val sendVirtualRemoteAction: SendVirtualRemoteAction,
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
private val addVirtualRemoteKey: AddVirtualRemoteKey,
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
private val activityWriter: ActivityEventWriter,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val messageFlow = MutableStateFlow<HubUiMessage?>(null)
private val selectedRemoteIdFlow = MutableStateFlow<VirtualRemoteId?>(null)
private val isLearnModeFlow = MutableStateFlow(false)
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
private var acDebounceJob: Job? = null
private val routeDeviceName: DeviceName? = savedStateHandle.get<String>("deviceName")
?.takeIf { it.isNotBlank() }
?.let(::DeviceName)
@@ -96,10 +124,51 @@ class InfraredHubViewModel @Inject constructor(
}
}
private data class PanelSnapshot(
val selectedId: VirtualRemoteId?,
val remote: VirtualRemote?,
val bindings: List<RemoteKeyBinding>,
)
private val panelSnapshotFlow = combine(hubSnapshotFlow, selectedRemoteIdFlow) { hub, selectedId ->
val resolvedId = selectedId?.takeIf { id -> hub.remotes.any { it.id == id } } ?: hub.remotes.firstOrNull()?.id
resolvedId to hub
}.flatMapLatest { (selectedId, hub) ->
if (selectedId == null) {
flowOf(PanelSnapshot(null, null, emptyList()))
} else {
virtualRemoteRepository.observeKeyBindings(selectedId).map { result ->
PanelSnapshot(
selectedId,
hub.remotes.firstOrNull { it.id == selectedId },
if (result is DomainResult.Success) result.value else emptyList(),
)
}
}
}
private data class CenterActions(
val learnMode: Boolean,
val learningButtonId: String?,
val sendingButtonId: String?,
val localAcState: AcStateSnapshot?,
)
private val centerActionsFlow = combine(
isLearnModeFlow,
learningButtonIdFlow,
sendingButtonIdFlow,
localAcStateFlow,
) { learnMode, learningId, sendingId, localAc ->
CenterActions(learnMode, learningId, sendingId, localAc)
}
val state: StateFlow<InfraredHubUiState> = combine(
hubSnapshotFlow,
panelSnapshotFlow,
centerActionsFlow,
messageFlow,
) { snapshot, msg ->
) { snapshot, panel, actions, msg ->
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid
@@ -119,6 +188,13 @@ class InfraredHubViewModel @Inject constructor(
providerDisplayName = snapshot.providerProfile?.displayName,
savedDeviceName = snapshot.savedDevice,
remotes = snapshot.remotes,
selectedRemoteId = panel.selectedId,
selectedRemote = panel.remote,
bindings = panel.bindings,
acState = actions.localAcState ?: panel.remote?.acState ?: AcStateSnapshot(),
isLearnMode = actions.learnMode,
learningButtonId = actions.learningButtonId,
sendingButtonId = actions.sendingButtonId,
slotQuota = snapshot.slotQuota,
loading = false,
message = resolvedMessage,
@@ -168,6 +244,155 @@ class InfraredHubViewModel @Inject constructor(
}
}
fun selectRemote(remoteId: VirtualRemoteId) {
selectedRemoteIdFlow.value = remoteId
localAcStateFlow.value = null
isLearnModeFlow.value = false
}
fun moveRemote(remoteId: VirtualRemoteId, offset: Int) {
val remotes = state.value.remotes
val from = remotes.indexOfFirst { it.id == remoteId }
val to = from + offset
if (from !in remotes.indices || to !in remotes.indices) return
val current = remotes[from]
val target = remotes[to]
viewModelScope.launch {
val first = virtualRemoteRepository.updateRemote(
current.copy(createdAtEpochMillis = target.createdAtEpochMillis),
)
val second = virtualRemoteRepository.updateRemote(
target.copy(createdAtEpochMillis = current.createdAtEpochMillis),
)
if (first is DomainResult.Failure) handleDomainError(first.error)
else if (second is DomainResult.Failure) handleDomainError(second.error)
}
}
fun updateRemote(
remoteId: VirtualRemoteId,
displayName: String,
layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle,
transmissionMode: TransmissionMode,
) {
val remote = state.value.remotes.firstOrNull { it.id == remoteId } ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
when (val result = virtualRemoteRepository.updateRemote(
remote.copy(
displayName = trimmed,
layoutStyle = layoutStyle,
transmissionMode = transmissionMode,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器配置已保存"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun toggleLearnMode() {
if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return
isLearnModeFlow.update { !it }
}
fun onButtonClick(buttonId: String) {
if (isLearnModeFlow.value) learnButton(buttonId) else sendButton(buttonId)
}
private fun sendButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
if (sendingButtonIdFlow.value != null || learningButtonIdFlow.value != null) return
sendingButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val result = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.SUCCEEDED)
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态"))
}
is DomainResult.Failure -> {
recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.FAILED, result.error.externalCode)
handleDomainError(result.error)
}
}
} finally {
sendingButtonIdFlow.value = null
}
}
}
private fun learnButton(buttonId: String) {
if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
if (learningButtonIdFlow.value != null) return
learningButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val result = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.SUCCEEDED)
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键信号已学习并保存"))
}
is DomainResult.Failure -> {
recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.FAILED, result.error.externalCode)
handleDomainError(result.error)
}
}
} finally {
learningButtonIdFlow.value = null
}
}
}
fun addCustomButton(displayName: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
viewModelScope.launch {
when (val result = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已添加按键"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun deleteButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
viewModelScope.launch {
when (val result = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键已删除"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun toggleAcPower() = updateAc(state.value.acState.copy(power = !state.value.acState.power))
fun adjustAcTemperature(delta: Int) = updateAc(state.value.acState.copy(temperature = (state.value.acState.temperature + delta).coerceIn(16, 30)))
fun setAcMode(mode: AcMode) = updateAc(state.value.acState.copy(mode = mode))
fun setAcFanSpeed(speed: AcFanSpeed) = updateAc(state.value.acState.copy(fanSpeed = speed))
fun toggleAcVerticalSwing() = updateAc(state.value.acState.copy(verticalSwing = !state.value.acState.verticalSwing))
fun toggleAcHorizontalSwing() = updateAc(state.value.acState.copy(horizontalSwing = !state.value.acState.horizontalSwing))
fun toggleAcEco() = updateAc(state.value.acState.copy(eco = !state.value.acState.eco))
private fun updateAc(newState: AcStateSnapshot) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
localAcStateFlow.value = newState
acDebounceJob?.cancel()
acDebounceJob = viewModelScope.launch {
delay(300L)
when (val result = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun saveDeviceName(newName: String) {
val currentProfile = profileId ?: return
val trimmed = newName.trim()
@@ -189,6 +414,19 @@ class InfraredHubViewModel @Inject constructor(
messageFlow.value = null
}
private fun recordRemoteActivity(kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null) {
val currentProfile = profileId ?: return
val remote = state.value.selectedRemote ?: return
activityWriter.record(
profileId = currentProfile,
kind = kind,
source = ActivitySource.USER,
outcome = outcome,
target = ActivityTarget(ActivityTargetType.REMOTE, remote.id.value, remote.displayName),
errorCode = errorCode,
)
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid
@@ -16,8 +16,10 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -61,7 +63,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -131,7 +133,7 @@ internal fun RemotePanelScreen(
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
var showAddKeyDialog by remember { mutableStateOf(false) }
@@ -141,7 +143,7 @@ internal fun RemotePanelScreen(
is PanelUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -149,7 +151,7 @@ internal fun RemotePanelScreen(
is PanelUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -191,8 +193,7 @@ internal fun RemotePanelScreen(
ABoxIconButton(ABoxIconKey.BACK, "返回", onBack)
},
actions = {
// 非空调面板显示学习模式开关
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
if (state.remote?.type == com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) {
FilterChip(
selected = state.isLearnMode,
onClick = onToggleLearnMode,
@@ -244,52 +245,20 @@ internal fun RemotePanelScreen(
}
}
// 面板内容区
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> {
AcControlPanel(
acState = state.acState,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVSwing = onToggleAcVerticalSwing,
onToggleHSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
)
}
PanelLayoutStyle.TV_NAV -> {
TvControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
}
PanelLayoutStyle.MEDIA_PLAYER -> {
MediaPlayerControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
}
PanelLayoutStyle.GRID -> {
GenericGridControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
onDeleteButton = onDeleteButton,
onAddButton = { showAddKeyDialog = true },
)
}
}
}
UnifiedRemoteControlSurface(
state = state,
onButtonClick = onButtonClick,
onAddCustomButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
onToggleAcPower = onToggleAcPower,
onAdjustAcTemp = onAdjustAcTemp,
onSetAcMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleAcVerticalSwing = onToggleAcVerticalSwing,
onToggleAcHorizontalSwing = onToggleAcHorizontalSwing,
onToggleAcEco = onToggleAcEco,
modifier = Modifier.fillMaxSize().weight(1f),
)
}
}
}
@@ -320,12 +289,13 @@ private fun AcControlPanel(
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag("ac_temperature_stepper"),
colors = CardDefaults.cardColors(
containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
),
@@ -359,21 +329,9 @@ private fun AcControlPanel(
}
}
Text(
text = "${acState.temperature}°C",
fontSize = 64.sp,
fontWeight = FontWeight.Black,
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
horizontalArrangement = Arrangement.spacedBy(32.dp),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Button(
@@ -383,6 +341,15 @@ private fun AcControlPanel(
) {
Icon(Icons.Outlined.Remove, contentDescription = "降低温度")
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${acState.temperature}°",
fontSize = 56.sp,
fontWeight = FontWeight.Black,
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("设定温度", style = MaterialTheme.typography.labelMedium)
}
Button(
onClick = { onAdjustTemp(1) },
modifier = Modifier.size(56.dp).testTag("ac_temp_up"),
@@ -391,21 +358,27 @@ private fun AcControlPanel(
Icon(Icons.Outlined.Add, contentDescription = "升高温度")
}
}
Text(
text = "本地选择:${acState.mode.name} · ${acState.fanSpeed.name}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
AcMode.entries.forEach { mode ->
FilterChip(
selected = acState.mode == mode,
onClick = { onSetMode(mode) },
label = { Text(mode.name) },
modifier = Modifier.weight(1f),
)
Card(modifier = Modifier.fillMaxWidth().testTag("ac_mode_selector")) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("运行模式", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AcMode.entries.forEach { mode ->
FilterChip(
selected = acState.mode == mode,
onClick = { onSetMode(mode) },
label = { Text(modeLabel(mode)) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@@ -418,7 +391,7 @@ private fun AcControlPanel(
FilterChip(
selected = acState.fanSpeed == speed,
onClick = { onSetFanSpeed(speed) },
label = { Text(speed.name) },
label = { Text(fanSpeedLabel(speed)) },
modifier = Modifier.weight(1f),
)
}
@@ -463,6 +436,7 @@ private fun TvControlPanel(
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -483,7 +457,7 @@ private fun TvControlPanel(
// 中部五向方向导航盘
Card(
shape = CircleShape,
modifier = Modifier.size(220.dp),
modifier = Modifier.size(220.dp).testTag("tv_direction_pad"),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -492,7 +466,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_UP") },
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"),
) {
ButtonIndicatorText("TV_NAV_UP", "上", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, "向上", learningButtonId, sendingButtonId)
}
// 下
@@ -500,7 +474,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_DOWN") },
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
) {
ButtonIndicatorText("TV_NAV_DOWN", "下", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, "向下", learningButtonId, sendingButtonId)
}
// 左
@@ -508,7 +482,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_LEFT") },
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
) {
ButtonIndicatorText("TV_NAV_LEFT", "左", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_LEFT", Icons.Outlined.KeyboardArrowLeft, "向左", learningButtonId, sendingButtonId)
}
// 右
@@ -516,7 +490,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_RIGHT") },
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
) {
ButtonIndicatorText("TV_NAV_RIGHT", "右", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_RIGHT", Icons.Outlined.KeyboardArrowRight, "向右", learningButtonId, sendingButtonId)
}
// OK 居中大按钮
@@ -535,14 +509,14 @@ private fun TvControlPanel(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceAround,
) {
InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_BACK", "返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_HOME", "主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
InteractiveRemoteButton("TV_MENU", "菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
}
// 底部音量与频道调节柱
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag("tv_volume_channel_rockers"),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(
@@ -550,11 +524,11 @@ private fun TvControlPanel(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId)
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Add, contentDescription = "提高音量")
}
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId)
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Remove, contentDescription = "降低音量")
}
}
@@ -563,17 +537,118 @@ private fun TvControlPanel(
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp)
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_CH_UP", "频道上调", learningButtonId, sendingButtonId)
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Add, contentDescription = "频道上调")
}
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_CH_DOWN", "频道下调", learningButtonId, sendingButtonId)
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Remove, contentDescription = "频道下调")
}
}
}
}
}
@Composable
fun UnifiedRemoteControlSurface(
state: RemotePanelUiState,
onButtonClick: (String) -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleAcVerticalSwing: () -> Unit,
onToggleAcHorizontalSwing: () -> Unit,
onToggleAcEco: () -> Unit,
modifier: Modifier = Modifier,
) {
var showAddKeyDialog by remember { mutableStateOf(false) }
val remote = state.remote
Box(modifier = modifier) {
if (remote == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> AcControlPanel(
acState = state.acState,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVSwing = onToggleAcVerticalSwing,
onToggleHSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
)
PanelLayoutStyle.TV_NAV -> TvControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
PanelLayoutStyle.MEDIA_PLAYER -> MediaPlayerControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
)
PanelLayoutStyle.GRID -> GenericGridControlPanel(
bindings = state.bindings,
isLearnMode = state.isLearnMode,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
onDeleteButton = onDeleteButton,
onAddButton = { showAddKeyDialog = true },
)
}
}
}
if (showAddKeyDialog) {
AddCustomKeyDialog(
onDismiss = { showAddKeyDialog = false },
onConfirm = {
showAddKeyDialog = false
onAddCustomButton(it)
},
)
}
}
@Composable
private fun DirectionIndicator(
buttonId: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
learningButtonId: String?,
sendingButtonId: String?,
) {
when (buttonId) {
learningButtonId -> Text("录制中", fontSize = 11.sp, fontWeight = FontWeight.Bold)
sendingButtonId -> CircularProgressIndicator(modifier = Modifier.size(18.dp))
else -> Icon(icon, contentDescription = description, modifier = Modifier.size(30.dp))
}
}
private fun modeLabel(mode: AcMode): String = when (mode) {
AcMode.COOL -> "制冷"
AcMode.HEAT -> "制热"
AcMode.AUTO -> "自动"
AcMode.DRY -> "除湿"
AcMode.FAN -> "送风"
}
private fun fanSpeedLabel(speed: AcFanSpeed): String = when (speed) {
AcFanSpeed.AUTO -> "自动"
AcFanSpeed.LOW -> "低速"
AcFanSpeed.MEDIUM -> "中速"
AcFanSpeed.HIGH -> "高速"
}
@Composable
private fun MediaPlayerControlPanel(
bindings: List<RemoteKeyBinding>,
@@ -709,7 +784,8 @@ private fun GridButtonCard(
val statusText = when {
isLearning -> "正在录制信号..."
binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})"
else -> "待录制(点击录码)"
binding.status == InfraredKeyStatus.FAILED -> "学习失败(请重试)"
else -> "未学习(点击录码)"
}
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -129,6 +129,7 @@ class RemotePanelViewModel @Inject constructor(
)
fun toggleLearnMode() {
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
val newMode = !isLearnModeFlow.value
isLearnModeFlow.value = newMode
if (!newMode) cancelLearning()
@@ -166,6 +167,7 @@ class RemotePanelViewModel @Inject constructor(
}
fun learnButton(buttonId: String) {
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
if (state.value.learningButtonId != null) return
@@ -204,7 +204,8 @@ class InfraredRemoteViewModel @Inject constructor(
activeLearningSlotFlow.value = spec.defaultSlot
val deferred = when (existingKey?.status) {
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING ->
learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
null -> learningCoordinator.learn(
profileId = currentProfile,
deviceName = currentDevice,
@@ -35,7 +35,7 @@ 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.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -89,7 +89,7 @@ internal fun InfraredRemoteScreen(
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
LaunchedEffect(state.message) {
state.message?.let { msg ->
@@ -97,7 +97,7 @@ internal fun InfraredRemoteScreen(
is RemoteUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -105,7 +105,7 @@ internal fun InfraredRemoteScreen(
is RemoteUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -33,7 +33,7 @@ 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.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -76,7 +76,7 @@ internal fun InfraredControlScreen(
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
val scrollState = rememberScrollState()
LaunchedEffect(state.message) {
@@ -85,7 +85,7 @@ internal fun InfraredControlScreen(
is InfraredUiMessage.Info -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -93,7 +93,7 @@ internal fun InfraredControlScreen(
is InfraredUiMessage.Error -> {
val text = when (val t = msg.text) {
is UiText.DynamicString -> t.value
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -73,9 +73,9 @@
<string name="infrared_key_tv_vol_down">电视-音量-</string>
<string name="infrared_key_tv_ch_up">电视-频道+</string>
<string name="infrared_key_tv_ch_down">电视-频道-</string>
<string name="infrared_key_tv_home">电视-主页</string>
<string name="infrared_key_tv_back">电视-返回</string>
<string name="infrared_key_tv_menu">电视-菜单</string>
<string name="infrared_key_tv_home">主页</string>
<string name="infrared_key_tv_back">返回</string>
<string name="infrared_key_tv_menu">菜单</string>
<string name="infrared_control_title">红外设备与环境配置</string>
<string name="infrared_device_config_section">ABSDK 设备名称配置</string>
<string name="infrared_device_config_desc">ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string>
@@ -114,4 +114,79 @@
<string name="infrared_hub_device_type_ac">空调设备</string>
<string name="infrared_hub_device_type_fan">🌀 电风扇 / 空气净化器</string>
<string name="infrared_hub_device_type_custom">自定义面板</string>
<string name="infrared_set_temperature">设定温度</string>
<string name="infrared_running_mode">运行模式</string>
<string name="infrared_fan_speed">风速档位</string>
<string name="infrared_cooling">制冷</string>
<string name="infrared_heating">制热</string>
<string name="infrared_auto">自动</string>
<string name="infrared_drying">除湿</string>
<string name="infrared_fan">送风</string>
<string name="infrared_low">低速</string>
<string name="infrared_medium">中速</string>
<string name="infrared_high">高速</string>
<string name="infrared_vertical_swing">上下扫风</string>
<string name="infrared_horizontal_swing">左右扫风</string>
<string name="infrared_eco">节能</string>
<string name="infrared_transmission_notice">状态变更会生成完整红外帧并发送;信号发射不代表设备状态已确认。</string>
<string name="infrared_up">向上</string>
<string name="infrared_down">向下</string>
<string name="infrared_left">向左</string>
<string name="infrared_right">向右</string>
<string name="infrared_volume">音量</string>
<string name="infrared_channel">频道</string>
<string name="infrared_raise_volume">提高音量</string>
<string name="infrared_lower_volume">降低音量</string>
<string name="infrared_raise_channel">频道上调</string>
<string name="infrared_lower_channel">频道下调</string>
<string name="infrared_media_panel">媒体控制面板</string>
<string name="infrared_remote_keys">%1$s 个按键</string>
<string name="infrared_no_buttons_manage">暂无按键,请在管理面板中添加。</string>
<string name="infrared_add_button">添加按键</string>
<string name="infrared_add_button_title">添加自定义按键</string>
<string name="infrared_add_button_description">先设置按键名称,录制红外信号时再分配硬件槽位。</string>
<string name="infrared_button_name">按键名称</string>
<string name="infrared_button_available">可用</string>
<string name="infrared_button_learning">正在学习</string>
<string name="infrared_button_sending">正在发送</string>
<string name="infrared_button_ready">可发送</string>
<string name="infrared_button_failed">学习失败</string>
<string name="infrared_button_unlearned">待学习</string>
<string name="infrared_delete_button">删除按键</string>
<string name="infrared_center_subtitle">红外控制器 · 遥控中心</string>
<string name="infrared_settings_subtitle">红外设备设置</string>
<string name="infrared_manage_description">遥控器、槽位与网关设备名称</string>
<string name="infrared_open_management">打开管理</string>
<string name="infrared_no_remotes_hint">使用管理操作添加遥控器</string>
<string name="infrared_manage_action">管理红外设备与遥控器</string>
<string name="infrared_custom">自定义</string>
<string name="infrared_static_slots">静态槽位</string>
<string name="infrared_dynamic_codes">动态写码</string>
<string name="infrared_layout_tv">电视导航面板</string>
<string name="infrared_layout_ac">空调状态面板</string>
<string name="infrared_layout_media">媒体控制面板</string>
<string name="infrared_layout_grid">自定义按键面板</string>
<string name="infrared_learn_mode">学习模式</string>
<string name="infrared_recording">录制中</string>
<string name="infrared_hardware_summary">红外控制器 · %1$d 个虚拟遥控器</string>
<string name="infrared_slot_summary">静态槽位 %1$d 个 · 动态缓冲槽位 100 · 剩余 %2$d 个</string>
<string name="infrared_remote_name">遥控器名称</string>
<string name="infrared_layout_tv_desc">电视导航布局,包含方向、音量和频道控制</string>
<string name="infrared_layout_media_desc">媒体播放器布局,包含播放控制</string>
<string name="infrared_layout_grid_desc">自由网格布局,可增删按键</string>
<string name="infrared_mode_static_title">静态独占槽位模式</string>
<string name="infrared_mode_dynamic_title">动态写码模式</string>
<string name="infrared_hardware_slots_title">硬件槽位</string>
<string name="infrared_remote_count">%1$d 个遥控器</string>
<string name="infrared_add_remote">添加遥控器</string>
<string name="infrared_move_remote">调整遥控器顺序</string>
<string name="infrared_move_up">上移</string>
<string name="infrared_move_down">下移</string>
<string name="infrared_edit_remote">编辑遥控器</string>
<string name="infrared_gateway_name_title">网关设备名称</string>
<string name="infrared_gateway_name_desc">用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。</string>
<string name="infrared_save_name">保存设备名称</string>
<string name="infrared_layout">面板布局</string>
<string name="infrared_transmission_mode">传输模式</string>
<string name="infrared_delete_remote">删除遥控器</string>
</resources>
@@ -114,4 +114,79 @@
<string name="infrared_hub_device_type_ac">Air Conditioner (AC)</string>
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
<string name="infrared_hub_device_type_custom">Custom Panel</string>
<string name="infrared_set_temperature">Set temperature</string>
<string name="infrared_running_mode">Operating mode</string>
<string name="infrared_fan_speed">Fan speed</string>
<string name="infrared_cooling">Cool</string>
<string name="infrared_heating">Heat</string>
<string name="infrared_auto">Auto</string>
<string name="infrared_drying">Dry</string>
<string name="infrared_fan">Fan</string>
<string name="infrared_low">Low</string>
<string name="infrared_medium">Medium</string>
<string name="infrared_high">High</string>
<string name="infrared_vertical_swing">Vertical swing</string>
<string name="infrared_horizontal_swing">Horizontal swing</string>
<string name="infrared_eco">Eco</string>
<string name="infrared_transmission_notice">State changes generate a complete infrared frame and send it. Transmission does not confirm the appliance state.</string>
<string name="infrared_up">Up</string>
<string name="infrared_down">Down</string>
<string name="infrared_left">Left</string>
<string name="infrared_right">Right</string>
<string name="infrared_volume">Volume</string>
<string name="infrared_channel">Channel</string>
<string name="infrared_raise_volume">Raise volume</string>
<string name="infrared_lower_volume">Lower volume</string>
<string name="infrared_raise_channel">Raise channel</string>
<string name="infrared_lower_channel">Lower channel</string>
<string name="infrared_media_panel">Media control panel</string>
<string name="infrared_remote_keys">%1$s buttons</string>
<string name="infrared_no_buttons_manage">No buttons. Add one in management.</string>
<string name="infrared_add_button">Add button</string>
<string name="infrared_add_button_title">Add custom button</string>
<string name="infrared_add_button_description">Name the button now. Its hardware slot will be assigned when you record the infrared signal.</string>
<string name="infrared_button_name">Button name</string>
<string name="infrared_button_available">Available</string>
<string name="infrared_button_learning">Learning</string>
<string name="infrared_button_sending">Sending</string>
<string name="infrared_button_ready">Ready to send</string>
<string name="infrared_button_failed">Learning failed</string>
<string name="infrared_button_unlearned">Not learned</string>
<string name="infrared_delete_button">Delete button</string>
<string name="infrared_center_subtitle">Infrared controller · Remote center</string>
<string name="infrared_settings_subtitle">Infrared device settings</string>
<string name="infrared_manage_description">Remotes, slots, and gateway device name</string>
<string name="infrared_open_management">Open management</string>
<string name="infrared_no_remotes_hint">Use the management action to add a remote</string>
<string name="infrared_manage_action">Manage infrared devices and remotes</string>
<string name="infrared_custom">Custom</string>
<string name="infrared_static_slots">Static slots</string>
<string name="infrared_dynamic_codes">Dynamic codes</string>
<string name="infrared_layout_tv">TV navigation panel</string>
<string name="infrared_layout_ac">Air conditioner panel</string>
<string name="infrared_layout_media">Media control panel</string>
<string name="infrared_layout_grid">Custom button panel</string>
<string name="infrared_learn_mode">Learn mode</string>
<string name="infrared_recording">Recording</string>
<string name="infrared_hardware_summary">Infrared controller · %1$d virtual remotes</string>
<string name="infrared_slot_summary">Static slots: %1$d · Dynamic buffer: slot 100 · Free: %2$d</string>
<string name="infrared_remote_name">Remote name</string>
<string name="infrared_layout_tv_desc">TV navigation layout with direction, volume, and channel controls</string>
<string name="infrared_layout_media_desc">Media player layout with playback controls</string>
<string name="infrared_layout_grid_desc">Custom grid layout with editable buttons</string>
<string name="infrared_mode_static_title">Static slot mode</string>
<string name="infrared_mode_dynamic_title">Dynamic code mode</string>
<string name="infrared_hardware_slots_title">Hardware slots</string>
<string name="infrared_remote_count">%1$d remotes</string>
<string name="infrared_add_remote">Add remote</string>
<string name="infrared_move_remote">Reorder remote</string>
<string name="infrared_move_up">Move up</string>
<string name="infrared_move_down">Move down</string>
<string name="infrared_edit_remote">Edit remote</string>
<string name="infrared_gateway_name_title">Gateway device name</string>
<string name="infrared_gateway_name_desc">Used to match the infrared controller in the gateway. It must exactly match the gateway name.</string>
<string name="infrared_save_name">Save device name</string>
<string name="infrared_layout">Panel layout</string>
<string name="infrared_transmission_mode">Transmission mode</string>
<string name="infrared_delete_remote">Delete remote</string>
</resources>