From 3952f9f32499d59bda9bd4b2d67641527b7cadd3 Mon Sep 17 00:00:00 2001 From: mxhsnd <2049295946@qq.com> Date: Mon, 24 Aug 2026 11:21:00 +0800 Subject: [PATCH] feat: add infrared control screen --- .../infrared/ui/InfraredControlScreenTest.kt | 121 +++++ .../infrared/ui/InfraredControlScreen.kt | 456 ++++++++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 33 ++ .../infrared/src/main/res/values/strings.xml | 33 ++ 4 files changed, 643 insertions(+) create mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt create mode 100644 android/feature/infrared/src/main/res/values-zh-rCN/strings.xml create mode 100644 android/feature/infrared/src/main/res/values/strings.xml diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt new file mode 100644 index 0000000..211cefe --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -0,0 +1,121 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +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.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class InfraredControlScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun displaysModeAndDeviceInformation() { + val state = InfraredControlUiState( + runtimeMode = InfraredRuntimeMode.FAKE, + effectiveProfileId = ProviderProfileId("abox:infrared-fake"), + savedDeviceName = DeviceName("ir-living"), + loading = false, + ) + + composeTestRule.setContent { + InfraredControlScreen( + state = state, + onBack = {}, + onSessionInvalid = {}, + onDeviceNameChanged = {}, + onSaveDeviceName = {}, + onLearn = {}, + onRetry = {}, + onSend = {}, + onRename = { _, _ -> }, + onDelete = {}, + onModeChanged = {}, + onMessageShown = {}, + ) + } + + composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_save_device").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_learn_new_key").assertIsDisplayed() + } + + @Test + fun rendersKeysWithCorrectActionButtons() { + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k-ready"), + profileId = ProviderProfileId("abox"), + deviceName = DeviceName("ir-1"), + displayName = "Power Key", + sdkKey = "app_ir_kready", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + val failedKey = SavedInfraredKey( + id = InfraredKeyId("k-failed"), + profileId = ProviderProfileId("abox"), + deviceName = DeviceName("ir-1"), + displayName = "Volume Up", + sdkKey = "app_ir_kfailed", + status = InfraredKeyStatus.FAILED, + lastErrorCode = "20206", + lastErrorDetail = null, + createdAtEpochMillis = 200L, + updatedAtEpochMillis = 200L, + ) + + val state = InfraredControlUiState( + runtimeMode = InfraredRuntimeMode.REAL_ABSDK, + savedDeviceName = DeviceName("ir-1"), + keys = listOf(readyKey, failedKey), + loading = false, + ) + + var sentKeyId: InfraredKeyId? = null + var retriedKeyId: InfraredKeyId? = null + + composeTestRule.setContent { + InfraredControlScreen( + state = state, + onBack = {}, + onSessionInvalid = {}, + onDeviceNameChanged = {}, + onSaveDeviceName = {}, + onLearn = {}, + onRetry = { retriedKeyId = it }, + onSend = { sentKeyId = it }, + onRename = { _, _ -> }, + onDelete = {}, + onModeChanged = {}, + onMessageShown = {}, + ) + } + + composeTestRule.onNodeWithTag("infrared_key_k-ready").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_send_k-ready").performClick() + assertEquals(InfraredKeyId("k-ready"), sentKeyId) + + composeTestRule.onNodeWithTag("infrared_key_k-failed").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_retry_k-failed").performClick() + assertEquals(InfraredKeyId("k-failed"), retriedKeyId) + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt new file mode 100644 index 0000000..61b94b1 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -0,0 +1,456 @@ +package com.flagship.abox.manager.infrared.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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxButton +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode + +@Composable +fun InfraredControlRoute( + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: InfraredControlViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InfraredControlScreen( + state = state, + onBack = onBack, + onSessionInvalid = onSessionInvalid, + onDeviceNameChanged = viewModel::onDeviceNameChanged, + onSaveDeviceName = viewModel::saveDeviceName, + onLearn = viewModel::learnKey, + onRetry = viewModel::retryLearning, + onSend = viewModel::sendKey, + onRename = viewModel::renameKey, + onDelete = viewModel::deleteKey, + onModeChanged = viewModel::setRuntimeMode, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredControlScreen( + state: InfraredControlUiState, + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + onDeviceNameChanged: (String) -> Unit, + onSaveDeviceName: () -> Unit, + onLearn: (String) -> Unit, + onRetry: (InfraredKeyId) -> Unit, + onSend: (InfraredKeyId) -> Unit, + onRename: (InfraredKeyId, String) -> Unit, + onDelete: (InfraredKeyId) -> Unit, + onModeChanged: (InfraredRuntimeMode) -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + + var showLearnDialog by remember { mutableStateOf(false) } + var keyToRename by remember { mutableStateOf(null) } + var keyToDelete by remember { mutableStateOf(null) } + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is InfraredUiMessage.Info -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is InfraredUiMessage.Error -> { + snackbarHostState.showSnackbar(msg.text) + onMessageShown() + } + is InfraredUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.infrared_title)) }, + navigationIcon = { + TextButton(onClick = onBack) { + Text(stringResource(R.string.infrared_back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Mode indicator and toggle + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { + stringResource(R.string.infrared_mode_fake) + } else { + stringResource(R.string.infrared_mode_real) + } + Text( + text = modeText, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.testTag("infrared_mode_label"), + ) + OutlinedButton( + onClick = { + val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { + InfraredRuntimeMode.REAL_ABSDK + } else { + InfraredRuntimeMode.FAKE + } + onModeChanged(target) + }, + enabled = !state.hasActiveLearning, + ) { + Text(stringResource(R.string.infrared_switch_mode)) + } + } + + // Device name configuration card + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val saved = state.savedDeviceName + Text( + text = if (saved != null) { + stringResource(R.string.infrared_saved_device, saved.value) + } else { + stringResource(R.string.infrared_no_device_saved) + }, + style = MaterialTheme.typography.titleMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = state.deviceNameInput, + onValueChange = onDeviceNameChanged, + label = { Text(stringResource(R.string.infrared_device_name_label)) }, + placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) }, + singleLine = true, + modifier = Modifier + .weight(1f) + .testTag("infrared_device_name_input"), + ) + ABoxButton( + onClick = onSaveDeviceName, + modifier = Modifier.testTag("infrared_save_device"), + ) { + Text(stringResource(R.string.infrared_save_device)) + } + } + } + } + + // Learned Keys Section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.infrared_keys_title), + style = MaterialTheme.typography.titleMedium, + ) + ABoxButton( + onClick = { showLearnDialog = true }, + enabled = state.savedDeviceName != null, + modifier = Modifier.testTag("infrared_learn_new_key"), + ) { + Text(stringResource(R.string.infrared_learn_new_key)) + } + } + + if (state.keys.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.infrared_no_keys), + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(state.keys, key = { it.id.value }) { key -> + InfraredKeyRow( + key = key, + isSending = state.sendingKeyIds.contains(key.id), + onSend = { onSend(key.id) }, + onRetry = { onRetry(key.id) }, + onRename = { keyToRename = key }, + onDelete = { keyToDelete = key }, + ) + } + } + } + } + } + } + + if (showLearnDialog) { + var keyName by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = { showLearnDialog = false }, + title = { Text(stringResource(R.string.infrared_learn_dialog_title)) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(stringResource(R.string.infrared_learn_dialog_prompt)) + OutlinedTextField( + value = keyName, + onValueChange = { keyName = it }, + label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + onLearn(keyName) + showLearnDialog = false + }, + enabled = keyName.isNotBlank(), + ) { + Text(stringResource(R.string.infrared_learn_dialog_start)) + } + }, + dismissButton = { + TextButton(onClick = { showLearnDialog = false }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } + + keyToRename?.let { targetKey -> + var newName by remember { mutableStateOf(targetKey.displayName) } + AlertDialog( + onDismissRequest = { keyToRename = null }, + title = { Text(stringResource(R.string.infrared_rename_dialog_title)) }, + text = { + OutlinedTextField( + value = newName, + onValueChange = { newName = it }, + label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + Button( + onClick = { + onRename(targetKey.id, newName) + keyToRename = null + }, + enabled = newName.isNotBlank(), + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { keyToRename = null }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } + + keyToDelete?.let { targetKey -> + AlertDialog( + onDismissRequest = { keyToDelete = null }, + title = { Text(stringResource(R.string.infrared_delete_dialog_title)) }, + text = { Text(stringResource(R.string.infrared_delete_dialog_message)) }, + confirmButton = { + Button( + onClick = { + onDelete(targetKey.id) + keyToDelete = null + }, + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = { keyToDelete = null }) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) + } +} + +@Composable +private fun InfraredKeyRow( + key: SavedInfraredKey, + isSending: Boolean, + onSend: () -> Unit, + onRetry: () -> Unit, + onRename: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag("infrared_key_${key.id.value}"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column { + Text( + text = key.displayName, + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "SDK: ${key.sdkKey}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + val statusLabel = when (key.status) { + InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning) + InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready) + InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed) + } + Text( + text = statusLabel, + style = MaterialTheme.typography.bodyMedium, + color = when (key.status) { + InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary + InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary + InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error + }, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + if (key.status == InfraredKeyStatus.READY) { + Button( + onClick = onSend, + enabled = !isSending, + modifier = Modifier.testTag("infrared_send_${key.id.value}"), + ) { + if (isSending) { + Text(stringResource(R.string.infrared_sending)) + } else { + Text(stringResource(R.string.infrared_send)) + } + } + Spacer(modifier = Modifier.width(8.dp)) + } + + if (key.status == InfraredKeyStatus.FAILED) { + Button( + onClick = onRetry, + modifier = Modifier.testTag("infrared_retry_${key.id.value}"), + ) { + Text(stringResource(R.string.infrared_retry)) + } + Spacer(modifier = Modifier.width(8.dp)) + } + + if (key.status != InfraredKeyStatus.LEARNING) { + OutlinedButton(onClick = onRename) { + Text(stringResource(R.string.infrared_rename)) + } + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton(onClick = onDelete) { + Text(stringResource(R.string.infrared_delete)) + } + } else { + CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp)) + } + } + } + } +} diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml new file mode 100644 index 0000000..8592155 --- /dev/null +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -0,0 +1,33 @@ + + 红外控制 + 模式:模拟 / Fake + 模式:真实 ABSDK + 切换模式 + 红外设备名称 + 例如:ir-living + 已保存设备:%1$s + 未配置红外设备 + 保存设备名 + 已学习按键 + 暂无保存的红外按键 + 学习新按键 + 学习中… + 就绪 + 失败 + 发射 + 发射中… + 重试 + 重命名 + 删除 + 学习红外按键 + 请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。 + 按键名称(如:电源) + 开始学习 + 重命名按键 + 新按键名称 + 删除按键 + 仅从本机列表移除,ABox 服务端可能仍保留该按键。 + 取消 + 确认 + 返回 + diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml new file mode 100644 index 0000000..42bd2f1 --- /dev/null +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -0,0 +1,33 @@ + + Infrared Control + Mode: Fake / Simulated + Mode: Real ABSDK + Switch Mode + Infrared Device Name + e.g. ir-living + Saved Device: %1$s + No infrared device configured + Save Device + Learned Keys + No infrared keys saved + Learn New Key + Learning… + Ready + Failed + Send + Sending… + Retry + Rename + Delete + Learn Infrared Key + Prepare the physical remote. After starting, point it at the infrared controller and press the target button. + Key Name (e.g. Power) + Start Learning + Rename Key + New Display Name + Delete Key + Only removes the key locally from this app. ABox server may retain the learned key. + Cancel + Confirm + Back +