Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d27e595ad0
|
||
|
|
b022ff0b0a
|
||
|
|
56917e3c8e
|
||
|
|
6e7bd7910a
|
||
|
|
0b98c5b544
|
||
|
|
d780c455f6
|
@@ -39,11 +39,13 @@ import com.flagship.abox.manager.R
|
|||||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
|
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
|
||||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
||||||
|
|
||||||
private const val FOUNDATION_ROUTE = "foundation"
|
private const val FOUNDATION_ROUTE = "foundation"
|
||||||
private const val PLACEHOLDER_ROUTE = "placeholder"
|
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"
|
private const val TEMPORARY_ABOX_PROFILE_ID = "abox"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -63,7 +65,7 @@ fun ABoxManagerApp() {
|
|||||||
onContinue = { navController.navigate(PLACEHOLDER_ROUTE) },
|
onContinue = { navController.navigate(PLACEHOLDER_ROUTE) },
|
||||||
onOpenInfrared = {
|
onOpenInfrared = {
|
||||||
sessionMessage = null
|
sessionMessage = null
|
||||||
navController.navigate("infrared/$TEMPORARY_ABOX_PROFILE_ID")
|
navController.navigate("infrared_remote/$TEMPORARY_ABOX_PROFILE_ID")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -74,7 +76,25 @@ fun ABoxManagerApp() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
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(
|
arguments = listOf(
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
),
|
),
|
||||||
|
|||||||
+45
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+214
@@ -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 ?: "操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+172
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+407
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+161
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+139
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
@@ -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 编号学习与发射。
|
||||||
Reference in New Issue
Block a user