feat: add infrared control state machine
This commit is contained in:
+27
@@ -0,0 +1,27 @@
|
||||
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 }
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
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 learnKey(displayName: String) {
|
||||
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
|
||||
}
|
||||
learningCoordinator.learn(currentProfile, savedDevice, trimmedName)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user