feat(infrared): add infrared control, learning, and virtual remote module #9

Merged
KremeCN merged 41 commits from feat/moler-infrared-control into main 2026-08-25 14:51:34 +08:00
4 changed files with 643 additions and 0 deletions
Showing only changes of commit 116f8d1b75 - Show all commits
@@ -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)
}
}
@@ -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<SavedInfraredKey?>(null) }
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(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))
}
}
}
}
}
@@ -0,0 +1,33 @@
<resources>
<string name="infrared_title">红外控制</string>
<string name="infrared_mode_fake">模式:模拟 / Fake</string>
<string name="infrared_mode_real">模式:真实 ABSDK</string>
<string name="infrared_switch_mode">切换模式</string>
<string name="infrared_device_name_label">红外设备名称</string>
<string name="infrared_device_name_hint">例如:ir-living</string>
<string name="infrared_saved_device">已保存设备:%1$s</string>
<string name="infrared_no_device_saved">未配置红外设备</string>
<string name="infrared_save_device">保存设备名</string>
<string name="infrared_keys_title">已学习按键</string>
<string name="infrared_no_keys">暂无保存的红外按键</string>
<string name="infrared_learn_new_key">学习新按键</string>
<string name="infrared_status_learning">学习中…</string>
<string name="infrared_status_ready">就绪</string>
<string name="infrared_status_failed">失败</string>
<string name="infrared_send">发射</string>
<string name="infrared_sending">发射中…</string>
<string name="infrared_retry">重试</string>
<string name="infrared_rename">重命名</string>
<string name="infrared_delete">删除</string>
<string name="infrared_learn_dialog_title">学习红外按键</string>
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string>
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
<string name="infrared_learn_dialog_start">开始学习</string>
<string name="infrared_rename_dialog_title">重命名按键</string>
<string name="infrared_rename_dialog_input_label">新按键名称</string>
<string name="infrared_delete_dialog_title">删除按键</string>
<string name="infrared_delete_dialog_message">仅从本机列表移除,ABox 服务端可能仍保留该按键。</string>
<string name="infrared_dialog_cancel">取消</string>
<string name="infrared_dialog_confirm">确认</string>
<string name="infrared_back">返回</string>
</resources>
@@ -0,0 +1,33 @@
<resources>
<string name="infrared_title">Infrared Control</string>
<string name="infrared_mode_fake">Mode: Fake / Simulated</string>
<string name="infrared_mode_real">Mode: Real ABSDK</string>
<string name="infrared_switch_mode">Switch Mode</string>
<string name="infrared_device_name_label">Infrared Device Name</string>
<string name="infrared_device_name_hint">e.g. ir-living</string>
<string name="infrared_saved_device">Saved Device: %1$s</string>
<string name="infrared_no_device_saved">No infrared device configured</string>
<string name="infrared_save_device">Save Device</string>
<string name="infrared_keys_title">Learned Keys</string>
<string name="infrared_no_keys">No infrared keys saved</string>
<string name="infrared_learn_new_key">Learn New Key</string>
<string name="infrared_status_learning">Learning…</string>
<string name="infrared_status_ready">Ready</string>
<string name="infrared_status_failed">Failed</string>
<string name="infrared_send">Send</string>
<string name="infrared_sending">Sending…</string>
<string name="infrared_retry">Retry</string>
<string name="infrared_rename">Rename</string>
<string name="infrared_delete">Delete</string>
<string name="infrared_learn_dialog_title">Learn Infrared Key</string>
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
<string name="infrared_learn_dialog_start">Start Learning</string>
<string name="infrared_rename_dialog_title">Rename Key</string>
<string name="infrared_rename_dialog_input_label">New Display Name</string>
<string name="infrared_delete_dialog_title">Delete Key</string>
<string name="infrared_delete_dialog_message">Only removes the key locally from this app. ABox server may retain the learned key.</string>
<string name="infrared_dialog_cancel">Cancel</string>
<string name="infrared_dialog_confirm">Confirm</string>
<string name="infrared_back">Back</string>
</resources>