refactor(infrared): unify infrared remote with app provider architecture and complete localization
This commit is contained in:
@@ -22,7 +22,6 @@ import com.flagship.abox.manager.designsystem.ABoxTheme
|
|||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.Username
|
import com.flagship.abox.manager.domain.Username
|
||||||
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
|
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
|
||||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
|
||||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
@@ -115,22 +114,7 @@ private fun AuthenticatedNavHost(
|
|||||||
navArgument("profileId") { type = NavType.StringType },
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
|
||||||
InfraredRemoteRoute(
|
InfraredRemoteRoute(
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onOpenSettings = {
|
|
||||||
navController.navigate("infrared_settings/$encodedProfileId")
|
|
||||||
},
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = "infrared_settings/{profileId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
InfraredControlRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onSessionInvalid = onLogout,
|
onSessionInvalid = onLogout,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ dependencies {
|
|||||||
implementation(libs.room.ktx)
|
implementation(libs.room.ktx)
|
||||||
kapt(libs.room.compiler)
|
kapt(libs.room.compiler)
|
||||||
|
|
||||||
implementation(libs.datastore.preferences)
|
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.navigation.compose)
|
implementation(libs.androidx.navigation.compose)
|
||||||
|
|||||||
-1
@@ -30,7 +30,6 @@ class InfraredRemoteScreenTest {
|
|||||||
InfraredRemoteScreen(
|
InfraredRemoteScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onOpenSettings = {},
|
|
||||||
onSessionInvalid = {},
|
onSessionInvalid = {},
|
||||||
onSceneSelected = {},
|
onSceneSelected = {},
|
||||||
onToggleLearningMode = {},
|
onToggleLearningMode = {},
|
||||||
|
|||||||
-121
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+19
-2
@@ -11,9 +11,12 @@ import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
|
|||||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.NonCancellable
|
import kotlinx.coroutines.NonCancellable
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
|
||||||
class InfraredLearningCoordinator(
|
class InfraredLearningCoordinator(
|
||||||
private val learnInfraredKey: LearnInfraredKey,
|
private val learnInfraredKey: LearnInfraredKey,
|
||||||
@@ -22,20 +25,32 @@ class InfraredLearningCoordinator(
|
|||||||
private val keyRepository: InfraredKeyRepository,
|
private val keyRepository: InfraredKeyRepository,
|
||||||
private val externalScope: CoroutineScope,
|
private val externalScope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
|
private val mutex = Mutex()
|
||||||
|
private var cleanupJob: Job? = null
|
||||||
|
|
||||||
fun learn(
|
fun learn(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
sdkKey: String? = null,
|
sdkKey: String? = null,
|
||||||
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
|
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
|
||||||
|
mutex.withLock {
|
||||||
|
cleanupJob?.join()
|
||||||
|
}
|
||||||
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
|
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
||||||
|
mutex.withLock {
|
||||||
|
cleanupJob?.join()
|
||||||
|
}
|
||||||
retryInfraredKeyLearning(ref)
|
retryInfraredKeyLearning(ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
||||||
|
mutex.withLock {
|
||||||
|
cleanupJob?.join()
|
||||||
|
}
|
||||||
relearnInfraredKey(ref)
|
relearnInfraredKey(ref)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,13 +58,15 @@ class InfraredLearningCoordinator(
|
|||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
cutoffEpochMillis: Long = System.currentTimeMillis(),
|
cutoffEpochMillis: Long = System.currentTimeMillis(),
|
||||||
updatedAtEpochMillis: Long = cutoffEpochMillis,
|
updatedAtEpochMillis: Long = cutoffEpochMillis,
|
||||||
) {
|
): Job {
|
||||||
externalScope.launch(NonCancellable) {
|
val job = externalScope.launch(NonCancellable) {
|
||||||
keyRepository.failInterruptedLearning(
|
keyRepository.failInterruptedLearning(
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
cutoffEpochMillis = cutoffEpochMillis,
|
cutoffEpochMillis = cutoffEpochMillis,
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
cleanupJob = job
|
||||||
|
return job
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-33
@@ -1,10 +1,6 @@
|
|||||||
package com.flagship.abox.manager.infrared.di
|
package com.flagship.abox.manager.infrared.di
|
||||||
|
|
||||||
import android.content.Context
|
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 androidx.room.Room
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
import com.flagship.abox.manager.domain.InfraredGateway
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
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.InfraredDao
|
||||||
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
|
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
|
||||||
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
|
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.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -42,10 +35,6 @@ import javax.inject.Singleton
|
|||||||
@Retention(AnnotationRetention.BINARY)
|
@Retention(AnnotationRetention.BINARY)
|
||||||
annotation class InfraredApplicationScope
|
annotation class InfraredApplicationScope
|
||||||
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class InfraredDataStore
|
|
||||||
|
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
object InfraredFeatureModule {
|
object InfraredFeatureModule {
|
||||||
@@ -83,28 +72,6 @@ object InfraredFeatureModule {
|
|||||||
repository: RoomInfraredKeyRepository,
|
repository: RoomInfraredKeyRepository,
|
||||||
): InfraredDeviceConfigurationRepository = repository
|
): InfraredDeviceConfigurationRepository = repository
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@InfraredDataStore
|
|
||||||
fun provideInfraredPreferencesDataStore(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): DataStore<Preferences> {
|
|
||||||
return PreferenceDataStoreFactory.create(
|
|
||||||
produceFile = { context.preferencesDataStoreFile("infrared_preferences") },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredRuntimeModeStore(
|
|
||||||
@InfraredDataStore dataStore: DataStore<Preferences>,
|
|
||||||
): InfraredRuntimeModeStore = DataStoreInfraredRuntimeModeStore(dataStore)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredRuntimeProfileResolver(): InfraredRuntimeProfileResolver =
|
|
||||||
InfraredRuntimeProfileResolver()
|
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
|
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
|
||||||
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
|
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
|
||||||
|
|||||||
+84
-87
@@ -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.InfraredKeyStatus
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
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.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 dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -34,16 +32,15 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
sealed interface RemoteUiMessage {
|
sealed interface RemoteUiMessage {
|
||||||
data class Info(val text: String) : RemoteUiMessage
|
data class Info(val text: UiText) : RemoteUiMessage
|
||||||
data class Error(val text: String) : RemoteUiMessage
|
data class Error(val text: UiText) : RemoteUiMessage
|
||||||
data object SessionInvalid : RemoteUiMessage
|
data object SessionInvalid : RemoteUiMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
data class InfraredRemoteUiState(
|
data class InfraredRemoteUiState(
|
||||||
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
||||||
val isLearningMode: Boolean = false,
|
val isLearningMode: Boolean = false,
|
||||||
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
val profileId: ProviderProfileId? = null,
|
||||||
val effectiveProfileId: ProviderProfileId? = null,
|
|
||||||
val savedDeviceName: DeviceName? = null,
|
val savedDeviceName: DeviceName? = null,
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||||
val sendingKeySlot: String? = null,
|
val sendingKeySlot: String? = null,
|
||||||
@@ -57,14 +54,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val keyRepository: InfraredKeyRepository,
|
private val keyRepository: InfraredKeyRepository,
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||||
private val runtimeModeStore: InfraredRuntimeModeStore,
|
|
||||||
private val profileResolver: InfraredRuntimeProfileResolver,
|
|
||||||
private val learningCoordinator: InfraredLearningCoordinator,
|
private val learningCoordinator: InfraredLearningCoordinator,
|
||||||
private val sendInfraredKey: SendInfraredKey,
|
private val sendInfraredKey: SendInfraredKey,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
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 sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
|
||||||
private val isLearningModeFlow = MutableStateFlow(false)
|
private val isLearningModeFlow = MutableStateFlow(false)
|
||||||
@@ -75,71 +70,39 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (authenticatedProfileId != null) {
|
if (profileId != null) {
|
||||||
viewModelScope.launch {
|
val startupCutoff = System.currentTimeMillis()
|
||||||
val startupCutoff = System.currentTimeMillis()
|
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
|
||||||
runtimeModeStore.mode.collect { mode ->
|
|
||||||
val effective = profileResolver.resolve(authenticatedProfileId, mode)
|
|
||||||
learningCoordinator.failInterruptedLearning(effective, cutoffEpochMillis = startupCutoff)
|
|
||||||
if (effective != authenticatedProfileId) {
|
|
||||||
learningCoordinator.failInterruptedLearning(authenticatedProfileId, cutoffEpochMillis = startupCutoff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class InfraredRemoteConfigSnapshot(
|
private data class InfraredRemoteConfigSnapshot(
|
||||||
val effectiveProfileId: ProviderProfileId? = null,
|
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
|
||||||
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
|
||||||
val savedDeviceName: DeviceName? = null,
|
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||||
val configError: DomainError? = null,
|
val configError: DomainError? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
||||||
runtimeModeStore.mode.flatMapLatest { mode ->
|
if (profileId == null) {
|
||||||
val profile = authenticatedProfileId?.let { profileResolver.resolve(it, mode) }
|
flowOf(InfraredRemoteConfigSnapshot())
|
||||||
if (profile == null) {
|
} else {
|
||||||
flowOf(InfraredRemoteConfigSnapshot(effectiveProfileId = null, runtimeMode = mode))
|
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
|
||||||
} else {
|
when (deviceResult) {
|
||||||
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
is DomainResult.Failure -> flowOf(
|
||||||
when (deviceResult) {
|
InfraredRemoteConfigSnapshot(configError = deviceResult.error),
|
||||||
is DomainResult.Failure -> flowOf(
|
)
|
||||||
InfraredRemoteConfigSnapshot(
|
is DomainResult.Success -> {
|
||||||
effectiveProfileId = profile,
|
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
||||||
runtimeMode = mode,
|
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
|
||||||
configError = deviceResult.error,
|
when (keysResult) {
|
||||||
),
|
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
|
||||||
)
|
savedDeviceName = deviceName,
|
||||||
is DomainResult.Success -> {
|
configError = keysResult.error,
|
||||||
val deviceName = deviceResult.value
|
)
|
||||||
if (deviceName == null) {
|
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
|
||||||
flowOf(
|
savedDeviceName = deviceName,
|
||||||
InfraredRemoteConfigSnapshot(
|
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
|
||||||
effectiveProfileId = profile,
|
|
||||||
runtimeMode = mode,
|
|
||||||
savedDeviceName = null,
|
|
||||||
learnedSlotMap = emptyMap(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
} 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 ->
|
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
||||||
when (error) {
|
when (error) {
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
|
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
|
||||||
is DomainError.DeviceOffline -> RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
|
is DomainError.DeviceOffline -> RemoteUiMessage.Error(
|
||||||
else -> RemoteUiMessage.Error(error.detail ?: "数据加载失败")
|
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(
|
InfraredRemoteUiState(
|
||||||
selectedScene = scene,
|
selectedScene = scene,
|
||||||
isLearningMode = isLearning,
|
isLearningMode = isLearning,
|
||||||
runtimeMode = snapshot.runtimeMode,
|
profileId = profileId,
|
||||||
effectiveProfileId = snapshot.effectiveProfileId,
|
|
||||||
savedDeviceName = snapshot.savedDeviceName,
|
savedDeviceName = snapshot.savedDeviceName,
|
||||||
learnedSlotMap = snapshot.learnedSlotMap,
|
learnedSlotMap = snapshot.learnedSlotMap,
|
||||||
sendingKeySlot = sendingSlot,
|
sendingKeySlot = sendingSlot,
|
||||||
@@ -176,7 +143,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
}.stateIn(
|
}.stateIn(
|
||||||
scope = viewModelScope,
|
scope = viewModelScope,
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
initialValue = InfraredRemoteUiState(),
|
initialValue = InfraredRemoteUiState(profileId = profileId),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun switchScene(scene: RemoteScene) {
|
fun switchScene(scene: RemoteScene) {
|
||||||
@@ -192,35 +159,38 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
activeLearningJob?.cancel()
|
activeLearningJob?.cancel()
|
||||||
activeLearningJob = null
|
activeLearningJob = null
|
||||||
activeLearningSlotFlow.value = null
|
activeLearningSlotFlow.value = null
|
||||||
state.value.effectiveProfileId?.let { profile ->
|
profileId?.let { profile ->
|
||||||
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
|
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
messageFlow.value = RemoteUiMessage.Info(
|
||||||
|
UiText.StringResource(R.string.infrared_msg_learning_mode_enabled),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onButtonClick(spec: RemoteButtonSpec) {
|
fun onButtonClick(spec: RemoteButtonSpec) {
|
||||||
val currentProfile = state.value.effectiveProfileId ?: return
|
val currentProfile = profileId ?: return
|
||||||
val savedDevice = state.value.savedDeviceName
|
val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME
|
||||||
if (savedDevice == null) {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLearningModeFlow.value) {
|
if (isLearningModeFlow.value) {
|
||||||
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
|
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
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
|
activeLearningSlotFlow.value = spec.defaultSlot
|
||||||
val deferred = when (existingKey?.status) {
|
val deferred = when (existingKey?.status) {
|
||||||
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||||
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
||||||
null -> learningCoordinator.learn(
|
null -> learningCoordinator.learn(
|
||||||
profileId = currentProfile,
|
profileId = currentProfile,
|
||||||
deviceName = savedDevice,
|
deviceName = currentDevice,
|
||||||
displayName = spec.displayName,
|
displayName = spec.displayName,
|
||||||
sdkKey = spec.defaultSlot,
|
sdkKey = spec.defaultSlot,
|
||||||
)
|
)
|
||||||
@@ -240,7 +210,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
when (result) {
|
when (result) {
|
||||||
is DomainResult.Success -> {
|
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 -> {
|
is DomainResult.Failure -> {
|
||||||
handleDomainError(result.error)
|
handleDomainError(result.error)
|
||||||
@@ -263,8 +238,13 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
||||||
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != savedDevice) {
|
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) {
|
||||||
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
|
messageFlow.value = RemoteUiMessage.Error(
|
||||||
|
UiText.StringResource(
|
||||||
|
R.string.infrared_msg_key_not_learned,
|
||||||
|
listOf(spec.displayName),
|
||||||
|
),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sendingKeySlotFlow.value = spec.defaultSlot
|
sendingKeySlotFlow.value = spec.defaultSlot
|
||||||
@@ -272,7 +252,12 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
|
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
|
||||||
is DomainResult.Success -> {
|
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 -> {
|
is DomainResult.Failure -> {
|
||||||
handleDomainError(result.error)
|
handleDomainError(result.error)
|
||||||
@@ -292,7 +277,7 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
underlyingLearningDeferred = null
|
underlyingLearningDeferred = null
|
||||||
activeLearningJob?.cancel()
|
activeLearningJob?.cancel()
|
||||||
activeLearningJob = null
|
activeLearningJob = null
|
||||||
state.value.effectiveProfileId?.let { profile ->
|
profileId?.let { profile ->
|
||||||
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
|
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,11 +292,23 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
messageFlow.value = RemoteUiMessage.SessionInvalid
|
messageFlow.value = RemoteUiMessage.SessionInvalid
|
||||||
}
|
}
|
||||||
is DomainError.DeviceOffline -> {
|
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 -> {
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-21
@@ -1,5 +1,16 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote
|
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<Any> = emptyList(),
|
||||||
|
) : UiText
|
||||||
|
}
|
||||||
|
|
||||||
enum class RemoteScene {
|
enum class RemoteScene {
|
||||||
AIR_CONDITIONER,
|
AIR_CONDITIONER,
|
||||||
TELEVISION,
|
TELEVISION,
|
||||||
@@ -8,38 +19,39 @@ enum class RemoteScene {
|
|||||||
data class RemoteButtonSpec(
|
data class RemoteButtonSpec(
|
||||||
val id: String,
|
val id: String,
|
||||||
val defaultSlot: String,
|
val defaultSlot: String,
|
||||||
|
@StringRes val nameRes: Int,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val symbol: String,
|
val symbol: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
object RemoteSceneConfig {
|
object RemoteSceneConfig {
|
||||||
object AirConditioner {
|
object AirConditioner {
|
||||||
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
|
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻")
|
||||||
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
|
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲")
|
||||||
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
|
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼")
|
||||||
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
|
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀")
|
||||||
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
|
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨")
|
||||||
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
|
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔")
|
||||||
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
|
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱")
|
||||||
|
|
||||||
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
|
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
|
||||||
}
|
}
|
||||||
|
|
||||||
object Television {
|
object Television {
|
||||||
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
|
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻")
|
||||||
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
|
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇")
|
||||||
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
|
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲")
|
||||||
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
|
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼")
|
||||||
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
|
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀")
|
||||||
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
|
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶")
|
||||||
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
|
val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
|
||||||
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
|
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
|
||||||
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "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", "电视-频道+", "CH +")
|
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +")
|
||||||
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
|
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -")
|
||||||
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
|
val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠")
|
||||||
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
|
val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩")
|
||||||
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
|
val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰")
|
||||||
|
|
||||||
val buttons = listOf(
|
val buttons = listOf(
|
||||||
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
|
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
|
||||||
|
|||||||
+5
-3
@@ -20,12 +20,14 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
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.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
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.RemoteButtonSpec
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
||||||
|
|
||||||
@@ -78,7 +80,7 @@ fun AcRemotePanel(
|
|||||||
isLearningMode = isLearningMode,
|
isLearningMode = isLearningMode,
|
||||||
onUpClick = { onButtonClick(tempUpSpec) },
|
onUpClick = { onButtonClick(tempUpSpec) },
|
||||||
onDownClick = { onButtonClick(tempDownSpec) },
|
onDownClick = { onButtonClick(tempDownSpec) },
|
||||||
label = "温度调节",
|
label = stringResource(R.string.infrared_label_temp_control),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,13 +150,13 @@ private fun AcLcdDisplay() {
|
|||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = "❄️ 制冷模式",
|
text = stringResource(R.string.infrared_label_cool_mode),
|
||||||
color = Color(0xFF38BDF8),
|
color = Color(0xFF38BDF8),
|
||||||
fontSize = 14.sp,
|
fontSize = 14.sp,
|
||||||
fontWeight = FontWeight.Medium,
|
fontWeight = FontWeight.Medium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "💨 自动风速 | ↔ 扫风",
|
text = stringResource(R.string.infrared_label_ac_status),
|
||||||
color = Color(0xFF94A3B8),
|
color = Color(0xFF94A3B8),
|
||||||
fontSize = 12.sp,
|
fontSize = 12.sp,
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-13
@@ -35,6 +35,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.painterResource
|
import androidx.compose.ui.res.painterResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.RemoteButtonSpec
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteScene
|
import com.flagship.abox.manager.infrared.remote.RemoteScene
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
|
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
|
||||||
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InfraredRemoteRoute(
|
fun InfraredRemoteRoute(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
onSessionInvalid: () -> Unit,
|
||||||
viewModel: InfraredRemoteViewModel = hiltViewModel(),
|
viewModel: InfraredRemoteViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -63,7 +64,6 @@ fun InfraredRemoteRoute(
|
|||||||
InfraredRemoteScreen(
|
InfraredRemoteScreen(
|
||||||
state = state,
|
state = state,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onSessionInvalid = onSessionInvalid,
|
onSessionInvalid = onSessionInvalid,
|
||||||
onSceneSelected = viewModel::switchScene,
|
onSceneSelected = viewModel::switchScene,
|
||||||
onToggleLearningMode = viewModel::toggleLearningMode,
|
onToggleLearningMode = viewModel::toggleLearningMode,
|
||||||
@@ -77,7 +77,6 @@ fun InfraredRemoteRoute(
|
|||||||
internal fun InfraredRemoteScreen(
|
internal fun InfraredRemoteScreen(
|
||||||
state: InfraredRemoteUiState,
|
state: InfraredRemoteUiState,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
onSessionInvalid: () -> Unit,
|
||||||
onSceneSelected: (RemoteScene) -> Unit,
|
onSceneSelected: (RemoteScene) -> Unit,
|
||||||
onToggleLearningMode: (Boolean) -> Unit,
|
onToggleLearningMode: (Boolean) -> Unit,
|
||||||
@@ -85,16 +84,25 @@ internal fun InfraredRemoteScreen(
|
|||||||
onMessageShown: () -> Unit,
|
onMessageShown: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
LaunchedEffect(state.message) {
|
||||||
state.message?.let { msg ->
|
state.message?.let { msg ->
|
||||||
when (msg) {
|
when (msg) {
|
||||||
is RemoteUiMessage.Info -> {
|
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()
|
onMessageShown()
|
||||||
}
|
}
|
||||||
is RemoteUiMessage.Error -> {
|
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()
|
onMessageShown()
|
||||||
}
|
}
|
||||||
is RemoteUiMessage.SessionInvalid -> {
|
is RemoteUiMessage.SessionInvalid -> {
|
||||||
@@ -145,14 +153,6 @@ internal fun InfraredRemoteScreen(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置齿轮按钮
|
|
||||||
IconButton(
|
|
||||||
onClick = onOpenSettings,
|
|
||||||
modifier = Modifier.testTag("remote_settings_button"),
|
|
||||||
) {
|
|
||||||
Text("⚙️", fontSize = 20.sp)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
+4
-2
@@ -10,9 +10,11 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
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.RemoteButtonSpec
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ fun TvRemotePanel(
|
|||||||
isLearningMode = isLearningMode,
|
isLearningMode = isLearningMode,
|
||||||
onUpClick = { onButtonClick(volUp) },
|
onUpClick = { onButtonClick(volUp) },
|
||||||
onDownClick = { onButtonClick(volDown) },
|
onDownClick = { onButtonClick(volDown) },
|
||||||
label = "音量 VOL",
|
label = stringResource(R.string.infrared_label_vol),
|
||||||
)
|
)
|
||||||
|
|
||||||
// 频道控制
|
// 频道控制
|
||||||
@@ -134,7 +136,7 @@ fun TvRemotePanel(
|
|||||||
isLearningMode = isLearningMode,
|
isLearningMode = isLearningMode,
|
||||||
onUpClick = { onButtonClick(chUp) },
|
onUpClick = { onButtonClick(chUp) },
|
||||||
onDownClick = { onButtonClick(chDown) },
|
onDownClick = { onButtonClick(chDown) },
|
||||||
label = "频道 CH",
|
label = stringResource(R.string.infrared_label_ch),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-59
@@ -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<Preferences>,
|
|
||||||
) : InfraredRuntimeModeStore {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val KEY_RUNTIME_MODE = stringPreferencesKey("infrared_runtime_mode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override val mode: Flow<InfraredRuntimeMode> = 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<Unit> {
|
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-15
@@ -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<InfraredRuntimeMode>
|
|
||||||
suspend fun currentMode(): InfraredRuntimeMode
|
|
||||||
suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit>
|
|
||||||
}
|
|
||||||
-12
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-466
@@ -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<SavedInfraredKey?>(null) }
|
|
||||||
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
|
||||||
state.message?.let { msg ->
|
|
||||||
when (msg) {
|
|
||||||
is InfraredUiMessage.Info -> {
|
|
||||||
snackbarHostState.showSnackbar(msg.text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is InfraredUiMessage.Error -> {
|
|
||||||
snackbarHostState.showSnackbar(msg.text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is InfraredUiMessage.SessionInvalid -> {
|
|
||||||
onSessionInvalid()
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.infrared_title)) },
|
|
||||||
navigationIcon = {
|
|
||||||
TextButton(onClick = onBack) {
|
|
||||||
Text(stringResource(R.string.infrared_back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
) { innerPadding ->
|
|
||||||
ABoxSurface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// Mode indicator and toggle
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
val modeText = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
|
|
||||||
stringResource(R.string.infrared_mode_fake)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.infrared_mode_real)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = modeText,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.testTag("infrared_mode_label"),
|
|
||||||
)
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = {
|
|
||||||
val target = if (state.runtimeMode == InfraredRuntimeMode.FAKE) {
|
|
||||||
InfraredRuntimeMode.REAL_ABSDK
|
|
||||||
} else {
|
|
||||||
InfraredRuntimeMode.FAKE
|
|
||||||
}
|
|
||||||
onModeChanged(target)
|
|
||||||
},
|
|
||||||
enabled = !state.hasActiveLearning,
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_switch_mode))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device name configuration card
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
val saved = state.savedDeviceName
|
|
||||||
Text(
|
|
||||||
text = if (saved != null) {
|
|
||||||
stringResource(R.string.infrared_saved_device, saved.value)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.infrared_no_device_saved)
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.deviceNameInput,
|
|
||||||
onValueChange = onDeviceNameChanged,
|
|
||||||
label = { Text(stringResource(R.string.infrared_device_name_label)) },
|
|
||||||
placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.testTag("infrared_device_name_input"),
|
|
||||||
)
|
|
||||||
ABoxButton(
|
|
||||||
onClick = onSaveDeviceName,
|
|
||||||
modifier = Modifier.testTag("infrared_save_device"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_save_device))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Learned Keys Section
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_keys_title),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
ABoxButton(
|
|
||||||
onClick = { showLearnDialog = true },
|
|
||||||
enabled = state.savedDeviceName != null,
|
|
||||||
modifier = Modifier.testTag("infrared_learn_new_key"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_learn_new_key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.keys.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_no_keys),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
items(state.keys, key = { it.id.value }) { key ->
|
|
||||||
InfraredKeyRow(
|
|
||||||
key = key,
|
|
||||||
isSending = state.sendingKeyIds.contains(key.id),
|
|
||||||
onSend = { onSend(key.id) },
|
|
||||||
onRetry = { onRetry(key.id) },
|
|
||||||
onRename = { keyToRename = key },
|
|
||||||
onDelete = { keyToDelete = key },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showLearnDialog) {
|
|
||||||
var keyName by remember { mutableStateOf("") }
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-27
@@ -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<SavedInfraredKey> = emptyList(),
|
|
||||||
val loading: Boolean = true,
|
|
||||||
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
|
|
||||||
val message: InfraredUiMessage? = null,
|
|
||||||
) {
|
|
||||||
val hasActiveLearning: Boolean get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
|
|
||||||
}
|
|
||||||
-342
@@ -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<Set<InfraredKeyId>>(emptySet())
|
|
||||||
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
|
||||||
|
|
||||||
private val effectiveProfileAndMode: Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> =
|
|
||||||
runtimeModeStore.mode.mapModeToProfile()
|
|
||||||
|
|
||||||
private fun Flow<InfraredRuntimeMode>.mapModeToProfile(): Flow<Pair<ProviderProfileId?, InfraredRuntimeMode>> {
|
|
||||||
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<DomainResult<DeviceName?>> =
|
|
||||||
effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
|
||||||
if (profile == null) {
|
|
||||||
flowOf(DomainResult.Success(null))
|
|
||||||
} else {
|
|
||||||
deviceConfigurationRepository.observeDeviceName(profile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val keysFlow: Flow<DomainResult<List<SavedInfraredKey>>> =
|
|
||||||
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<DeviceName?>,
|
|
||||||
val keysResult: DomainResult<List<SavedInfraredKey>>,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val serverStateFlow: Flow<ServerState> = combine(
|
|
||||||
effectiveProfileAndMode,
|
|
||||||
deviceNameFlow,
|
|
||||||
keysFlow,
|
|
||||||
) { profileAndMode, deviceResult, keysResult ->
|
|
||||||
ServerState(
|
|
||||||
profile = profileAndMode.first,
|
|
||||||
mode = profileAndMode.second,
|
|
||||||
deviceResult = deviceResult,
|
|
||||||
keysResult = keysResult,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val state: StateFlow<InfraredControlUiState> = combine(
|
|
||||||
serverStateFlow,
|
|
||||||
deviceNameInputFlow,
|
|
||||||
sendingKeyIdsFlow,
|
|
||||||
messageFlow,
|
|
||||||
) { serverState, input, sendingIds, msg ->
|
|
||||||
val savedDevice = (serverState.deviceResult as? DomainResult.Success<DeviceName?>)?.value
|
|
||||||
val keys = (serverState.keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -39,4 +39,41 @@
|
|||||||
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
|
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
|
||||||
<string name="infrared_remote_scene_tv">📺 电视遥控</string>
|
<string name="infrared_remote_scene_tv">📺 电视遥控</string>
|
||||||
<string name="infrared_remote_settings">设置</string>
|
<string name="infrared_remote_settings">设置</string>
|
||||||
|
|
||||||
|
<string name="infrared_msg_learning_mode_enabled">🔴 学习录制模式已开启:点击任意按键即可开始录制</string>
|
||||||
|
<string name="infrared_msg_recording_key">正在录制【%1$s】(Slot %2$s),请按遥控器按键...</string>
|
||||||
|
<string name="infrared_msg_learned_success">【%1$s】录制成功</string>
|
||||||
|
<string name="infrared_msg_key_not_learned">按键【%1$s】尚未学习,请开启右上角学习模式进行录制</string>
|
||||||
|
<string name="infrared_msg_signal_sent">已发射: %1$s</string>
|
||||||
|
<string name="infrared_msg_device_offline">红外控制器离线 (错误码: %1$s)</string>
|
||||||
|
<string name="infrared_msg_load_failed">数据加载失败</string>
|
||||||
|
<string name="infrared_msg_operation_failed">操作失败</string>
|
||||||
|
<string name="infrared_label_temp_control">温度调节</string>
|
||||||
|
<string name="infrared_label_vol">音量 VOL</string>
|
||||||
|
<string name="infrared_label_ch">频道 CH</string>
|
||||||
|
<string name="infrared_label_cool_mode">❄️ 制冷模式</string>
|
||||||
|
<string name="infrared_label_ac_status">💨 自动风速 | ↔ 扫风</string>
|
||||||
|
|
||||||
|
<string name="infrared_key_ac_power">空调-电源</string>
|
||||||
|
<string name="infrared_key_ac_temp_up">空调-温度+</string>
|
||||||
|
<string name="infrared_key_ac_temp_down">空调-温度-</string>
|
||||||
|
<string name="infrared_key_ac_mode">空调-模式</string>
|
||||||
|
<string name="infrared_key_ac_fan">空调-风速</string>
|
||||||
|
<string name="infrared_key_ac_swing">空调-扫风</string>
|
||||||
|
<string name="infrared_key_ac_timer">空调-定时</string>
|
||||||
|
|
||||||
|
<string name="infrared_key_tv_power">电视-电源</string>
|
||||||
|
<string name="infrared_key_tv_mute">电视-静音</string>
|
||||||
|
<string name="infrared_key_tv_nav_up">电视-上</string>
|
||||||
|
<string name="infrared_key_tv_nav_down">电视-下</string>
|
||||||
|
<string name="infrared_key_tv_nav_left">电视-左</string>
|
||||||
|
<string name="infrared_key_tv_nav_right">电视-右</string>
|
||||||
|
<string name="infrared_key_tv_ok">电视-确定</string>
|
||||||
|
<string name="infrared_key_tv_vol_up">电视-音量+</string>
|
||||||
|
<string name="infrared_key_tv_vol_down">电视-音量-</string>
|
||||||
|
<string name="infrared_key_tv_ch_up">电视-频道+</string>
|
||||||
|
<string name="infrared_key_tv_ch_down">电视-频道-</string>
|
||||||
|
<string name="infrared_key_tv_home">电视-主页</string>
|
||||||
|
<string name="infrared_key_tv_back">电视-返回</string>
|
||||||
|
<string name="infrared_key_tv_menu">电视-菜单</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -39,4 +39,41 @@
|
|||||||
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
|
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
|
||||||
<string name="infrared_remote_scene_tv">📺 Television</string>
|
<string name="infrared_remote_scene_tv">📺 Television</string>
|
||||||
<string name="infrared_remote_settings">Settings</string>
|
<string name="infrared_remote_settings">Settings</string>
|
||||||
|
|
||||||
|
<string name="infrared_msg_learning_mode_enabled">🔴 Learn mode active: tap any button to record</string>
|
||||||
|
<string name="infrared_msg_recording_key">Recording [%1$s] (Slot %2$s), press remote button…</string>
|
||||||
|
<string name="infrared_msg_learned_success">[%1$s] recorded successfully</string>
|
||||||
|
<string name="infrared_msg_key_not_learned">Button [%1$s] is not learned. Enable learn mode to record.</string>
|
||||||
|
<string name="infrared_msg_signal_sent">Sent: %1$s</string>
|
||||||
|
<string name="infrared_msg_device_offline">Infrared controller offline (code: %1$s)</string>
|
||||||
|
<string name="infrared_msg_load_failed">Failed to load data</string>
|
||||||
|
<string name="infrared_msg_operation_failed">Operation failed</string>
|
||||||
|
<string name="infrared_label_temp_control">Temp Control</string>
|
||||||
|
<string name="infrared_label_vol">VOL</string>
|
||||||
|
<string name="infrared_label_ch">CH</string>
|
||||||
|
<string name="infrared_label_cool_mode">❄️ Cool Mode</string>
|
||||||
|
<string name="infrared_label_ac_status">💨 Auto Fan | ↔ Swing</string>
|
||||||
|
|
||||||
|
<string name="infrared_key_ac_power">AC Power</string>
|
||||||
|
<string name="infrared_key_ac_temp_up">AC Temp +</string>
|
||||||
|
<string name="infrared_key_ac_temp_down">AC Temp -</string>
|
||||||
|
<string name="infrared_key_ac_mode">AC Mode</string>
|
||||||
|
<string name="infrared_key_ac_fan">AC Fan Speed</string>
|
||||||
|
<string name="infrared_key_ac_swing">AC Swing</string>
|
||||||
|
<string name="infrared_key_ac_timer">AC Timer</string>
|
||||||
|
|
||||||
|
<string name="infrared_key_tv_power">TV Power</string>
|
||||||
|
<string name="infrared_key_tv_mute">TV Mute</string>
|
||||||
|
<string name="infrared_key_tv_nav_up">TV Up</string>
|
||||||
|
<string name="infrared_key_tv_nav_down">TV Down</string>
|
||||||
|
<string name="infrared_key_tv_nav_left">TV Left</string>
|
||||||
|
<string name="infrared_key_tv_nav_right">TV Right</string>
|
||||||
|
<string name="infrared_key_tv_ok">TV OK</string>
|
||||||
|
<string name="infrared_key_tv_vol_up">TV Vol +</string>
|
||||||
|
<string name="infrared_key_tv_vol_down">TV Vol -</string>
|
||||||
|
<string name="infrared_key_tv_ch_up">TV CH +</string>
|
||||||
|
<string name="infrared_key_tv_ch_down">TV CH -</string>
|
||||||
|
<string name="infrared_key_tv_home">TV Home</string>
|
||||||
|
<string name="infrared_key_tv_back">TV Back</string>
|
||||||
|
<string name="infrared_key_tv_menu">TV Menu</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
+28
@@ -125,4 +125,32 @@ class InfraredLearningCoordinatorTest {
|
|||||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
||||||
assertEquals(InfraredKeyStatus.FAILED, updated.status)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-43
@@ -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.RetryInfraredKeyLearning
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
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.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.FakeInfraredDeviceConfigurationRepository
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
@@ -35,30 +31,17 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
private class TestModeStore : InfraredRuntimeModeStore {
|
|
||||||
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
|
|
||||||
override val mode: Flow<InfraredRuntimeMode> = modeFlow
|
|
||||||
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
|
|
||||||
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
|
|
||||||
modeFlow.value = mode
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class InfraredRemoteViewModelTest {
|
class InfraredRemoteViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val testScope = TestScope(testDispatcher)
|
private val testScope = TestScope(testDispatcher)
|
||||||
|
|
||||||
private val profile = ProviderProfileId("abox")
|
private val profile = ProviderProfileId("abox")
|
||||||
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
|
|
||||||
private val device = DeviceName("ir-living")
|
private val device = DeviceName("ir-living")
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
private val clock = EpochMillisClock { 10_000L }
|
||||||
|
|
||||||
private val keyRepo = FakeInfraredKeyRepository()
|
private val keyRepo = FakeInfraredKeyRepository()
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||||
private val modeStore = TestModeStore()
|
|
||||||
private val resolver = InfraredRuntimeProfileResolver()
|
|
||||||
private val gateway = FakeInfraredGateway()
|
private val gateway = FakeInfraredGateway()
|
||||||
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
|
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
|
||||||
|
|
||||||
@@ -84,8 +67,6 @@ class InfraredRemoteViewModelTest {
|
|||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
||||||
keyRepository = keyRepo,
|
keyRepository = keyRepo,
|
||||||
deviceConfigurationRepository = deviceRepo,
|
deviceConfigurationRepository = deviceRepo,
|
||||||
runtimeModeStore = modeStore,
|
|
||||||
profileResolver = resolver,
|
|
||||||
learningCoordinator = coordinator,
|
learningCoordinator = coordinator,
|
||||||
sendInfraredKey = send,
|
sendInfraredKey = send,
|
||||||
)
|
)
|
||||||
@@ -93,10 +74,10 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
|
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val readyKey = SavedInfraredKey(
|
val readyKey = SavedInfraredKey(
|
||||||
id = InfraredKeyId("k1"),
|
id = InfraredKeyId("k1"),
|
||||||
profileId = fakeProfile,
|
profileId = profile,
|
||||||
deviceName = device,
|
deviceName = device,
|
||||||
displayName = "空调-电源",
|
displayName = "空调-电源",
|
||||||
sdkKey = "1",
|
sdkKey = "1",
|
||||||
@@ -123,7 +104,7 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
|
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -141,7 +122,7 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest {
|
fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -157,10 +138,10 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
|
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val readyKey = SavedInfraredKey(
|
val readyKey = SavedInfraredKey(
|
||||||
id = InfraredKeyId("k1"),
|
id = InfraredKeyId("k1"),
|
||||||
profileId = fakeProfile,
|
profileId = profile,
|
||||||
deviceName = device,
|
deviceName = device,
|
||||||
displayName = "空调-电源",
|
displayName = "空调-电源",
|
||||||
sdkKey = "1",
|
sdkKey = "1",
|
||||||
@@ -187,10 +168,10 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
|
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val failedKey = SavedInfraredKey(
|
val failedKey = SavedInfraredKey(
|
||||||
id = InfraredKeyId("k1"),
|
id = InfraredKeyId("k1"),
|
||||||
profileId = fakeProfile,
|
profileId = profile,
|
||||||
deviceName = device,
|
deviceName = device,
|
||||||
displayName = "空调-电源",
|
displayName = "空调-电源",
|
||||||
sdkKey = "1",
|
sdkKey = "1",
|
||||||
@@ -212,16 +193,19 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
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(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()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun learningFailurePropagatesErrorMessage() = testScope.runTest {
|
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"))
|
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
@@ -232,14 +216,17 @@ class InfraredRemoteViewModelTest {
|
|||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||||
advanceUntilIdle()
|
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()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
|
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"))
|
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
@@ -257,7 +244,7 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
|
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -274,10 +261,10 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
|
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val readyKey = SavedInfraredKey(
|
val readyKey = SavedInfraredKey(
|
||||||
id = InfraredKeyId("k1"),
|
id = InfraredKeyId("k1"),
|
||||||
profileId = fakeProfile,
|
profileId = profile,
|
||||||
deviceName = device,
|
deviceName = device,
|
||||||
displayName = "空调-电源",
|
displayName = "空调-电源",
|
||||||
sdkKey = "1",
|
sdkKey = "1",
|
||||||
@@ -299,19 +286,22 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
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(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()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
|
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
deviceRepo.setDeviceName(profile, device)
|
||||||
val learningKey = SavedInfraredKey(
|
val learningKey = SavedInfraredKey(
|
||||||
id = InfraredKeyId("k1"),
|
id = InfraredKeyId("k1"),
|
||||||
profileId = fakeProfile,
|
profileId = profile,
|
||||||
deviceName = device,
|
deviceName = device,
|
||||||
displayName = "空调-电源",
|
displayName = "空调-电源",
|
||||||
sdkKey = "1",
|
sdkKey = "1",
|
||||||
@@ -333,9 +323,12 @@ class InfraredRemoteViewModelTest {
|
|||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
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(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()
|
job.cancel()
|
||||||
}
|
}
|
||||||
@@ -348,7 +341,7 @@ class InfraredRemoteViewModelTest {
|
|||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
assertEquals(RemoteUiMessage.Error("Disk failure"), vm.state.value.message)
|
assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message)
|
||||||
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
|
|||||||
-21
@@ -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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-295
@@ -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<InfraredRuntimeMode> = modeFlow
|
|
||||||
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
|
|
||||||
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user