From 86af7f4c91099c7e30c64ee298d79ebba46a1ab7 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 13:39:01 +0800 Subject: [PATCH] feat(infrared): integrate learned keys management screen and sync provider profile mode --- .../abox/manager/ui/ABoxManagerApp.kt | 16 + .../remote/InfraredRemoteScreenTest.kt | 5 + .../infrared/ui/InfraredControlScreenTest.kt | 77 +++ .../remote/InfraredRemoteViewModel.kt | 51 +- .../remote/ui/InfraredRemoteScreen.kt | 11 + .../infrared/ui/InfraredControlScreen.kt | 468 ++++++++++++++++++ .../infrared/ui/InfraredControlUiState.kt | 30 ++ .../infrared/ui/InfraredControlViewModel.kt | 307 ++++++++++++ .../src/main/res/values-zh-rCN/strings.xml | 11 + .../infrared/src/main/res/values/strings.xml | 11 + .../remote/InfraredRemoteViewModelTest.kt | 2 + .../ui/InfraredControlViewModelTest.kt | 276 +++++++++++ 12 files changed, 1248 insertions(+), 17 deletions(-) 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/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index abd9d09..257990d 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -22,6 +22,7 @@ import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute +import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginViewModel @@ -114,7 +115,22 @@ private fun AuthenticatedNavHost( navArgument("profileId") { type = NavType.StringType }, ), ) { + val encodedProfileId = android.net.Uri.encode(profile.id.value) InfraredRemoteRoute( + onBack = { navController.popBackStack() }, + onOpenSettings = { + navController.navigate("infrared_settings/$encodedProfileId") + }, + onSessionInvalid = onLogout, + ) + } + composable( + route = "infrared_settings/{profileId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + ), + ) { + InfraredControlRoute( onBack = { navController.popBackStack() }, onSessionInvalid = onLogout, ) diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt index 28e8c72..a071166 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -25,11 +25,13 @@ class InfraredRemoteScreenTest { savedDeviceName = DeviceName("ir-1"), ) var clickedSpec: RemoteButtonSpec? = null + var settingsClicked = false composeTestRule.setContent { InfraredRemoteScreen( state = state, onBack = {}, + onOpenSettings = { settingsClicked = true }, onSessionInvalid = {}, onSceneSelected = {}, onToggleLearningMode = {}, @@ -40,5 +42,8 @@ class InfraredRemoteScreenTest { composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick() assertEquals("AC_POWER", clickedSpec?.id) + + composeTestRule.onNodeWithTag("remote_settings_button").assertIsDisplayed().performClick() + assertTrue(settingsClicked) } } 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..99699b1 --- /dev/null +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt @@ -0,0 +1,77 @@ +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.performClick +import androidx.compose.ui.test.performTextInput +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.ProviderType +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 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 rendersControlScreenAndTriggersKeyOperations() { + val testKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = ProviderProfileId("profile1"), + deviceName = DeviceName("ir-1"), + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 100L, + updatedAtEpochMillis = 100L, + ) + val state = InfraredControlUiState( + profileId = ProviderProfileId("profile1"), + providerType = ProviderType.LOCAL_EXPERIENCE, + savedDeviceName = DeviceName("ir-1"), + keys = listOf(testKey), + loading = false, + ) + + var sendClicked = false + var deleteClicked = false + + composeTestRule.setContent { + InfraredControlScreen( + state = state, + onBack = {}, + onSessionInvalid = {}, + onDeviceNameChanged = {}, + onSaveDeviceName = {}, + onLearn = { _, _ -> }, + onRetry = {}, + onSend = { sendClicked = true }, + onRename = { _, _ -> }, + onDelete = { deleteClicked = true }, + onMessageShown = {}, + nextSlot = 2, + ) + } + + composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed() + composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick() + assertTrue(sendClicked) + + composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick() + composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick() + assertTrue(deleteClicked) + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 38deb4e..2e1806c 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -6,7 +6,10 @@ 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.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.ProviderType import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository import com.flagship.abox.manager.domain.infrared.InfraredKeyId import com.flagship.abox.manager.domain.infrared.InfraredKeyRef @@ -41,6 +44,8 @@ data class InfraredRemoteUiState( val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, val isLearningMode: Boolean = false, val profileId: ProviderProfileId? = null, + val providerType: ProviderType? = null, + val providerDisplayName: String? = null, val savedDeviceName: DeviceName? = null, val learnedSlotMap: Map = emptyMap(), val sendingKeySlot: String? = null, @@ -54,6 +59,7 @@ class InfraredRemoteViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val keyRepository: InfraredKeyRepository, private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val providerProfileStore: ProviderProfileStore, private val learningCoordinator: InfraredLearningCoordinator, private val sendInfraredKey: SendInfraredKey, ) : ViewModel() { @@ -77,6 +83,7 @@ class InfraredRemoteViewModel @Inject constructor( } private data class InfraredRemoteConfigSnapshot( + val providerProfile: ProviderProfile? = null, val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME, val learnedSlotMap: Map = emptyMap(), val configError: DomainError? = null, @@ -86,23 +93,31 @@ class InfraredRemoteViewModel @Inject constructor( if (profileId == null) { flowOf(InfraredRemoteConfigSnapshot()) } else { - deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> - when (deviceResult) { - is DomainResult.Failure -> flowOf( - InfraredRemoteConfigSnapshot(configError = deviceResult.error), - ) - is DomainResult.Success -> { - val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME - keyRepository.observeKeys(profileId, deviceName).map { keysResult -> - when (keysResult) { - is DomainResult.Failure -> InfraredRemoteConfigSnapshot( - savedDeviceName = deviceName, - configError = keysResult.error, - ) - is DomainResult.Success -> InfraredRemoteConfigSnapshot( - savedDeviceName = deviceName, - learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, - ) + providerProfileStore.observeProfiles().flatMapLatest { profiles -> + val active = profiles.find { it.id == profileId } + deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Failure -> flowOf( + InfraredRemoteConfigSnapshot( + providerProfile = active, + configError = deviceResult.error, + ), + ) + is DomainResult.Success -> { + val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME + keyRepository.observeKeys(profileId, deviceName).map { keysResult -> + when (keysResult) { + is DomainResult.Failure -> InfraredRemoteConfigSnapshot( + providerProfile = active, + savedDeviceName = deviceName, + configError = keysResult.error, + ) + is DomainResult.Success -> InfraredRemoteConfigSnapshot( + providerProfile = active, + savedDeviceName = deviceName, + learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, + ) + } } } } @@ -134,6 +149,8 @@ class InfraredRemoteViewModel @Inject constructor( selectedScene = scene, isLearningMode = isLearning, profileId = profileId, + providerType = snapshot.providerProfile?.type, + providerDisplayName = snapshot.providerProfile?.displayName, savedDeviceName = snapshot.savedDeviceName, learnedSlotMap = snapshot.learnedSlotMap, sendingKeySlot = sendingSlot, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index 1df7594..97ccfc3 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -56,6 +56,7 @@ import com.flagship.abox.manager.infrared.remote.UiText @Composable fun InfraredRemoteRoute( onBack: () -> Unit, + onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, viewModel: InfraredRemoteViewModel = hiltViewModel(), ) { @@ -64,6 +65,7 @@ fun InfraredRemoteRoute( InfraredRemoteScreen( state = state, onBack = onBack, + onOpenSettings = onOpenSettings, onSessionInvalid = onSessionInvalid, onSceneSelected = viewModel::switchScene, onToggleLearningMode = viewModel::toggleLearningMode, @@ -77,6 +79,7 @@ fun InfraredRemoteRoute( internal fun InfraredRemoteScreen( state: InfraredRemoteUiState, onBack: () -> Unit, + onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, onSceneSelected: (RemoteScene) -> Unit, onToggleLearningMode: (Boolean) -> Unit, @@ -153,6 +156,14 @@ internal fun InfraredRemoteScreen( ), ) } + + // 按键管理 / 设置入口按钮 + IconButton( + onClick = onOpenSettings, + modifier = Modifier.testTag("remote_settings_button"), + ) { + Text("⚙️", fontSize = 20.sp) + } }, ) }, 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..3ede863 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -0,0 +1,468 @@ +package com.flagship.abox.manager.infrared.ui + +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.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.ProviderType +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.remote.UiText + +@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, + onMessageShown = viewModel::clearMessage, + nextSlot = viewModel.nextAvailableKeySlot(), + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredControlScreen( + state: InfraredControlUiState, + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + onDeviceNameChanged: (String) -> Unit, + onSaveDeviceName: () -> Unit, + onLearn: (name: String, slot: String?) -> Unit, + onRetry: (InfraredKeyId) -> Unit, + onSend: (InfraredKeyId) -> Unit, + onRename: (InfraredKeyId, String) -> Unit, + onDelete: (InfraredKeyId) -> Unit, + onMessageShown: () -> Unit, + nextSlot: Int, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + 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 -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is InfraredUiMessage.Error -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is InfraredUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.infrared_control_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), + ) { + // 环境与设备卡片 + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val envText = when (state.providerType) { + ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) + ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) + null -> state.profileId?.value ?: "" + } + Text( + text = stringResource(R.string.infrared_mode_label, envText), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + + Text( + text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value), + style = MaterialTheme.typography.bodyMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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"), + ) + Button( + onClick = onSaveDeviceName, + modifier = Modifier.testTag("infrared_save_device_button"), + ) { + Text(stringResource(R.string.infrared_save_device)) + } + } + } + } + + // 已学习按键列表卡片 + Card(modifier = Modifier.fillMaxWidth().weight(1f)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.infrared_keys_title), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + Button( + onClick = { showLearnDialog = true }, + modifier = Modifier.testTag("infrared_learn_new_key_button"), + ) { + 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, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + 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("") } + var keySlot by remember { mutableStateOf(nextSlot.toString()) } + + 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().testTag("infrared_learn_name_input"), + ) + OutlinedTextField( + value = keySlot, + onValueChange = { keySlot = it }, + label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("infrared_learn_slot_input"), + ) + } + }, + confirmButton = { + Button( + onClick = { + onLearn(keyName, keySlot) + showLearnDialog = false + }, + enabled = keyName.isNotBlank() && keySlot.isNotBlank(), + modifier = Modifier.testTag("infrared_learn_dialog_confirm"), + ) { + 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().testTag("infrared_rename_input"), + ) + }, + confirmButton = { + Button( + onClick = { + onRename(targetKey.id, newName) + keyToRename = null + }, + enabled = newName.isNotBlank(), + modifier = Modifier.testTag("infrared_rename_confirm"), + ) { + 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 + }, + modifier = Modifier.testTag("infrared_delete_confirm"), + ) { + 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 = stringResource(R.string.infrared_key_slot_label, 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, + modifier = Modifier.testTag("infrared_rename_btn_${key.id.value}"), + ) { + Text(stringResource(R.string.infrared_rename)) + } + Spacer(modifier = Modifier.width(8.dp)) + OutlinedButton( + onClick = onDelete, + modifier = Modifier.testTag("infrared_delete_btn_${key.id.value}"), + ) { + Text(stringResource(R.string.infrared_delete)) + } + } else { + CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp)) + } + } + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt new file mode 100644 index 0000000..1231724 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt @@ -0,0 +1,30 @@ +package com.flagship.abox.manager.infrared.ui + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.infrared.remote.UiText + +sealed interface InfraredUiMessage { + data class Info(val text: UiText) : InfraredUiMessage + data class Error(val text: UiText) : InfraredUiMessage + data object SessionInvalid : InfraredUiMessage +} + +data class InfraredControlUiState( + val profileId: ProviderProfileId? = null, + val providerType: ProviderType? = null, + val providerDisplayName: String? = null, + val deviceNameInput: String = "", + val savedDeviceName: DeviceName = DeviceName("ir-1"), + val keys: List = emptyList(), + val loading: Boolean = true, + val sendingKeyIds: Set = emptySet(), + val message: InfraredUiMessage? = null, +) { + val hasActiveLearning: Boolean + get() = keys.any { it.status == InfraredKeyStatus.LEARNING } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt new file mode 100644 index 0000000..4feed56 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -0,0 +1,307 @@ +package com.flagship.abox.manager.infrared.ui + +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.ProviderProfileStore +import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.InfraredKeyId +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.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +import com.flagship.abox.manager.domain.infrared.SavedInfraredKey +import com.flagship.abox.manager.domain.infrared.SendInfraredKey +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.remote.UiText +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.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredControlViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val keyRepository: InfraredKeyRepository, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val providerProfileStore: ProviderProfileStore, + private val learningCoordinator: InfraredLearningCoordinator, + private val sendInfraredKey: SendInfraredKey, + private val renameInfraredKey: RenameInfraredKey, + private val deleteInfraredKey: DeleteInfraredKey, + private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val deviceNameInputFlow = MutableStateFlow("") + private val sendingKeyIdsFlow = MutableStateFlow>(emptySet()) + private val messageFlow = MutableStateFlow(null) + + init { + if (profileId != null) { + val startupCutoff = System.currentTimeMillis() + learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff) + } + } + + private data class ServerState( + val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null, + val savedDevice: DeviceName = DEFAULT_DEVICE_NAME, + val keys: List = emptyList(), + val loading: Boolean = false, + val configError: DomainError? = null, + ) + + private val serverStateFlow: Flow = + if (profileId == null) { + flowOf(ServerState(loading = false)) + } else { + providerProfileStore.observeProfiles().flatMapLatest { profiles -> + val active = profiles.find { it.id == profileId } + deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult -> + when (deviceResult) { + is DomainResult.Failure -> flowOf( + ServerState( + providerProfile = active, + configError = deviceResult.error, + ), + ) + is DomainResult.Success -> { + val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME + keyRepository.observeKeys(profileId, deviceName).map { keysResult -> + when (keysResult) { + is DomainResult.Failure -> ServerState( + providerProfile = active, + savedDevice = deviceName, + configError = keysResult.error, + ) + is DomainResult.Success -> ServerState( + providerProfile = active, + savedDevice = deviceName, + keys = keysResult.value, + ) + } + } + } + } + } + } + } + + val state: StateFlow = combine( + serverStateFlow, + deviceNameInputFlow, + sendingKeyIdsFlow, + messageFlow, + ) { serverState, input, sendingIds, msg -> + val resolvedMessage = msg ?: serverState.configError?.let { error -> + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid + is DomainError.DeviceOffline -> InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> InfraredUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_load_failed), + ) + } + } + InfraredControlUiState( + profileId = profileId, + providerType = serverState.providerProfile?.type, + providerDisplayName = serverState.providerProfile?.displayName, + deviceNameInput = input, + savedDeviceName = serverState.savedDevice, + keys = serverState.keys, + loading = serverState.loading, + sendingKeyIds = sendingIds, + message = resolvedMessage, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredControlUiState(profileId = profileId, loading = true), + ) + + fun onDeviceNameChanged(value: String) { + deviceNameInputFlow.value = value + } + + fun saveDeviceName() { + val currentProfile = profileId ?: return + val trimmed = deviceNameInputFlow.value.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_name_blank), + ) + return + } + viewModelScope.launch { + when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_device_name_saved), + ) + deviceNameInputFlow.value = "" + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun nextAvailableKeySlot(): Int { + val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() + return (1..100).firstOrNull { it !in usedSlots } ?: 1 + } + + fun learnKey(displayName: String, keySlotInput: String? = null) { + val currentProfile = profileId ?: return + val savedDevice = state.value.savedDeviceName + val trimmedName = displayName.trim() + if (trimmedName.isBlank()) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_key_name_blank), + ) + return + } + val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) { + val slotInt = keySlotInput.trim().toIntOrNull() + if (slotInt == null || slotInt !in 1..100) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_slot_invalid), + ) + return + } + slotInt.toString() + } else { + nextAvailableKeySlot().toString() + } + learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) + } + + fun retryLearning(keyId: InfraredKeyId) { + val currentProfile = profileId ?: return + learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) + } + + fun sendKey(keyId: InfraredKeyId) { + val currentProfile = profileId ?: return + if (state.value.sendingKeyIds.contains(keyId)) return + + val key = state.value.keys.find { it.id == keyId } + if (key == null || key.status != InfraredKeyStatus.READY) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_key_not_ready), + ) + return + } + + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId + viewModelScope.launch { + try { + when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(key.displayName)), + ) + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } finally { + sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId + } + } + } + + fun renameKey(keyId: InfraredKeyId, displayName: String) { + val currentProfile = profileId ?: return + val trimmed = displayName.trim() + if (trimmed.isBlank()) { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_key_name_blank), + ) + return + } + viewModelScope.launch { + when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_key_renamed), + ) + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun deleteKey(keyId: InfraredKeyId) { + val currentProfile = profileId ?: return + viewModelScope.launch { + when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_key_deleted), + ) + } + is DomainResult.Failure -> { + handleDomainError(result.error) + } + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> { + messageFlow.value = InfraredUiMessage.SessionInvalid + } + is DomainError.DeviceOffline -> { + messageFlow.value = InfraredUiMessage.Error( + UiText.StringResource( + R.string.infrared_msg_device_offline, + listOf(error.externalCode ?: "20206"), + ), + ) + } + else -> { + messageFlow.value = InfraredUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) + } + } + } + + companion object { + val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + } +} 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 index ddf40ac..6a798f4 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -76,4 +76,15 @@ 电视-主页 电视-返回 电视-菜单 + 红外按键管理 + 运行环境:%1$s + 本地体验(模拟) + ABox(真实硬件) + 设备名称已保存 + 设备名称不能为空 + 按键名称不能为空 + 按键编号必须在 1 到 100 之间 + 按键已重命名 + 按键已从本机删除 + 按键尚未就绪,无法发射 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index a91667e..14c6be5 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -76,4 +76,15 @@ TV Home TV Back TV Menu + Infrared Key Management + Environment: %1$s + Local Experience (Simulation) + ABox (Real Hardware) + Device name saved + Device name cannot be blank + Key name cannot be blank + Key slot must be between 1 and 100 + Key renamed + Key removed locally + Key is not ready to send diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 60bd17b..2f0fe96 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -42,6 +42,7 @@ class InfraredRemoteViewModelTest { private val keyRepo = FakeInfraredKeyRepository() private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore() private val gateway = FakeInfraredGateway() private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) @@ -67,6 +68,7 @@ class InfraredRemoteViewModelTest { savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), keyRepository = keyRepo, deviceConfigurationRepository = deviceRepo, + providerProfileStore = profileStore, learningCoordinator = coordinator, sendInfraredKey = send, ) diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt new file mode 100644 index 0000000..aca8f9b --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt @@ -0,0 +1,276 @@ +package com.flagship.abox.manager.infrared.ui + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey +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.InfraredKeyRef +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.LearnInfraredKey +import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning +import com.flagship.abox.manager.domain.infrared.RenameInfraredKey +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.R +import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator +import com.flagship.abox.manager.infrared.remote.UiText +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 com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +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.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredControlViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profileId = ProviderProfileId("profile1") + private val providerProfile = ProviderProfile( + id = profileId, + type = ProviderType.LOCAL_EXPERIENCE, + displayName = "Local Simulation", + ) + private val device = DeviceName("ir-living") + private val clock = EpochMillisClock { 10_000L } + + private val keyRepo = FakeInfraredKeyRepository() + private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val profileStore = FakeProviderProfileStore() + private val gateway = FakeInfraredGateway() + private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock) + + private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock) + private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock) + private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock) + private val send = SendInfraredKey(keyRepo, gateway) + private val rename = RenameInfraredKey(keyRepo, clock) + private val delete = DeleteInfraredKey(keyRepo) + private val recover = RecoverInterruptedInfraredLearning(keyRepo, clock) + private lateinit var coordinator: InfraredLearningCoordinator + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): InfraredControlViewModel { + return InfraredControlViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")), + keyRepository = keyRepo, + deviceConfigurationRepository = deviceRepo, + providerProfileStore = profileStore, + learningCoordinator = coordinator, + sendInfraredKey = send, + renameInfraredKey = rename, + deleteInfraredKey = delete, + recoverInterruptedInfraredLearning = recover, + ) + } + + @Test + fun initialStateLoadsKeysAndDeviceNameForProfile() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + 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() + + assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType) + assertEquals(device, vm.state.value.savedDeviceName) + assertEquals(1, vm.state.value.keys.size) + assertEquals("Power", vm.state.value.keys.first().displayName) + + job.cancel() + } + + @Test + fun saveDeviceNameUpdatesDeviceConfiguration() = testScope.runTest { + profileStore.saveProfile(providerProfile) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged("ir-bedroom") + vm.saveDeviceName() + advanceUntilIdle() + + assertEquals(DeviceName("ir-bedroom"), vm.state.value.savedDeviceName) + assertEquals( + InfraredUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)), + vm.state.value.message, + ) + + job.cancel() + } + + @Test + fun blankDeviceNameEmitsErrorMessage() = testScope.runTest { + profileStore.saveProfile(providerProfile) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onDeviceNameChanged(" ") + vm.saveDeviceName() + advanceUntilIdle() + + assertEquals( + InfraredUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)), + vm.state.value.message, + ) + + job.cancel() + } + + @Test + fun learnKeyTriggersCoordinator() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.learnKey("Volume Up", "5") + advanceUntilIdle() + + assertEquals(1, gateway.learnedKeys.size) + assertEquals("5", gateway.learnedKeys.first().key) + + job.cancel() + } + + @Test + fun sendKeyTriggersSendInfraredKeyWhenReady() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + 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.sendKey(InfraredKeyId("k1")) + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + + job.cancel() + } + + @Test + fun renameKeyUpdatesKeyName() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + 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.renameKey(InfraredKeyId("k1"), "Main Power") + advanceUntilIdle() + + val updated = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value!! + assertEquals("Main Power", updated.displayName) + + job.cancel() + } + + @Test + fun deleteKeyRemovesKey() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + val readyKey = SavedInfraredKey( + id = InfraredKeyId("k1"), + profileId = profileId, + deviceName = device, + displayName = "Power", + 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.deleteKey(InfraredKeyId("k1")) + advanceUntilIdle() + + val result = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value + assertEquals(null, result) + + job.cancel() + } +}