feat(infrared): integrate learned keys management screen and sync provider profile mode
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -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)
|
||||
}
|
||||
}
|
||||
+34
-17
@@ -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<String, SavedInfraredKey> = 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<String, SavedInfraredKey> = 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,
|
||||
|
||||
+11
@@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
+468
@@ -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<SavedInfraredKey?>(null) }
|
||||
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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<SavedInfraredKey> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
|
||||
val message: InfraredUiMessage? = null,
|
||||
) {
|
||||
val hasActiveLearning: Boolean
|
||||
get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
|
||||
}
|
||||
+307
@@ -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<Set<InfraredKeyId>>(emptySet())
|
||||
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(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<SavedInfraredKey> = emptyList(),
|
||||
val loading: Boolean = false,
|
||||
val configError: DomainError? = null,
|
||||
)
|
||||
|
||||
private val serverStateFlow: Flow<ServerState> =
|
||||
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<InfraredControlUiState> = 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")
|
||||
}
|
||||
}
|
||||
@@ -76,4 +76,15 @@
|
||||
<string name="infrared_key_tv_home">电视-主页</string>
|
||||
<string name="infrared_key_tv_back">电视-返回</string>
|
||||
<string name="infrared_key_tv_menu">电视-菜单</string>
|
||||
<string name="infrared_control_title">红外按键管理</string>
|
||||
<string name="infrared_mode_label">运行环境:%1$s</string>
|
||||
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
|
||||
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
|
||||
<string name="infrared_msg_device_name_saved">设备名称已保存</string>
|
||||
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
|
||||
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
|
||||
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
|
||||
<string name="infrared_msg_key_renamed">按键已重命名</string>
|
||||
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
|
||||
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
|
||||
</resources>
|
||||
|
||||
@@ -76,4 +76,15 @@
|
||||
<string name="infrared_key_tv_home">TV Home</string>
|
||||
<string name="infrared_key_tv_back">TV Back</string>
|
||||
<string name="infrared_key_tv_menu">TV Menu</string>
|
||||
<string name="infrared_control_title">Infrared Key Management</string>
|
||||
<string name="infrared_mode_label">Environment: %1$s</string>
|
||||
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
|
||||
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
|
||||
<string name="infrared_msg_device_name_saved">Device name saved</string>
|
||||
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
|
||||
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
|
||||
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
|
||||
<string name="infrared_msg_key_renamed">Key renamed</string>
|
||||
<string name="infrared_msg_key_deleted">Key removed locally</string>
|
||||
<string name="infrared_msg_key_not_ready">Key is not ready to send</string>
|
||||
</resources>
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+276
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user