Compare commits

...
6 Commits
12 changed files with 2181 additions and 3 deletions
@@ -39,11 +39,13 @@ import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
private const val FOUNDATION_ROUTE = "foundation"
private const val PLACEHOLDER_ROUTE = "placeholder"
private const val INFRARED_ROUTE = "infrared/{profileId}"
private const val INFRARED_REMOTE_ROUTE = "infrared_remote/{profileId}"
private const val INFRARED_SETTINGS_ROUTE = "infrared_settings/{profileId}"
private const val TEMPORARY_ABOX_PROFILE_ID = "abox"
@Composable
@@ -63,7 +65,7 @@ fun ABoxManagerApp() {
onContinue = { navController.navigate(PLACEHOLDER_ROUTE) },
onOpenInfrared = {
sessionMessage = null
navController.navigate("infrared/$TEMPORARY_ABOX_PROFILE_ID")
navController.navigate("infrared_remote/$TEMPORARY_ABOX_PROFILE_ID")
},
)
}
@@ -74,7 +76,25 @@ fun ABoxManagerApp() {
)
}
composable(
route = INFRARED_ROUTE,
route = INFRARED_REMOTE_ROUTE,
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
val sessionInvalidText = stringResource(R.string.infrared_session_invalid_message)
InfraredRemoteRoute(
onBack = { navController.popBackStack() },
onOpenSettings = {
navController.navigate("infrared_settings/$TEMPORARY_ABOX_PROFILE_ID")
},
onSessionInvalid = {
sessionMessage = sessionInvalidText
navController.popBackStack(FOUNDATION_ROUTE, inclusive = false)
},
)
}
composable(
route = INFRARED_SETTINGS_ROUTE,
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
@@ -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,214 @@
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow: Flow<DomainResult<DeviceName?>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(emptyList()))
else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Success -> {
val device = deviceResult.value
if (device == null) flowOf(DomainResult.Success(emptyList()))
else keyRepository.observeKeys(profile, device)
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { args: Array<Any?> ->
val scene = args[0] as RemoteScene
val isLearning = args[1] as Boolean
val profileAndMode = args[2] as Pair<ProviderProfileId?, InfraredRuntimeMode>
val deviceResult = args[3] as DomainResult<DeviceName?>
val keysResult = args[4] as DomainResult<List<SavedInfraredKey>>
val sendingSlot = args[5] as String?
val learningSlot = args[6] as String?
val msg = args[7] as RemoteUiMessage?
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (enabled) {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (isLearningModeFlow.value) {
activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...")
} else {
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
}
else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
}
}
}
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.infrared.remote
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU,
)
}
}
@@ -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,
)
}
@@ -0,0 +1,139 @@
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
private class TestModeStore : InfraredRuntimeModeStore {
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
override val mode: Flow<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
modeFlow.value = mode
return DomainResult.Success(Unit)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
job.cancel()
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 21..50 })
}
}
@@ -0,0 +1,589 @@
# Infrared Remote Control UI & Multi-Scene Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a realistic, multi-scene infrared remote control UI (Air Conditioner & Television) with learning/control mode toggle, fixed slot key bindings (1-100), graphic component modeling (circles, capsules, D-pad wheels), and settings navigation.
**Architecture:** Add scene models and predefined key specs in `:android:feature:infrared`. Implement `InfraredRemoteViewModel` to observe Room keys and coordinator actions. Build Compose panels for AC and TV remotes, and integrate the navigation route `infrared_remote/{profileId}` alongside `infrared_settings/{profileId}`.
**Tech Stack:** Kotlin 1.9.24, Jetpack Compose Material 3, Android Gradle Plugin 8.7.3, Room, StateFlow, Hilt, Coroutines.
**Spec:** `docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md`
## Global Constraints
- Remote buttons display clean symbols/icons, never raw Chinese long names on the button faces.
- Every button maps to a dedicated 1-100 integer slot (AC: 1–20, TV: 21–50).
- Learning mode toggle activates visual indicator and switches button clicks from `sendKey` to `learnKey`.
- Settings navigation allows jumping to the existing `InfraredControlScreen` (renamed to Settings) to view raw entries and device names.
- Follow TDD with unit and Compose UI tests.
---
### Task 1: Define Remote Scenes and Key Specs
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt`
**Interfaces:**
- Produces: `RemoteScene`, `RemoteButtonId`, `RemoteButtonSpec`, and `RemoteSceneConfig`.
- [ ] **Step 1: Write test for predefined AC and TV remote button specifications**
```kotlin
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assert(slots.all { it.toInt() in 21..50 })
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `RemoteModels.kt`**
```kotlin
package com.flagship.abox.manager.infrared.remote
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU
)
}
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt
git commit -m "feat: define infrared remote scenes and button specifications"
```
---
### Task 2: Implement Infrared Remote ViewModel
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteUiState.kt`
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt`
**Interfaces:**
- Consumes: `InfraredKeyRepository`, `InfraredDeviceConfigurationRepository`, `InfraredLearningCoordinator`, `SendInfraredKey`, `InfraredRuntimeModeStore`.
- Produces: `StateFlow<InfraredRemoteUiState>` with `onButtonClick`, `toggleLearningMode`, `switchScene`.
- [ ] **Step 1: Write failing ViewModel test**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.*
import org.junit.Before
import org.junit.Test
private class TestModeStore : InfraredRuntimeModeStore {
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
override val mode: Flow<InfraredRuntimeMode> = modeFlow
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
modeFlow.value = mode
return DomainResult.Success(Unit)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val modeStore = TestModeStore()
private val resolver = InfraredRuntimeProfileResolver()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("id-1") }, clock)
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
private val send = SendInfraredKey(keyRepo, gateway)
private lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
runtimeModeStore = modeStore,
profileResolver = resolver,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = fakeProfile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(readyKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(fakeProfile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
job.cancel()
}
}
```
- [ ] **Step 2: Run test to verify failure**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: Compilation failure.
- [ ] **Step 3: Implement `InfraredRemoteUiState` and `InfraredRemoteViewModel`**
```kotlin
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.*
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import javax.inject.Inject
sealed interface RemoteUiMessage {
data class Info(val text: String) : RemoteUiMessage
data class Error(val text: String) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
val effectiveProfileId: ProviderProfileId? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val runtimeModeStore: InfraredRuntimeModeStore,
private val profileResolver: InfraredRuntimeProfileResolver,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private val effectiveProfileAndMode = runtimeModeStore.mode.flatMapLatest { mode ->
if (authenticatedProfileId == null) {
flowOf(Pair(null, mode))
} else {
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
}
}
private val deviceNameFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(null))
else deviceConfigurationRepository.observeDeviceName(profile)
}
private val keysFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
if (profile == null) flowOf(DomainResult.Success(emptyList()))
else {
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Success -> {
val device = deviceResult.value
if (device == null) flowOf(DomainResult.Success(emptyList()))
else keyRepository.observeKeys(profile, device)
}
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
effectiveProfileAndMode,
deviceNameFlow,
keysFlow,
sendingKeySlotFlow,
activeLearningSlotFlow,
messageFlow,
) { scene, isLearning, profileAndMode, deviceResult, keysResult, sendingSlot, learningSlot, msg ->
val (profile, mode) = profileAndMode
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
val slotMap = keys.associateBy { it.sdkKey }
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
runtimeMode = mode,
effectiveProfileId = profile,
savedDeviceName = savedDevice,
learnedSlotMap = slotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = msg,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (enabled) {
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = state.value.effectiveProfileId ?: return
val savedDevice = state.value.savedDeviceName
if (savedDevice == null) {
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
return
}
if (state.value.isLearningMode) {
activeLearningSlotFlow.value = spec.defaultSlot
learningCoordinator.learn(
profileId = currentProfile,
deviceName = savedDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按物理遥控器按键...")
} else {
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY) {
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.symbol}")
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
}
else -> {
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
}
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: implement infrared remote control viewmodel"
```
---
### Task 3: Build Custom Remote Visual Components
**Files:**
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt`
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt`
- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt`
- [ ] **Step 1: Implement Compose visual elements**
- `RemoteCircleButton`, `VerticalCapsuleRocker`, `RemoteDpadWheel`, `AcLcdDisplay`.
- Compose panels for `AcRemotePanel` and `TvRemotePanel`.
- Assemble `InfraredRemoteScreen` with top learning switch and settings action.
- [ ] **Step 2: Write Compose UI Test**
```kotlin
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.domain.ProviderProfileId
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)
}
}
```
- [ ] **Step 3: Run compilation and tests**
Run: `./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:feature:infrared:testDebugUnitTest`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote
git commit -m "feat: add infrared remote control Compose screen and panels"
```
---
### Task 4: Integrate Navigation and App Flow
**Files:**
- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt`
- Modify: `android/app/src/main/res/values/strings.xml`
- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml`
- [ ] **Step 1: Wire Navigation**
- Route `infrared/{profileId}` now loads `InfraredRemoteRoute`.
- Add Route `infrared_settings/{profileId}` that loads `InfraredControlRoute` (the settings & raw list view).
- Top bar of `InfraredRemoteScreen` has ⚙️ button navigating to `infrared_settings/{profileId}`.
- [ ] **Step 2: Run Full Verification Suite & Build APK**
Run: `./gradlew :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:assembleDebug`
Expected: BUILD SUCCESSFUL.
- [ ] **Step 3: Commit and Push**
```bash
git add android/app android/feature/infrared
git commit -m "feat: wire infrared remote screen and settings navigation"
git push origin feat/moler-infrared-control
```
@@ -0,0 +1,115 @@
# 红外遥控器界面与多场景控制设计规范 / Infrared Remote Control UI Specification
## 1. 背景与目标 (Background & Goals)
在完成了底层红外学习、发射、Room 数据库、1~100 整数 Key 槽位管理以及 Fake/Real ABSDK 路由的基础上,本项目需要提供专业且直观的前端遥控器人机交互界面。
### 核心目标
1. **多场景遥控面板**:提供空调 (Air Conditioner) 与电视 (Television) 两大经典场景遥控器界面,使用圆形、圆角矩形、胶囊、圆形方向盘等拟物/现代图形组件构建真实遥控器外观。
2. **学习模式与控制模式切换**:右上角提供“红外学习模式”开关,在控制模式下点击按键发射红外,在学习模式下点击按键录制红外。
3. **固定槽位绑定 (Fixed Slot Binding)**:遥控器上的每一个功能按键具有预设唯一的 1~100 整数槽位编号(例如空调电源为 Slot 1,电视电源为 Slot 21)。
4. **简洁的符号化设计**:遥控器按键表面只展示标准图标与简写符号(⏻, +, -, ∧, ∨, ❄, 🔊 等),不展示底层数据库中文长名称。
5. **设置页面解耦与导航**:原“红外控制页面”定位为“红外学习设置页面”(Infrared Settings),通过遥控器右上角设置图标 ⚙️ 进入,用于配置红外设备名和管理全部已学按键列表。
---
## 2. 槽位与按键分配定义 (Slot & Button Mapping)
每个按键定义为一个 `RemoteButtonSpec`,包含按键唯一标识 `id`、对应默认槽位 `defaultSlot`、默认显示名称 `displayName`、图标及在遥控器面板中的图形位置。
### 2.1 空调遥控器 (Air Conditioner: Slots 1–20)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `AC_POWER` | 电源 ⏻ | `1` | `空调-电源` | 顶部圆形独立红色高亮按键 |
| `AC_TEMP_UP` | 温度+ 🔼 | `2` | `空调-温度+` | 垂直胶囊上半部分 |
| `AC_TEMP_DOWN` | 温度- 🔽 | `3` | `空调-温度-` | 垂直胶囊下半部分 |
| `AC_MODE` | 模式 ❄/☀ | `4` | `空调-模式` | 圆角矩形 |
| `AC_FAN` | 风速 💨 | `5` | `空调-风速` | 圆角矩形 |
| `AC_SWING` | 扫风 ↔️ | `6` | `空调-扫风` | 圆角矩形 |
| `AC_TIMER` | 定时 ⏱️ | `7` | `空调-定时` | 圆角矩形 |
### 2.2 电视遥控器 (Television: Slots 21–50)
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|---|---|---|---|---|
| `TV_POWER` | 电源 ⏻ | `21` | `电视-电源` | 顶部圆形红色按键 |
| `TV_MUTE` | 静音 🔇 | `22` | `电视-静音` | 顶部圆形灰色按键 |
| `TV_NAV_UP` | 方向上 🔼 | `23` | `电视-上` | 方向环形盘上弧段 |
| `TV_NAV_DOWN` | 方向下 🔽 | `24` | `电视-下` | 方向环形盘下弧段 |
| `TV_NAV_LEFT` | 方向左 ◀️ | `25` | `电视-左` | 方向环形盘左弧段 |
| `TV_NAV_RIGHT` | 方向右 ▶️ | `26` | `电视-右` | 方向环形盘右弧段 |
| `TV_OK` | 确认 🆗 | `27` | `电视-确定` | 方向盘中心圆形 |
| `TV_VOL_UP` | 音量+ 🔊+ | `28` | `电视-音量+` | 垂直胶囊上半部分 |
| `TV_VOL_DOWN` | 音量- 🔉- | `29` | `电视-音量-` | 垂直胶囊下半部分 |
| `TV_CH_UP` | 频道+ 📺+ | `30` | `电视-频道+` | 垂直胶囊上半部分 |
| `TV_CH_DOWN` | 频道- 📺- | `31` | `电视-频道-` | 垂直胶囊下半部分 |
| `TV_HOME` | 主页 🏠 | `32` | `电视-主页` | 中部圆角胶囊按键 |
| `TV_BACK` | 返回 ↩️ | `33` | `电视-返回` | 中部圆角胶囊按键 |
| `TV_MENU` | 菜单 ☰ | `34` | `电视-菜单` | 中部圆角胶囊按键 |
---
## 3. 架构与组件设计 (Architecture & Component Design)
### 3.1 导航结构
```text
FoundationScreen (首页/网关登录)
↓ 点击 "红外控制" (infrared/{profileId})
InfraredRemoteScreen (遥控器主界面,默认空调/电视)
├─ 学习模式开关 (Toggle: Learning Mode)
├─ 场景切换器 (Tab: ❄️ 空调 | 📺 电视)
├─ 空调遥控器面板 (AcRemotePanel)
├─ 电视遥控器面板 (TvRemotePanel)
└─ 点击右上角 ⚙️ 设置图标
↓ (infrared_settings/{profileId})
InfraredSettingsScreen (原红外设置/按键管理列表页面)
```
### 3.2 状态管理 (`InfraredRemoteViewModel` & `InfraredRemoteUiState`)
- `InfraredRemoteUiState`:
- `selectedScene`: `RemoteScene.AIR_CONDITIONER` 或 `RemoteScene.TELEVISION`
- `isLearningMode`: `Boolean`(是否处于学习录制模式)
- `activeLearningKeySlot`: `String?`(当前正在进行学习请求的按键槽位)
- `sendingKeySlot`: `String?`(当前正在发射信号的按键槽位)
- `learnedSlots`: `Map<String, SavedInfraredKey>`(已在 Room 数据库中记录为 READY 的槽位映射)
- `savedDeviceName`: `DeviceName?`(当前配置的红外设备名,如 `红外控制器`)
- `runtimeMode`: `InfraredRuntimeMode`(Fake 或 Real ABSDK)
- `message`: `UiMessage?`(提示信息或错误)
---
## 4. 交互与视觉规范 (Interaction & Visuals)
### 4.1 视觉造型
1. **遥控器本体外壳 (Remote Body Chassis)**:
- 居中放置的圆角卡片,具有微阴影、圆角半径 `28.dp`、外框及哑光质感。
2. **空调 LCD 虚拟屏**:
- 顶部黑色圆角框,内部呈现液晶感绿色/白色字样(如当前目标温度 `26°C` 与工作状态)。
3. **垂直双向胶囊摇杆 (Vertical Capsule Rockers)**:
- 上半区与下半区独立响应点击,中间由细微分割线隔开,常用于“温度 +/-”、“音量 +/-”及“频道 +/-”。
4. **圆形导航方向盘 (D-Pad Wheel)**:
- 直径约 `180.dp` 的圆形组合组件,外环包含 4 个扇区方向按键,内嵌独立的圆形 OK 键。
### 4.2 按键状态机反馈
1. **控制模式 (学习开关 OFF)**:
- **已录制 (READY)**:按键实体呈现饱满质感,点击触发波纹反馈,调用 `sendKey` 发射红外,提示“已发射”。
- **未录制 (UNLEARNED)**:按键显示浅色未激活状态;点击弹出轻量提示:`“按键尚未学习,请打开右上角学习模式”`。
2. **学习录制模式 (学习开关 ON)**:
- 顶部呈现橙色高亮提示条:`“🔴 学习模式已开启:点击任意按键开始录制”`。
- 所有按键进入可录制状态(带提示外框)。
- 点击目标按键后:
- 按键上显示微型 Loading 光圈。
- 调用 `learnKey(deviceName, defaultSlot, displayName)`。
- 手机或控制器捕获红外信号成功后,按键变为 READY 并伴随成功提示。
---
## 5. 验证与测试标准 (Verification & Acceptance Criteria)
1. **单元测试 (Unit Tests)**:
- `InfraredRemoteViewModelTest` 覆盖场景切换、学习模式开关、按键已学/未学状态映射、点击发射以及点击学习请求的转发。
2. **UI 测试 (Compose Tests)**:
- 空调与电视遥控器按键正常渲染并带正确 Tag。
- 学习模式切换后 UI 状态正确切换。
3. **真机与编译验收**:
- 模块与 App 通过 JVM 测试与打包验证。
- 手机上点击空调/电视按键能发起正确的 Slot 编号学习与发射。