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.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.Username
|
import com.flagship.abox.manager.domain.Username
|
||||||
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
|
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.devicecontrollist.DeviceListScreen
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
@@ -114,7 +115,22 @@ private fun AuthenticatedNavHost(
|
|||||||
navArgument("profileId") { type = NavType.StringType },
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
|
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
||||||
InfraredRemoteRoute(
|
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() },
|
onBack = { navController.popBackStack() },
|
||||||
onSessionInvalid = onLogout,
|
onSessionInvalid = onLogout,
|
||||||
)
|
)
|
||||||
|
|||||||
+5
@@ -25,11 +25,13 @@ class InfraredRemoteScreenTest {
|
|||||||
savedDeviceName = DeviceName("ir-1"),
|
savedDeviceName = DeviceName("ir-1"),
|
||||||
)
|
)
|
||||||
var clickedSpec: RemoteButtonSpec? = null
|
var clickedSpec: RemoteButtonSpec? = null
|
||||||
|
var settingsClicked = false
|
||||||
|
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
InfraredRemoteScreen(
|
InfraredRemoteScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBack = {},
|
onBack = {},
|
||||||
|
onOpenSettings = { settingsClicked = true },
|
||||||
onSessionInvalid = {},
|
onSessionInvalid = {},
|
||||||
onSceneSelected = {},
|
onSceneSelected = {},
|
||||||
onToggleLearningMode = {},
|
onToggleLearningMode = {},
|
||||||
@@ -40,5 +42,8 @@ class InfraredRemoteScreenTest {
|
|||||||
|
|
||||||
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
|
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
|
||||||
assertEquals("AC_POWER", clickedSpec?.id)
|
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.DeviceName
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
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.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.InfraredDeviceConfigurationRepository
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||||
@@ -41,6 +44,8 @@ data class InfraredRemoteUiState(
|
|||||||
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
||||||
val isLearningMode: Boolean = false,
|
val isLearningMode: Boolean = false,
|
||||||
val profileId: ProviderProfileId? = null,
|
val profileId: ProviderProfileId? = null,
|
||||||
|
val providerType: ProviderType? = null,
|
||||||
|
val providerDisplayName: String? = null,
|
||||||
val savedDeviceName: DeviceName? = null,
|
val savedDeviceName: DeviceName? = null,
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||||
val sendingKeySlot: String? = null,
|
val sendingKeySlot: String? = null,
|
||||||
@@ -54,6 +59,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val keyRepository: InfraredKeyRepository,
|
private val keyRepository: InfraredKeyRepository,
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||||
|
private val providerProfileStore: ProviderProfileStore,
|
||||||
private val learningCoordinator: InfraredLearningCoordinator,
|
private val learningCoordinator: InfraredLearningCoordinator,
|
||||||
private val sendInfraredKey: SendInfraredKey,
|
private val sendInfraredKey: SendInfraredKey,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
@@ -77,6 +83,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private data class InfraredRemoteConfigSnapshot(
|
private data class InfraredRemoteConfigSnapshot(
|
||||||
|
val providerProfile: ProviderProfile? = null,
|
||||||
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
|
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||||
val configError: DomainError? = null,
|
val configError: DomainError? = null,
|
||||||
@@ -86,23 +93,31 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
if (profileId == null) {
|
if (profileId == null) {
|
||||||
flowOf(InfraredRemoteConfigSnapshot())
|
flowOf(InfraredRemoteConfigSnapshot())
|
||||||
} else {
|
} else {
|
||||||
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
|
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
|
||||||
when (deviceResult) {
|
val active = profiles.find { it.id == profileId }
|
||||||
is DomainResult.Failure -> flowOf(
|
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
|
||||||
InfraredRemoteConfigSnapshot(configError = deviceResult.error),
|
when (deviceResult) {
|
||||||
)
|
is DomainResult.Failure -> flowOf(
|
||||||
is DomainResult.Success -> {
|
InfraredRemoteConfigSnapshot(
|
||||||
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
providerProfile = active,
|
||||||
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
|
configError = deviceResult.error,
|
||||||
when (keysResult) {
|
),
|
||||||
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
|
)
|
||||||
savedDeviceName = deviceName,
|
is DomainResult.Success -> {
|
||||||
configError = keysResult.error,
|
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
||||||
)
|
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
|
||||||
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
|
when (keysResult) {
|
||||||
savedDeviceName = deviceName,
|
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
|
||||||
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
|
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,
|
selectedScene = scene,
|
||||||
isLearningMode = isLearning,
|
isLearningMode = isLearning,
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
|
providerType = snapshot.providerProfile?.type,
|
||||||
|
providerDisplayName = snapshot.providerProfile?.displayName,
|
||||||
savedDeviceName = snapshot.savedDeviceName,
|
savedDeviceName = snapshot.savedDeviceName,
|
||||||
learnedSlotMap = snapshot.learnedSlotMap,
|
learnedSlotMap = snapshot.learnedSlotMap,
|
||||||
sendingKeySlot = sendingSlot,
|
sendingKeySlot = sendingSlot,
|
||||||
|
|||||||
+11
@@ -56,6 +56,7 @@ import com.flagship.abox.manager.infrared.remote.UiText
|
|||||||
@Composable
|
@Composable
|
||||||
fun InfraredRemoteRoute(
|
fun InfraredRemoteRoute(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
onSessionInvalid: () -> Unit,
|
onSessionInvalid: () -> Unit,
|
||||||
viewModel: InfraredRemoteViewModel = hiltViewModel(),
|
viewModel: InfraredRemoteViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -64,6 +65,7 @@ fun InfraredRemoteRoute(
|
|||||||
InfraredRemoteScreen(
|
InfraredRemoteScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
|
onOpenSettings = onOpenSettings,
|
||||||
onSessionInvalid = onSessionInvalid,
|
onSessionInvalid = onSessionInvalid,
|
||||||
onSceneSelected = viewModel::switchScene,
|
onSceneSelected = viewModel::switchScene,
|
||||||
onToggleLearningMode = viewModel::toggleLearningMode,
|
onToggleLearningMode = viewModel::toggleLearningMode,
|
||||||
@@ -77,6 +79,7 @@ fun InfraredRemoteRoute(
|
|||||||
internal fun InfraredRemoteScreen(
|
internal fun InfraredRemoteScreen(
|
||||||
state: InfraredRemoteUiState,
|
state: InfraredRemoteUiState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
|
onOpenSettings: () -> Unit,
|
||||||
onSessionInvalid: () -> Unit,
|
onSessionInvalid: () -> Unit,
|
||||||
onSceneSelected: (RemoteScene) -> Unit,
|
onSceneSelected: (RemoteScene) -> Unit,
|
||||||
onToggleLearningMode: (Boolean) -> 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_home">电视-主页</string>
|
||||||
<string name="infrared_key_tv_back">电视-返回</string>
|
<string name="infrared_key_tv_back">电视-返回</string>
|
||||||
<string name="infrared_key_tv_menu">电视-菜单</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>
|
</resources>
|
||||||
|
|||||||
@@ -76,4 +76,15 @@
|
|||||||
<string name="infrared_key_tv_home">TV Home</string>
|
<string name="infrared_key_tv_home">TV Home</string>
|
||||||
<string name="infrared_key_tv_back">TV Back</string>
|
<string name="infrared_key_tv_back">TV Back</string>
|
||||||
<string name="infrared_key_tv_menu">TV Menu</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>
|
</resources>
|
||||||
|
|||||||
+2
@@ -42,6 +42,7 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
private val keyRepo = FakeInfraredKeyRepository()
|
private val keyRepo = FakeInfraredKeyRepository()
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||||
|
private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore()
|
||||||
private val gateway = FakeInfraredGateway()
|
private val gateway = FakeInfraredGateway()
|
||||||
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
|
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ class InfraredRemoteViewModelTest {
|
|||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
||||||
keyRepository = keyRepo,
|
keyRepository = keyRepo,
|
||||||
deviceConfigurationRepository = deviceRepo,
|
deviceConfigurationRepository = deviceRepo,
|
||||||
|
providerProfileStore = profileStore,
|
||||||
learningCoordinator = coordinator,
|
learningCoordinator = coordinator,
|
||||||
sendInfraredKey = send,
|
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