From 4aa9116ac41261e4327658ff59c5761127a1c9a1 Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 13:25:04 +0800 Subject: [PATCH] refactor(infrared): unify infrared remote with app provider architecture and complete localization --- .../abox/manager/ui/ABoxManagerApp.kt | 16 - android/feature/infrared/build.gradle.kts | 1 - .../remote/InfraredRemoteScreenTest.kt | 1 - .../infrared/ui/InfraredControlScreenTest.kt | 121 ----- .../InfraredLearningCoordinator.kt | 21 +- .../infrared/di/InfraredFeatureModule.kt | 33 -- .../remote/InfraredRemoteViewModel.kt | 171 ++++--- .../manager/infrared/remote/RemoteModels.kt | 54 +- .../infrared/remote/ui/AcRemotePanel.kt | 8 +- .../remote/ui/InfraredRemoteScreen.kt | 26 +- .../infrared/remote/ui/TvRemotePanel.kt | 6 +- .../DataStoreInfraredRuntimeModeStore.kt | 59 --- .../infrared/runtime/InfraredRuntimeMode.kt | 15 - .../runtime/InfraredRuntimeProfileResolver.kt | 12 - .../infrared/ui/InfraredControlScreen.kt | 466 ------------------ .../infrared/ui/InfraredControlUiState.kt | 27 - .../infrared/ui/InfraredControlViewModel.kt | 342 ------------- .../src/main/res/values-zh-rCN/strings.xml | 37 ++ .../infrared/src/main/res/values/strings.xml | 37 ++ .../InfraredLearningCoordinatorTest.kt | 28 ++ .../remote/InfraredRemoteViewModelTest.kt | 79 ++- .../InfraredRuntimeProfileResolverTest.kt | 21 - .../ui/InfraredControlViewModelTest.kt | 295 ----------- 23 files changed, 296 insertions(+), 1580 deletions(-) delete mode 100644 android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt delete mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt delete mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt delete mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 257990d..abd9d09 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -22,7 +22,6 @@ 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 @@ -115,22 +114,7 @@ private fun AuthenticatedNavHost( navArgument("profileId") { type = NavType.StringType }, ), ) { - val encodedProfileId = android.net.Uri.encode(profile.id.value) InfraredRemoteRoute( - onBack = { navController.popBackStack() }, - onOpenSettings = { - navController.navigate("infrared_settings/$encodedProfileId") - }, - onSessionInvalid = onLogout, - ) - } - composable( - route = "infrared_settings/{profileId}", - arguments = listOf( - navArgument("profileId") { type = NavType.StringType }, - ), - ) { - InfraredControlRoute( onBack = { navController.popBackStack() }, onSessionInvalid = onLogout, ) diff --git a/android/feature/infrared/build.gradle.kts b/android/feature/infrared/build.gradle.kts index d9228d2..d088f6b 100644 --- a/android/feature/infrared/build.gradle.kts +++ b/android/feature/infrared/build.gradle.kts @@ -47,7 +47,6 @@ dependencies { implementation(libs.room.ktx) kapt(libs.room.compiler) - implementation(libs.datastore.preferences) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.navigation.compose) diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt index 64f9a7b..28e8c72 100644 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt +++ b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt @@ -30,7 +30,6 @@ class InfraredRemoteScreenTest { InfraredRemoteScreen( state = state, onBack = {}, - onOpenSettings = {}, onSessionInvalid = {}, onSceneSelected = {}, onToggleLearningMode = {}, diff --git a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt b/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt deleted file mode 100644 index e670e3f..0000000 --- a/android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreenTest.kt +++ /dev/null @@ -1,121 +0,0 @@ -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.test.ext.junit.runners.AndroidJUnit4 -import com.flagship.abox.manager.domain.DeviceName -import com.flagship.abox.manager.domain.ProviderProfileId -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import org.junit.Assert.assertEquals -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class InfraredControlScreenTest { - - @get:Rule - val composeTestRule = createComposeRule() - - @Test - fun displaysModeAndDeviceInformation() { - val state = InfraredControlUiState( - runtimeMode = InfraredRuntimeMode.FAKE, - effectiveProfileId = ProviderProfileId("abox:infrared-fake"), - savedDeviceName = DeviceName("ir-living"), - loading = false, - ) - - composeTestRule.setContent { - InfraredControlScreen( - state = state, - nextSlot = 1, - onBack = {}, - onSessionInvalid = {}, - onDeviceNameChanged = {}, - onSaveDeviceName = {}, - onLearn = { _, _ -> }, - onRetry = {}, - onSend = {}, - onRename = { _, _ -> }, - onDelete = {}, - onModeChanged = {}, - onMessageShown = {}, - ) - } - - composeTestRule.onNodeWithTag("infrared_mode_label").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_device_name_input").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_save_device").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_learn_new_key").assertIsDisplayed() - } - - @Test - fun rendersKeysWithCorrectActionButtons() { - val readyKey = SavedInfraredKey( - id = InfraredKeyId("k-ready"), - profileId = ProviderProfileId("abox"), - deviceName = DeviceName("ir-1"), - displayName = "Power Key", - sdkKey = "1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - val failedKey = SavedInfraredKey( - id = InfraredKeyId("k-failed"), - profileId = ProviderProfileId("abox"), - deviceName = DeviceName("ir-1"), - displayName = "Volume Up", - sdkKey = "2", - status = InfraredKeyStatus.FAILED, - lastErrorCode = "20206", - lastErrorDetail = null, - createdAtEpochMillis = 200L, - updatedAtEpochMillis = 200L, - ) - - val state = InfraredControlUiState( - runtimeMode = InfraredRuntimeMode.REAL_ABSDK, - savedDeviceName = DeviceName("ir-1"), - keys = listOf(readyKey, failedKey), - loading = false, - ) - - var sentKeyId: InfraredKeyId? = null - var retriedKeyId: InfraredKeyId? = null - - composeTestRule.setContent { - InfraredControlScreen( - state = state, - nextSlot = 3, - onBack = {}, - onSessionInvalid = {}, - onDeviceNameChanged = {}, - onSaveDeviceName = {}, - onLearn = { _, _ -> }, - onRetry = { retriedKeyId = it }, - onSend = { sentKeyId = it }, - onRename = { _, _ -> }, - onDelete = {}, - onModeChanged = {}, - onMessageShown = {}, - ) - } - - composeTestRule.onNodeWithTag("infrared_key_k-ready").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_send_k-ready").performClick() - assertEquals(InfraredKeyId("k-ready"), sentKeyId) - - composeTestRule.onNodeWithTag("infrared_key_k-failed").assertIsDisplayed() - composeTestRule.onNodeWithTag("infrared_retry_k-failed").performClick() - assertEquals(InfraredKeyId("k-failed"), retriedKeyId) - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt index 8fb6dcf..41c7e01 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinator.kt @@ -11,9 +11,12 @@ import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.async import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock class InfraredLearningCoordinator( private val learnInfraredKey: LearnInfraredKey, @@ -22,20 +25,32 @@ class InfraredLearningCoordinator( private val keyRepository: InfraredKeyRepository, private val externalScope: CoroutineScope, ) { + private val mutex = Mutex() + private var cleanupJob: Job? = null + fun learn( profileId: ProviderProfileId, deviceName: DeviceName, displayName: String, sdkKey: String? = null, ): Deferred> = externalScope.async { + mutex.withLock { + cleanupJob?.join() + } learnInfraredKey(profileId, deviceName, displayName, sdkKey) } fun retry(ref: InfraredKeyRef): Deferred> = externalScope.async { + mutex.withLock { + cleanupJob?.join() + } retryInfraredKeyLearning(ref) } fun relearn(ref: InfraredKeyRef): Deferred> = externalScope.async { + mutex.withLock { + cleanupJob?.join() + } relearnInfraredKey(ref) } @@ -43,13 +58,15 @@ class InfraredLearningCoordinator( profileId: ProviderProfileId, cutoffEpochMillis: Long = System.currentTimeMillis(), updatedAtEpochMillis: Long = cutoffEpochMillis, - ) { - externalScope.launch(NonCancellable) { + ): Job { + val job = externalScope.launch(NonCancellable) { keyRepository.failInterruptedLearning( profileId = profileId, cutoffEpochMillis = cutoffEpochMillis, updatedAtEpochMillis = updatedAtEpochMillis, ) } + cleanupJob = job + return job } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 58179f1..5237989 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -1,10 +1,6 @@ package com.flagship.abox.manager.infrared.di import android.content.Context -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.PreferenceDataStoreFactory -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.preferencesDataStoreFile import androidx.room.Room import com.flagship.abox.manager.domain.InfraredGateway import com.flagship.abox.manager.domain.infrared.EpochMillisClock @@ -23,9 +19,6 @@ import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinato import com.flagship.abox.manager.infrared.data.local.InfraredDao import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository -import com.flagship.abox.manager.infrared.runtime.DataStoreInfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -42,10 +35,6 @@ import javax.inject.Singleton @Retention(AnnotationRetention.BINARY) annotation class InfraredApplicationScope -@Qualifier -@Retention(AnnotationRetention.BINARY) -annotation class InfraredDataStore - @Module @InstallIn(SingletonComponent::class) object InfraredFeatureModule { @@ -83,28 +72,6 @@ object InfraredFeatureModule { repository: RoomInfraredKeyRepository, ): InfraredDeviceConfigurationRepository = repository - @Provides - @Singleton - @InfraredDataStore - fun provideInfraredPreferencesDataStore( - @ApplicationContext context: Context, - ): DataStore { - return PreferenceDataStoreFactory.create( - produceFile = { context.preferencesDataStoreFile("infrared_preferences") }, - ) - } - - @Provides - @Singleton - fun provideInfraredRuntimeModeStore( - @InfraredDataStore dataStore: DataStore, - ): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore) - - @Provides - @Singleton - fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver = - InfraredRuntimeProfileResolver() - @Provides fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator = InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt index 2bb1601..38deb4e 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt @@ -14,10 +14,8 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus 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.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -34,16 +32,15 @@ import kotlinx.coroutines.launch import javax.inject.Inject sealed interface RemoteUiMessage { - data class Info(val text: String) : RemoteUiMessage - data class Error(val text: String) : RemoteUiMessage + data class Info(val text: UiText) : RemoteUiMessage + data class Error(val text: UiText) : RemoteUiMessage data object SessionInvalid : RemoteUiMessage } data class InfraredRemoteUiState( val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER, val isLearningMode: Boolean = false, - val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, - val effectiveProfileId: ProviderProfileId? = null, + val profileId: ProviderProfileId? = null, val savedDeviceName: DeviceName? = null, val learnedSlotMap: Map = emptyMap(), val sendingKeySlot: String? = null, @@ -57,14 +54,12 @@ class InfraredRemoteViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val keyRepository: InfraredKeyRepository, private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, - private val runtimeModeStore: InfraredRuntimeModeStore, - private val profileResolver: InfraredRuntimeProfileResolver, private val learningCoordinator: InfraredLearningCoordinator, private val sendInfraredKey: SendInfraredKey, ) : ViewModel() { private val rawProfileId: String? = savedStateHandle["profileId"] - private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER) private val isLearningModeFlow = MutableStateFlow(false) @@ -75,71 +70,39 @@ class InfraredRemoteViewModel @Inject constructor( private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null init { - if (authenticatedProfileId != null) { - viewModelScope.launch { - val startupCutoff = System.currentTimeMillis() - runtimeModeStore.mode.collect { mode -> - val effective = profileResolver.resolve(authenticatedProfileId, mode) - learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff) - if (effective != authenticatedProfileId) { - learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff) - } - } - } + if (profileId != null) { + val startupCutoff = System.currentTimeMillis() + learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff) } } private data class InfraredRemoteConfigSnapshot( - val effectiveProfileId: ProviderProfileId? = null, - val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, - val savedDeviceName: DeviceName? = null, + val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME, val learnedSlotMap: Map = emptyMap(), val configError: DomainError? = null, ) private val configSnapshotFlow: Flow = - runtimeModeStore.mode.flatMapLatest { mode -> - val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) } - if (profile == null) { - flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode)) - } else { - deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> - when (deviceResult) { - is DomainResult.Failure -> flowOf( - InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - configError = deviceResult.error, - ), - ) - is DomainResult.Success -> { - val deviceName = deviceResult.value - if (deviceName == null) { - flowOf( - InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - savedDeviceName = null, - learnedSlotMap = emptyMap(), - ), + 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 }, ) - } else { - keyRepository.observeKeys(profile, deviceName).map { keysResult -> - when (keysResult) { - is DomainResult.Failure -> InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - savedDeviceName = deviceName, - configError = keysResult.error, - ) - is DomainResult.Success -> InfraredRemoteConfigSnapshot( - effectiveProfileId = profile, - runtimeMode = mode, - savedDeviceName = deviceName, - learnedSlotMap = keysResult.value.associateBy { it.sdkKey }, - ) - } - } } } } @@ -158,15 +121,19 @@ class InfraredRemoteViewModel @Inject constructor( val resolvedMessage = msg ?: snapshot.configError?.let { error -> when (error) { is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid - is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") - else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败") + is DomainError.DeviceOffline -> RemoteUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> RemoteUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_load_failed), + ) } } InfraredRemoteUiState( selectedScene = scene, isLearningMode = isLearning, - runtimeMode = snapshot.runtimeMode, - effectiveProfileId = snapshot.effectiveProfileId, + profileId = profileId, savedDeviceName = snapshot.savedDeviceName, learnedSlotMap = snapshot.learnedSlotMap, sendingKeySlot = sendingSlot, @@ -176,7 +143,7 @@ class InfraredRemoteViewModel @Inject constructor( }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5_000), - initialValue = InfraredRemoteUiState(), + initialValue = InfraredRemoteUiState(profileId = profileId), ) fun switchScene(scene: RemoteScene) { @@ -192,35 +159,38 @@ class InfraredRemoteViewModel @Inject constructor( activeLearningJob?.cancel() activeLearningJob = null activeLearningSlotFlow.value = null - state.value.effectiveProfileId?.let { profile -> + profileId?.let { profile -> learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff) } } else { - messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_learning_mode_enabled), + ) } } fun onButtonClick(spec: RemoteButtonSpec) { - val currentProfile = state.value.effectiveProfileId ?: return - val savedDevice = state.value.savedDeviceName - if (savedDevice == null) { - messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置") - return - } + val currentProfile = profileId ?: return + val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME if (isLearningModeFlow.value) { if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) { return } val existingKey = state.value.learnedSlotMap[spec.defaultSlot] - messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按遥控器按键...") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource( + R.string.infrared_msg_recording_key, + listOf(spec.displayName, spec.defaultSlot), + ), + ) activeLearningSlotFlow.value = spec.defaultSlot val deferred = when (existingKey?.status) { InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id)) InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id)) null -> learningCoordinator.learn( profileId = currentProfile, - deviceName = savedDevice, + deviceName = currentDevice, displayName = spec.displayName, sdkKey = spec.defaultSlot, ) @@ -240,7 +210,12 @@ class InfraredRemoteViewModel @Inject constructor( } when (result) { is DomainResult.Success -> { - messageFlow.value = RemoteUiMessage.Info("【${spec.displayName}】录制成功") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource( + R.string.infrared_msg_learned_success, + listOf(spec.displayName), + ), + ) } is DomainResult.Failure -> { handleDomainError(result.error) @@ -263,8 +238,13 @@ class InfraredRemoteViewModel @Inject constructor( return } val key = state.value.learnedSlotMap[spec.defaultSlot] - if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) { - messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制") + if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) { + messageFlow.value = RemoteUiMessage.Error( + UiText.StringResource( + R.string.infrared_msg_key_not_learned, + listOf(spec.displayName), + ), + ) return } sendingKeySlotFlow.value = spec.defaultSlot @@ -272,7 +252,12 @@ class InfraredRemoteViewModel @Inject constructor( try { when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) { is DomainResult.Success -> { - messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.displayName}") + messageFlow.value = RemoteUiMessage.Info( + UiText.StringResource( + R.string.infrared_msg_signal_sent, + listOf(spec.displayName), + ), + ) } is DomainResult.Failure -> { handleDomainError(result.error) @@ -292,7 +277,7 @@ class InfraredRemoteViewModel @Inject constructor( underlyingLearningDeferred = null activeLearningJob?.cancel() activeLearningJob = null - state.value.effectiveProfileId?.let { profile -> + profileId?.let { profile -> learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff) } } @@ -307,11 +292,23 @@ class InfraredRemoteViewModel @Inject constructor( messageFlow.value = RemoteUiMessage.SessionInvalid } is DomainError.DeviceOffline -> { - messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})") + messageFlow.value = RemoteUiMessage.Error( + UiText.StringResource( + R.string.infrared_msg_device_offline, + listOf(error.externalCode ?: "20206"), + ), + ) } else -> { - messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败") + messageFlow.value = RemoteUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) } } } + + companion object { + val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt index 041790b..bb50d7a 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt @@ -1,5 +1,16 @@ package com.flagship.abox.manager.infrared.remote +import androidx.annotation.StringRes +import com.flagship.abox.manager.infrared.R + +sealed interface UiText { + data class DynamicString(val value: String) : UiText + data class StringResource( + @StringRes val resId: Int, + val args: List = emptyList(), + ) : UiText +} + enum class RemoteScene { AIR_CONDITIONER, TELEVISION, @@ -8,38 +19,39 @@ enum class RemoteScene { data class RemoteButtonSpec( val id: String, val defaultSlot: String, + @StringRes val nameRes: Int, val displayName: String, val symbol: String, ) object RemoteSceneConfig { object AirConditioner { - val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻") - val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲") - val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼") - val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀") - val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨") - val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔") - val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱") + val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻") + val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲") + val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼") + val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀") + val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨") + val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔") + val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱") val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER) } object Television { - val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻") - val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇") - val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲") - val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼") - val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀") - val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶") - val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK") - val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +") - val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -") - val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +") - val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -") - val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠") - val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩") - val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰") + val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻") + val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇") + val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲") + val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼") + val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀") + val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶") + val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK") + val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +") + val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -") + val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +") + val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -") + val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠") + val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩") + val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰") val buttons = listOf( POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt index d691ae2..2f3f043 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt @@ -20,12 +20,14 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp 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.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig @@ -78,7 +80,7 @@ fun AcRemotePanel( isLearningMode = isLearningMode, onUpClick = { onButtonClick(tempUpSpec) }, onDownClick = { onButtonClick(tempDownSpec) }, - label = "温度调节", + label = stringResource(R.string.infrared_label_temp_control), ) } @@ -148,13 +150,13 @@ private fun AcLcdDisplay() { ) { Column { Text( - text = "❄️ 制冷模式", + text = stringResource(R.string.infrared_label_cool_mode), color = Color(0xFF38BDF8), fontSize = 14.sp, fontWeight = FontWeight.Medium, ) Text( - text = "💨 自动风速 | ↔ 扫风", + text = stringResource(R.string.infrared_label_ac_status), color = Color(0xFF94A3B8), fontSize = 12.sp, ) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt index ad7bde0..1df7594 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -50,11 +51,11 @@ import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteScene import com.flagship.abox.manager.infrared.remote.RemoteUiMessage +import com.flagship.abox.manager.infrared.remote.UiText @Composable fun InfraredRemoteRoute( onBack: () -> Unit, - onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, viewModel: InfraredRemoteViewModel = hiltViewModel(), ) { @@ -63,7 +64,6 @@ fun InfraredRemoteRoute( InfraredRemoteScreen( state = state, onBack = onBack, - onOpenSettings = onOpenSettings, onSessionInvalid = onSessionInvalid, onSceneSelected = viewModel::switchScene, onToggleLearningMode = viewModel::toggleLearningMode, @@ -77,7 +77,6 @@ fun InfraredRemoteRoute( internal fun InfraredRemoteScreen( state: InfraredRemoteUiState, onBack: () -> Unit, - onOpenSettings: () -> Unit, onSessionInvalid: () -> Unit, onSceneSelected: (RemoteScene) -> Unit, onToggleLearningMode: (Boolean) -> Unit, @@ -85,16 +84,25 @@ internal fun InfraredRemoteScreen( onMessageShown: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current LaunchedEffect(state.message) { state.message?.let { msg -> when (msg) { is RemoteUiMessage.Info -> { - snackbarHostState.showSnackbar(msg.text) + 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 RemoteUiMessage.Error -> { - snackbarHostState.showSnackbar(msg.text) + 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 RemoteUiMessage.SessionInvalid -> { @@ -145,14 +153,6 @@ internal fun InfraredRemoteScreen( ), ) } - - // 设置齿轮按钮 - IconButton( - onClick = onOpenSettings, - modifier = Modifier.testTag("remote_settings_button"), - ) { - Text("⚙️", fontSize = 20.sp) - } }, ) }, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt index 2c80c9d..fca60ea 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt @@ -10,9 +10,11 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp 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.RemoteButtonSpec import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig @@ -118,7 +120,7 @@ fun TvRemotePanel( isLearningMode = isLearningMode, onUpClick = { onButtonClick(volUp) }, onDownClick = { onButtonClick(volDown) }, - label = "音量 VOL", + label = stringResource(R.string.infrared_label_vol), ) // 频道控制 @@ -134,7 +136,7 @@ fun TvRemotePanel( isLearningMode = isLearningMode, onUpClick = { onButtonClick(chUp) }, onDownClick = { onButtonClick(chDown) }, - label = "频道 CH", + label = stringResource(R.string.infrared_label_ch), ) } } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt deleted file mode 100644 index eb2e141..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/DataStoreInfraredRuntimeModeStore.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringPreferencesKey -import com.flagship.abox.manager.domain.DomainError -import com.flagship.abox.manager.domain.DomainResult -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import java.io.IOException - -class DataStoreInfraredRuntimeModeStore( - private val dataStore: DataStore, -) : InfraredRuntimeModeStore { - - companion object { - private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode") - } - - override val mode: Flow = dataStore.data - .catch { exception -> - if (exception is IOException) { - emit(androidx.datastore.preferences.core.emptyPreferences()) - } else { - throw exception - } - } - .map { preferences -> - val storedName = preferences[KEY_RUNTIME_MODE] - if (storedName != null) { - try { - InfraredRuntimeMode.valueOf(storedName) - } catch (_: IllegalArgumentException) { - InfraredRuntimeMode.FAKE - } - } else { - InfraredRuntimeMode.FAKE - } - } - - override suspend fun currentMode(): InfraredRuntimeMode { - return mode.first() - } - - override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { - return try { - dataStore.edit { preferences -> - preferences[KEY_RUNTIME_MODE] = mode.name - } - DomainResult.Success(Unit) - } catch (throwable: Throwable) { - if (throwable is kotlinx.coroutines.CancellationException) throw throwable - DomainResult.Failure(DomainError.Unknown(detail = "Failed to update runtime mode")) - } - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt deleted file mode 100644 index 15f540d..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeMode.kt +++ /dev/null @@ -1,15 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import com.flagship.abox.manager.domain.DomainResult -import kotlinx.coroutines.flow.Flow - -enum class InfraredRuntimeMode { - FAKE, - REAL_ABSDK, -} - -interface InfraredRuntimeModeStore { - val mode: Flow - suspend fun currentMode(): InfraredRuntimeMode - suspend fun setMode(mode: InfraredRuntimeMode): DomainResult -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt deleted file mode 100644 index df0f84d..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolver.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import com.flagship.abox.manager.domain.ProviderProfileId - -class InfraredRuntimeProfileResolver { - fun resolve(realProfileId: ProviderProfileId, mode: InfraredRuntimeMode): ProviderProfileId { - return when (mode) { - InfraredRuntimeMode.REAL_ABSDK -> realProfileId - InfraredRuntimeMode.FAKE -> ProviderProfileId("${realProfileId.value}:infrared-fake") - } - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt deleted file mode 100644 index 6f575ce..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ /dev/null @@ -1,466 +0,0 @@ -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.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flagship.abox.manager.designsystem.ABoxButton -import com.flagship.abox.manager.designsystem.ABoxSurface -import com.flagship.abox.manager.domain.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.infrared.R -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode - -@Composable -fun InfraredControlRoute( - onBack: () -> Unit, - onSessionInvalid: () -> Unit, - viewModel: InfraredControlViewModel = hiltViewModel(), -) { - val state by viewModel.state.collectAsStateWithLifecycle() - - InfraredControlScreen( - state = state, - nextSlot = viewModel.nextAvailableKeySlot(), - onBack = onBack, - onSessionInvalid = onSessionInvalid, - onDeviceNameChanged = viewModel::onDeviceNameChanged, - onSaveDeviceName = viewModel::saveDeviceName, - onLearn = viewModel::learnKey, - onRetry = viewModel::retryLearning, - onSend = viewModel::sendKey, - onRename = viewModel::renameKey, - onDelete = viewModel::deleteKey, - onModeChanged = viewModel::setRuntimeMode, - onMessageShown = viewModel::clearMessage, - ) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun InfraredControlScreen( - state: InfraredControlUiState, - nextSlot: Int, - onBack: () -> Unit, - onSessionInvalid: () -> Unit, - onDeviceNameChanged: (String) -> Unit, - onSaveDeviceName: () -> Unit, - onLearn: (String, String?) -> Unit, - onRetry: (InfraredKeyId) -> Unit, - onSend: (InfraredKeyId) -> Unit, - onRename: (InfraredKeyId, String) -> Unit, - onDelete: (InfraredKeyId) -> Unit, - onModeChanged: (InfraredRuntimeMode) -> Unit, - onMessageShown: () -> Unit, -) { - val snackbarHostState = remember { SnackbarHostState() } - - var showLearnDialog by remember { mutableStateOf(false) } - var keyToRename by remember { mutableStateOf(null) } - var keyToDelete by remember { mutableStateOf(null) } - - LaunchedEffect(state.message) { - state.message?.let { msg -> - when (msg) { - is InfraredUiMessage.Info -> { - snackbarHostState.showSnackbar(msg.text) - onMessageShown() - } - is InfraredUiMessage.Error -> { - snackbarHostState.showSnackbar(msg.text) - onMessageShown() - } - is InfraredUiMessage.SessionInvalid -> { - onSessionInvalid() - onMessageShown() - } - } - } - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.infrared_title)) }, - navigationIcon = { - TextButton(onClick = onBack) { - Text(stringResource(R.string.infrared_back)) - } - }, - ) - }, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { innerPadding -> - ABoxSurface( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - ) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Mode indicator and toggle - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { - stringResource(R.string.infrared_mode_fake) - } else { - stringResource(R.string.infrared_mode_real) - } - Text( - text = modeText, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.testTag("infrared_mode_label"), - ) - OutlinedButton( - onClick = { - val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) { - InfraredRuntimeMode.REAL_ABSDK - } else { - InfraredRuntimeMode.FAKE - } - onModeChanged(target) - }, - enabled = !state.hasActiveLearning, - ) { - Text(stringResource(R.string.infrared_switch_mode)) - } - } - - // Device name configuration card - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val saved = state.savedDeviceName - Text( - text = if (saved != null) { - stringResource(R.string.infrared_saved_device, saved.value) - } else { - stringResource(R.string.infrared_no_device_saved) - }, - style = MaterialTheme.typography.titleMedium, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedTextField( - value = state.deviceNameInput, - onValueChange = onDeviceNameChanged, - label = { Text(stringResource(R.string.infrared_device_name_label)) }, - placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) }, - singleLine = true, - modifier = Modifier - .weight(1f) - .testTag("infrared_device_name_input"), - ) - ABoxButton( - onClick = onSaveDeviceName, - modifier = Modifier.testTag("infrared_save_device"), - ) { - Text(stringResource(R.string.infrared_save_device)) - } - } - } - } - - // Learned Keys Section - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(R.string.infrared_keys_title), - style = MaterialTheme.typography.titleMedium, - ) - ABoxButton( - onClick = { showLearnDialog = true }, - enabled = state.savedDeviceName != null, - modifier = Modifier.testTag("infrared_learn_new_key"), - ) { - Text(stringResource(R.string.infrared_learn_new_key)) - } - } - - if (state.keys.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(R.string.infrared_no_keys), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(state.keys, key = { it.id.value }) { key -> - InfraredKeyRow( - key = key, - isSending = state.sendingKeyIds.contains(key.id), - onSend = { onSend(key.id) }, - onRetry = { onRetry(key.id) }, - onRename = { keyToRename = key }, - onDelete = { keyToDelete = key }, - ) - } - } - } - } - } - } - - if (showLearnDialog) { - var keyName by remember { mutableStateOf("") } - 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(), - ) - OutlinedTextField( - value = keySlot, - onValueChange = { keySlot = it }, - label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - Button( - onClick = { - onLearn(keyName, keySlot) - showLearnDialog = false - }, - enabled = keyName.isNotBlank() && keySlot.isNotBlank(), - ) { - Text(stringResource(R.string.infrared_learn_dialog_start)) - } - }, - dismissButton = { - TextButton(onClick = { showLearnDialog = false }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } - - keyToRename?.let { targetKey -> - var newName by remember { mutableStateOf(targetKey.displayName) } - AlertDialog( - onDismissRequest = { keyToRename = null }, - title = { Text(stringResource(R.string.infrared_rename_dialog_title)) }, - text = { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - ) - }, - confirmButton = { - Button( - onClick = { - onRename(targetKey.id, newName) - keyToRename = null - }, - enabled = newName.isNotBlank(), - ) { - Text(stringResource(R.string.infrared_dialog_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { keyToRename = null }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } - - keyToDelete?.let { targetKey -> - AlertDialog( - onDismissRequest = { keyToDelete = null }, - title = { Text(stringResource(R.string.infrared_delete_dialog_title)) }, - text = { Text(stringResource(R.string.infrared_delete_dialog_message)) }, - confirmButton = { - Button( - onClick = { - onDelete(targetKey.id) - keyToDelete = null - }, - ) { - Text(stringResource(R.string.infrared_dialog_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { keyToDelete = null }) { - Text(stringResource(R.string.infrared_dialog_cancel)) - } - }, - ) - } -} - -@Composable -private fun InfraredKeyRow( - key: SavedInfraredKey, - isSending: Boolean, - onSend: () -> Unit, - onRetry: () -> Unit, - onRename: () -> Unit, - onDelete: () -> Unit, -) { - Card( - modifier = Modifier - .fillMaxWidth() - .testTag("infrared_key_${key.id.value}"), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column { - Text( - text = key.displayName, - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = 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) { - Text(stringResource(R.string.infrared_rename)) - } - Spacer(modifier = Modifier.width(8.dp)) - OutlinedButton(onClick = onDelete) { - Text(stringResource(R.string.infrared_delete)) - } - } else { - CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp)) - } - } - } - } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt deleted file mode 100644 index 14e8c90..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlUiState.kt +++ /dev/null @@ -1,27 +0,0 @@ -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.infrared.InfraredKeyId -import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus -import com.flagship.abox.manager.domain.infrared.SavedInfraredKey -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode - -sealed interface InfraredUiMessage { - data class Info(val text: String) : InfraredUiMessage - data class Error(val text: String) : InfraredUiMessage - data object SessionInvalid : InfraredUiMessage -} - -data class InfraredControlUiState( - val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE, - val effectiveProfileId: ProviderProfileId? = null, - val deviceNameInput: String = "", - val savedDeviceName: DeviceName? = null, - val keys: List = emptyList(), - val loading: Boolean = true, - val sendingKeyIds: Set = emptySet(), - val message: InfraredUiMessage? = null, -) { - val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING } -} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt deleted file mode 100644 index d1ee47f..0000000 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ /dev/null @@ -1,342 +0,0 @@ -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.infrared.DeleteInfraredKey -import com.flagship.abox.manager.domain.infrared.EpochMillisClock -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.application.InfraredLearningCoordinator -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver -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.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 runtimeModeStore: InfraredRuntimeModeStore, - private val profileResolver: InfraredRuntimeProfileResolver, - private val learningCoordinator: InfraredLearningCoordinator, - private val sendInfraredKey: SendInfraredKey, - private val renameInfraredKey: RenameInfraredKey, - private val deleteInfraredKey: DeleteInfraredKey, - private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning, - private val clock: EpochMillisClock, -) : ViewModel() { - - private val rawProfileId: String? = savedStateHandle["profileId"] - private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } - - private val deviceNameInputFlow = MutableStateFlow("") - private val sendingKeyIdsFlow = MutableStateFlow>(emptySet()) - private val messageFlow = MutableStateFlow(null) - - private val effectiveProfileAndMode: Flow> = - runtimeModeStore.mode.mapModeToProfile() - - private fun Flow.mapModeToProfile(): Flow> { - return flatMapLatest { mode -> - if (authenticatedProfileId == null) { - flowOf(Pair(null, mode)) - } else { - val effectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) - flowOf(Pair(effectiveProfile, mode)) - } - } - } - - private val deviceNameFlow: Flow> = - effectiveProfileAndMode.flatMapLatest { (profile, _) -> - if (profile == null) { - flowOf(DomainResult.Success(null)) - } else { - deviceConfigurationRepository.observeDeviceName(profile) - } - } - - private val keysFlow: Flow>> = - effectiveProfileAndMode.flatMapLatest { (profile, _) -> - if (profile == null) { - flowOf(DomainResult.Success(emptyList())) - } else { - deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult -> - when (deviceResult) { - is DomainResult.Success -> { - val device = deviceResult.value - if (device == null) { - flowOf(DomainResult.Success(emptyList())) - } else { - keyRepository.observeKeys(profile, device) - } - } - is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error)) - } - } - } - } - - private data class ServerState( - val profile: ProviderProfileId?, - val mode: InfraredRuntimeMode, - val deviceResult: DomainResult, - val keysResult: DomainResult>, - ) - - private val serverStateFlow: Flow = combine( - effectiveProfileAndMode, - deviceNameFlow, - keysFlow, - ) { profileAndMode, deviceResult, keysResult -> - ServerState( - profile = profileAndMode.first, - mode = profileAndMode.second, - deviceResult = deviceResult, - keysResult = keysResult, - ) - } - - val state: StateFlow = combine( - serverStateFlow, - deviceNameInputFlow, - sendingKeyIdsFlow, - messageFlow, - ) { serverState, input, sendingIds, msg -> - val savedDevice = (serverState.deviceResult as? DomainResult.Success)?.value - val keys = (serverState.keysResult as? DomainResult.Success>)?.value ?: emptyList() - val loading = serverState.profile == null || - serverState.deviceResult !is DomainResult.Success || - serverState.keysResult !is DomainResult.Success - - InfraredControlUiState( - runtimeMode = serverState.mode, - effectiveProfileId = serverState.profile, - deviceNameInput = input, - savedDeviceName = savedDevice, - keys = keys, - loading = loading, - sendingKeyIds = sendingIds, - message = if (authenticatedProfileId == null) { - InfraredUiMessage.Error("Invalid or missing profile ID") - } else { - msg - }, - ) - }.stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5_000), - initialValue = InfraredControlUiState( - runtimeMode = InfraredRuntimeMode.FAKE, - effectiveProfileId = null, - loading = true, - ), - ) - - init { - if (authenticatedProfileId != null) { - viewModelScope.launch { - val currentMode = runtimeModeStore.currentMode() - val effectiveProfile = profileResolver.resolve(authenticatedProfileId, currentMode) - recoverInterruptedInfraredLearning(effectiveProfile) - } - } - } - - fun onDeviceNameChanged(value: String) { - deviceNameInputFlow.value = value - } - - fun saveDeviceName() { - val currentProfile = state.value.effectiveProfileId ?: return - val trimmed = deviceNameInputFlow.value.trim() - if (trimmed.isBlank()) { - messageFlow.value = InfraredUiMessage.Error("Device name cannot be blank") - return - } - viewModelScope.launch { - when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("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 = state.value.effectiveProfileId ?: return - val savedDevice = state.value.savedDeviceName - if (savedDevice == null) { - messageFlow.value = InfraredUiMessage.Error("Please set and save a device name first") - return - } - val trimmedName = displayName.trim() - if (trimmedName.isBlank()) { - messageFlow.value = InfraredUiMessage.Error("Key name cannot be 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("Key slot must be an integer between 1 and 100") - return - } - slotInt.toString() - } else { - nextAvailableKeySlot().toString() - } - learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) - } - - fun retryLearning(keyId: InfraredKeyId) { - val currentProfile = state.value.effectiveProfileId ?: return - learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) - } - - fun sendKey(keyId: InfraredKeyId) { - val currentProfile = state.value.effectiveProfileId ?: 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("Key is not ready to send") - return - } - - sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId - viewModelScope.launch { - try { - when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("Signal sent successfully") - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } finally { - sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId - } - } - } - - fun renameKey(keyId: InfraredKeyId, displayName: String) { - val currentProfile = state.value.effectiveProfileId ?: return - val trimmed = displayName.trim() - if (trimmed.isBlank()) { - messageFlow.value = InfraredUiMessage.Error("Name cannot be blank") - return - } - viewModelScope.launch { - when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("Key renamed") - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun deleteKey(keyId: InfraredKeyId) { - val currentProfile = state.value.effectiveProfileId ?: return - viewModelScope.launch { - when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) { - is DomainResult.Success -> { - messageFlow.value = InfraredUiMessage.Info("Key removed locally") - } - is DomainResult.Failure -> { - handleDomainError(result.error) - } - } - } - } - - fun setRuntimeMode(mode: InfraredRuntimeMode) { - if (state.value.hasActiveLearning) { - messageFlow.value = InfraredUiMessage.Error("Cannot change mode while learning is active") - return - } - viewModelScope.launch { - when (val result = runtimeModeStore.setMode(mode)) { - is DomainResult.Success -> { - if (authenticatedProfileId != null) { - val newEffectiveProfile = profileResolver.resolve(authenticatedProfileId, mode) - recoverInterruptedInfraredLearning(newEffectiveProfile) - } - } - 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("Infrared controller is offline (code: ${error.externalCode ?: "20206"})") - } - is DomainError.DeviceNotFound -> { - messageFlow.value = InfraredUiMessage.Error("Device not found (code: ${error.externalCode ?: "20203"})") - } - is DomainError.InvalidRequest -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Invalid request") - } - is DomainError.OperationFailed -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Operation failed (code: ${error.externalCode ?: "unknown"})") - } - is DomainError.Network -> { - messageFlow.value = InfraredUiMessage.Error("Network error: ${error.detail ?: "connection failed"}") - } - is DomainError.NoData -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "No data found") - } - is DomainError.Unknown -> { - messageFlow.value = InfraredUiMessage.Error(error.detail ?: "Unknown error") - } - } - } -} diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index f1cf838..ddf40ac 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -39,4 +39,41 @@ ❄️ 空调遥控 📺 电视遥控 设置 + + 🔴 学习录制模式已开启:点击任意按键即可开始录制 + 正在录制【%1$s】(Slot %2$s),请按遥控器按键... + 【%1$s】录制成功 + 按键【%1$s】尚未学习,请开启右上角学习模式进行录制 + 已发射: %1$s + 红外控制器离线 (错误码: %1$s) + 数据加载失败 + 操作失败 + 温度调节 + 音量 VOL + 频道 CH + ❄️ 制冷模式 + 💨 自动风速 | ↔ 扫风 + + 空调-电源 + 空调-温度+ + 空调-温度- + 空调-模式 + 空调-风速 + 空调-扫风 + 空调-定时 + + 电视-电源 + 电视-静音 + 电视-上 + 电视-下 + 电视-左 + 电视-右 + 电视-确定 + 电视-音量+ + 电视-音量- + 电视-频道+ + 电视-频道- + 电视-主页 + 电视-返回 + 电视-菜单 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index e676a1f..a91667e 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -39,4 +39,41 @@ ❄️ Air Conditioner 📺 Television Settings + + 🔴 Learn mode active: tap any button to record + Recording [%1$s] (Slot %2$s), press remote button… + [%1$s] recorded successfully + Button [%1$s] is not learned. Enable learn mode to record. + Sent: %1$s + Infrared controller offline (code: %1$s) + Failed to load data + Operation failed + Temp Control + VOL + CH + ❄️ Cool Mode + 💨 Auto Fan | ↔ Swing + + AC Power + AC Temp + + AC Temp - + AC Mode + AC Fan Speed + AC Swing + AC Timer + + TV Power + TV Mute + TV Up + TV Down + TV Left + TV Right + TV OK + TV Vol + + TV Vol - + TV CH + + TV CH - + TV Home + TV Back + TV Menu diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt index f9123c9..3d4bee1 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/application/InfraredLearningCoordinatorTest.kt @@ -125,4 +125,32 @@ class InfraredLearningCoordinatorTest { val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.FAILED, updated.status) } + + @Test + fun subsequentLearnWaitsForCleanupCompletion() = runTest { + val learningKey = SavedInfraredKey( + id = InfraredKeyId("key1"), + profileId = profile, + deviceName = device, + displayName = "Power", + sdkKey = "1", + status = InfraredKeyStatus.READY, + lastErrorCode = null, + lastErrorDetail = null, + createdAtEpochMillis = 500L, + updatedAtEpochMillis = 500L, + ) + repository.insert(learningKey) + + val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this) + + coordinator.failInterruptedLearning(profile, cutoffEpochMillis = 1000L) + val relearnJob = coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1"))) + advanceUntilIdle() + + val relearnResult = relearnJob.await() + assertTrue(relearnResult is DomainResult.Success) + val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!! + assertEquals(InfraredKeyStatus.READY, updated.status) + } } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt index 0700476..60bd17b 100644 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt @@ -12,17 +12,13 @@ import com.flagship.abox.manager.domain.infrared.LearnInfraredKey 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.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver 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 kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -35,30 +31,17 @@ import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Test -private class TestModeStore : InfraredRuntimeModeStore { - private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) - override val mode: Flow = modeFlow - override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value - override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { - modeFlow.value = mode - return DomainResult.Success(Unit) - } -} - @OptIn(ExperimentalCoroutinesApi::class) class InfraredRemoteViewModelTest { private val testDispatcher = StandardTestDispatcher() private val testScope = TestScope(testDispatcher) private val profile = ProviderProfileId("abox") - private val fakeProfile = ProviderProfileId("abox:infrared-fake") private val device = DeviceName("ir-living") private val clock = EpochMillisClock { 10_000L } private val keyRepo = FakeInfraredKeyRepository() private val deviceRepo = FakeInfraredDeviceConfigurationRepository() - private val modeStore = TestModeStore() - private val resolver = InfraredRuntimeProfileResolver() private val gateway = FakeInfraredGateway() private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) @@ -84,8 +67,6 @@ class InfraredRemoteViewModelTest { savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")), keyRepository = keyRepo, deviceConfigurationRepository = deviceRepo, - runtimeModeStore = modeStore, - profileResolver = resolver, learningCoordinator = coordinator, sendInfraredKey = send, ) @@ -93,10 +74,10 @@ class InfraredRemoteViewModelTest { @Test fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val readyKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -123,7 +104,7 @@ class InfraredRemoteViewModelTest { @Test fun learningModeTriggersLearnOnButtonClick() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val vm = createViewModel() val job = launch { vm.state.collect {} } advanceUntilIdle() @@ -141,7 +122,7 @@ class InfraredRemoteViewModelTest { @Test fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val vm = createViewModel() val job = launch { vm.state.collect {} } advanceUntilIdle() @@ -157,10 +138,10 @@ class InfraredRemoteViewModelTest { @Test fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val readyKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -187,10 +168,10 @@ class InfraredRemoteViewModelTest { @Test fun learningExistingKeyRetriesExistingKey() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val failedKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -212,16 +193,19 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) - val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) - assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))), + vm.state.value.message, + ) job.cancel() } @Test fun learningFailurePropagatesErrorMessage() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206")) val vm = createViewModel() @@ -232,14 +216,17 @@ class InfraredRemoteViewModelTest { vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER) advanceUntilIdle() - assertEquals(RemoteUiMessage.Error("红外控制器离线 (错误码: 20206)"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_offline, listOf("20206"))), + vm.state.value.message, + ) job.cancel() } @Test fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired")) val vm = createViewModel() @@ -257,7 +244,7 @@ class InfraredRemoteViewModelTest { @Test fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val vm = createViewModel() val job = launch { vm.state.collect {} } advanceUntilIdle() @@ -274,10 +261,10 @@ class InfraredRemoteViewModelTest { @Test fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val readyKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -299,19 +286,22 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) - val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) - assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))), + vm.state.value.message, + ) job.cancel() } @Test fun relearningKeyInLearningStatusSucceeds() = testScope.runTest { - deviceRepo.setDeviceName(fakeProfile, device) + deviceRepo.setDeviceName(profile, device) val learningKey = SavedInfraredKey( id = InfraredKeyId("k1"), - profileId = fakeProfile, + profileId = profile, deviceName = device, displayName = "空调-电源", sdkKey = "1", @@ -333,9 +323,12 @@ class InfraredRemoteViewModelTest { assertEquals(1, gateway.learnedKeys.size) assertEquals("1", gateway.learnedKeys.first().key) - val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(fakeProfile, InfraredKeyId("k1"))) as DomainResult.Success).value!! + val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!! assertEquals(InfraredKeyStatus.READY, updated.status) - assertEquals(RemoteUiMessage.Info("【空调-电源】录制成功"), vm.state.value.message) + assertEquals( + RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))), + vm.state.value.message, + ) job.cancel() } @@ -348,7 +341,7 @@ class InfraredRemoteViewModelTest { val job = launch { vm.state.collect {} } advanceUntilIdle() - assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message) + assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message) job.cancel() } diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt deleted file mode 100644 index 6249db9..0000000 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/runtime/InfraredRuntimeProfileResolverTest.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.flagship.abox.manager.infrared.runtime - -import com.flagship.abox.manager.domain.ProviderProfileId -import org.junit.Assert.assertEquals -import org.junit.Test - -class InfraredRuntimeProfileResolverTest { - - @Test - fun realModeUsesAuthenticatedProfileAndFakeModeUsesIsolatedProfile() { - val resolver = InfraredRuntimeProfileResolver() - assertEquals( - ProviderProfileId("abox"), - resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.REAL_ABSDK), - ) - assertEquals( - ProviderProfileId("abox:infrared-fake"), - resolver.resolve(ProviderProfileId("abox"), InfraredRuntimeMode.FAKE), - ) - } -} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt deleted file mode 100644 index 822a811..0000000 --- a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModelTest.kt +++ /dev/null @@ -1,295 +0,0 @@ -package com.flagship.abox.manager.infrared.ui - -import androidx.lifecycle.SavedStateHandle -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.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.application.InfraredLearningCoordinator -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore -import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver -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 kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -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.assertTrue -import org.junit.Before -import org.junit.Test - -class FakeRuntimeModeStore : InfraredRuntimeModeStore { - private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE) - override val mode: Flow = modeFlow - override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value - override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult { - modeFlow.value = mode - return DomainResult.Success(Unit) - } -} - -@OptIn(ExperimentalCoroutinesApi::class) -class InfraredControlViewModelTest { - - private val testDispatcher = StandardTestDispatcher() - private val testScope = TestScope(testDispatcher) - - private val baseProfile = ProviderProfileId("abox") - private val fakeProfile = ProviderProfileId("abox:infrared-fake") - private val device = DeviceName("ir-living") - private val clock = EpochMillisClock { 10_000L } - - private val keyRepository = FakeInfraredKeyRepository() - private val deviceConfigRepository = FakeInfraredDeviceConfigurationRepository() - private val runtimeModeStore = FakeRuntimeModeStore() - private val profileResolver = InfraredRuntimeProfileResolver() - private val gateway = FakeInfraredGateway() - private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock) - - private val learnUseCase = LearnInfraredKey(keyRepository, gateway, factory, clock) - private val retryUseCase = RetryInfraredKeyLearning(keyRepository, gateway, clock) - private val relearnUseCase = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepository, gateway, clock) - private val sendUseCase = SendInfraredKey(keyRepository, gateway) - private val renameUseCase = RenameInfraredKey(keyRepository, clock) - private val deleteUseCase = DeleteInfraredKey(keyRepository) - private val recoverUseCase = RecoverInterruptedInfraredLearning(keyRepository, clock) - - private lateinit var coordinator: InfraredLearningCoordinator - - @Before - fun setup() { - Dispatchers.setMain(testDispatcher) - coordinator = InfraredLearningCoordinator(learnUseCase, retryUseCase, relearnUseCase, keyRepository, testScope) - } - - @After - fun teardown() { - Dispatchers.resetMain() - } - - private fun createViewModel(profileId: String? = "abox"): InfraredControlViewModel { - val handle = SavedStateHandle(if (profileId != null) mapOf("profileId" to profileId) else emptyMap()) - return InfraredControlViewModel( - savedStateHandle = handle, - keyRepository = keyRepository, - deviceConfigurationRepository = deviceConfigRepository, - runtimeModeStore = runtimeModeStore, - profileResolver = profileResolver, - learningCoordinator = coordinator, - sendInfraredKey = sendUseCase, - renameInfraredKey = renameUseCase, - deleteInfraredKey = deleteUseCase, - recoverInterruptedInfraredLearning = recoverUseCase, - clock = clock, - ) - } - - @Test - fun resolvesEffectiveProfileByRuntimeMode() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - assertEquals(fakeProfile, vm.state.value.effectiveProfileId) - assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) - - vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) - advanceUntilIdle() - - assertEquals(baseProfile, vm.state.value.effectiveProfileId) - assertEquals(InfraredRuntimeMode.REAL_ABSDK, vm.state.value.runtimeMode) - - job.cancel() - } - - @Test - fun blankDeviceNameReturnsValidationError() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.onDeviceNameChanged(" ") - vm.saveDeviceName() - advanceUntilIdle() - - val msg = vm.state.value.message - assertTrue(msg is InfraredUiMessage.Error) - assertEquals("Device name cannot be blank", (msg as InfraredUiMessage.Error).text) - - job.cancel() - } - - @Test - fun saveDeviceNamePersistsAndTrims() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.onDeviceNameChanged(" ir-main ") - vm.saveDeviceName() - advanceUntilIdle() - - assertEquals(DeviceName("ir-main"), vm.state.value.savedDeviceName) - assertEquals("", vm.state.value.deviceNameInput) - assertTrue(vm.state.value.message is InfraredUiMessage.Info) - - job.cancel() - } - - @Test - fun learnWithoutSavedDeviceFails() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.learnKey("Power") - advanceUntilIdle() - - val msg = vm.state.value.message - assertTrue(msg is InfraredUiMessage.Error) - assertEquals("Please set and save a device name first", (msg as InfraredUiMessage.Error).text) - - job.cancel() - } - - @Test - fun learnWithSavedDeviceSucceeds() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - vm.onDeviceNameChanged("ir-1") - vm.saveDeviceName() - advanceUntilIdle() - - vm.learnKey("Air Conditioner") - advanceUntilIdle() - - val keys = vm.state.value.keys - assertEquals(1, keys.size) - assertEquals("Air Conditioner", keys.first().displayName) - assertEquals(InfraredKeyStatus.READY, keys.first().status) - - job.cancel() - } - - @Test - fun sendReadyKeySendsSignalAndClearsSendingSet() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - deviceConfigRepository.setDeviceName(fakeProfile, device) - val key = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = fakeProfile, - deviceName = device, - displayName = "Power", - sdkKey = "app_ir_k1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepository.insert(key) - advanceUntilIdle() - - vm.sendKey(InfraredKeyId("k1")) - advanceUntilIdle() - - assertTrue(vm.state.value.sendingKeyIds.isEmpty()) - assertTrue(vm.state.value.message is InfraredUiMessage.Info) - assertEquals(1, gateway.sentKeys.size) - - job.cancel() - } - - @Test - fun sendFailurePreservesReadyStatus() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - deviceConfigRepository.setDeviceName(fakeProfile, device) - val key = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = fakeProfile, - deviceName = device, - displayName = "Power", - sdkKey = "app_ir_k1", - status = InfraredKeyStatus.READY, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepository.insert(key) - gateway.sendResult = DomainResult.Failure(DomainError.DeviceOffline("20206")) - advanceUntilIdle() - - vm.sendKey(InfraredKeyId("k1")) - advanceUntilIdle() - - val fetched = vm.state.value.keys.single() - assertEquals(InfraredKeyStatus.READY, fetched.status) - assertTrue(vm.state.value.message is InfraredUiMessage.Error) - - job.cancel() - } - - @Test - fun setModeBlockedWhileLearningIsActive() = testScope.runTest { - val vm = createViewModel() - val job = launch { vm.state.collect {} } - advanceUntilIdle() - - deviceConfigRepository.setDeviceName(fakeProfile, device) - val key = SavedInfraredKey( - id = InfraredKeyId("k1"), - profileId = fakeProfile, - deviceName = device, - displayName = "Power", - sdkKey = "app_ir_k1", - status = InfraredKeyStatus.LEARNING, - lastErrorCode = null, - lastErrorDetail = null, - createdAtEpochMillis = 100L, - updatedAtEpochMillis = 100L, - ) - keyRepository.insert(key) - advanceUntilIdle() - - assertTrue(vm.state.value.hasActiveLearning) - vm.setRuntimeMode(InfraredRuntimeMode.REAL_ABSDK) - advanceUntilIdle() - - assertEquals(InfraredRuntimeMode.FAKE, vm.state.value.runtimeMode) - assertTrue(vm.state.value.message is InfraredUiMessage.Error) - - job.cancel() - } -}