From 7f9fb8c2b6923ae6db45820bd8b371eac033efac Mon Sep 17 00:00:00 2001 From: KremeCN <3265494849@qq.com> Date: Tue, 25 Aug 2026 14:11:49 +0800 Subject: [PATCH] feat(infrared): refactor virtual remote hub with multi-device presets and quota management --- .../abox/manager/ui/ABoxManagerApp.kt | 25 +- .../infrared/data/local/InfraredDao.kt | 106 ++++ .../infrared/data/local/InfraredDatabase.kt | 6 +- .../infrared/data/local/InfraredEntities.kt | 49 ++ .../data/local/RoomVirtualRemoteRepository.kt | 213 ++++++++ .../infrared/di/InfraredFeatureModule.kt | 38 +- .../manager/infrared/hub/InfraredHubScreen.kt | 509 ++++++++++++++++++ .../infrared/hub/InfraredHubUiState.kt | 25 + .../infrared/hub/InfraredHubViewModel.kt | 198 +++++++ .../infrared/panel/RemotePanelScreen.kt | 499 +++++++++++++++++ .../infrared/panel/RemotePanelUiState.kt | 23 + .../infrared/panel/RemotePanelViewModel.kt | 187 +++++++ .../infrared/ui/InfraredControlScreen.kt | 4 +- .../infrared/ui/InfraredControlViewModel.kt | 31 +- .../src/main/res/values-zh-rCN/strings.xml | 79 ++- .../infrared/src/main/res/values/strings.xml | 21 + .../infrared/hub/InfraredHubViewModelTest.kt | 136 +++++ .../panel/RemotePanelViewModelTest.kt | 128 +++++ .../domain/infrared/AcCodeGenerator.kt | 52 ++ .../domain/infrared/CreateVirtualRemote.kt | 108 ++++ .../domain/infrared/DeleteVirtualRemote.kt | 13 + .../domain/infrared/PresetCodeLibrary.kt | 81 +++ .../infrared/SendVirtualRemoteAction.kt | 85 +++ .../domain/infrared/VirtualRemoteModels.kt | 93 ++++ .../infrared/VirtualRemoteRepository.kt | 56 ++ .../infrared/InMemoryInfraredTestDoubles.kt | 103 +++- .../infrared/VirtualRemoteUseCaseTest.kt | 119 ++++ .../testing/fakes/FakeDeviceGateways.kt | 3 + .../fakes/FakeVirtualRemoteRepository.kt | 126 +++++ 29 files changed, 3072 insertions(+), 44 deletions(-) create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt create mode 100644 android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt create mode 100644 android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt create mode 100644 domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt create mode 100644 domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt create mode 100644 testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt diff --git a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt index 257990d..5b6998e 100644 --- a/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt +++ b/android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt @@ -21,7 +21,8 @@ import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.Username -import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute +import com.flagship.abox.manager.infrared.hub.InfraredHubRoute +import com.flagship.abox.manager.infrared.panel.RemotePanelRoute import com.flagship.abox.manager.infrared.ui.InfraredControlRoute import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen import com.flagship.abox.manager.ui.login.LoginRoute @@ -103,27 +104,43 @@ private fun AuthenticatedNavHost( val encodedProfileId = android.net.Uri.encode(profile.id.value) DeviceListScreen( onInfraredClick = { - navController.navigate("infrared_remote/$encodedProfileId") + navController.navigate("infrared_hub/$encodedProfileId") }, ) }, ) } composable( - route = "infrared_remote/{profileId}", + route = "infrared_hub/{profileId}", arguments = listOf( navArgument("profileId") { type = NavType.StringType }, ), ) { val encodedProfileId = android.net.Uri.encode(profile.id.value) - InfraredRemoteRoute( + InfraredHubRoute( onBack = { navController.popBackStack() }, + onOpenRemote = { remoteId -> + val encodedRemoteId = android.net.Uri.encode(remoteId) + navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId") + }, onOpenSettings = { navController.navigate("infrared_settings/$encodedProfileId") }, onSessionInvalid = onLogout, ) } + composable( + route = "infrared_panel/{profileId}/{remoteId}", + arguments = listOf( + navArgument("profileId") { type = NavType.StringType }, + navArgument("remoteId") { type = NavType.StringType }, + ), + ) { + RemotePanelRoute( + onBack = { navController.popBackStack() }, + onSessionInvalid = onLogout, + ) + } composable( route = "infrared_settings/{profileId}", arguments = listOf( diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt index 3d24b10..98e402f 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDao.kt @@ -105,4 +105,110 @@ interface InfraredDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity) + + // Virtual Remotes + @Query( + """ + SELECT * FROM virtual_remotes + WHERE profileId = :profileId AND deviceName = :deviceName + ORDER BY createdAtEpochMillis DESC + """, + ) + fun observeVirtualRemotes(profileId: String, deviceName: String): Flow> + + @Query( + """ + SELECT * FROM virtual_remotes + WHERE profileId = :profileId AND id = :id + LIMIT 1 + """, + ) + fun observeVirtualRemote(profileId: String, id: String): Flow + + @Query( + """ + SELECT * FROM virtual_remotes + WHERE profileId = :profileId AND id = :id + LIMIT 1 + """, + ) + suspend fun getVirtualRemote(profileId: String, id: String): VirtualRemoteEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertVirtualRemote(entity: VirtualRemoteEntity) + + @Query( + """ + DELETE FROM virtual_remotes + WHERE profileId = :profileId AND id = :id + """, + ) + suspend fun deleteVirtualRemote(profileId: String, id: String): Int + + @Query( + """ + UPDATE virtual_remotes + SET acPower = :power, + acMode = :mode, + acTemperature = :temperature, + acFanSpeed = :fanSpeed, + acVerticalSwing = :verticalSwing, + acHorizontalSwing = :horizontalSwing, + acEco = :eco, + updatedAtEpochMillis = :updatedAtEpochMillis + WHERE profileId = :profileId AND id = :id + """, + ) + suspend fun updateAcState( + profileId: String, + id: String, + power: Boolean?, + mode: String?, + temperature: Int?, + fanSpeed: String?, + verticalSwing: Boolean?, + horizontalSwing: Boolean?, + eco: Boolean?, + updatedAtEpochMillis: Long, + ): Int + + // Remote Key Bindings + @Query( + """ + SELECT * FROM remote_key_bindings + WHERE remoteId = :remoteId + """, + ) + fun observeKeyBindings(remoteId: String): Flow> + + @Query( + """ + SELECT * FROM remote_key_bindings + WHERE remoteId = :remoteId + """, + ) + suspend fun getKeyBindings(remoteId: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertKeyBindings(entities: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertKeyBinding(entity: RemoteKeyBindingEntity) + + @Query( + """ + DELETE FROM remote_key_bindings + WHERE remoteId = :remoteId + """, + ) + suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int + + @Query( + """ + SELECT b.assignedSlot FROM remote_key_bindings b + INNER JOIN virtual_remotes r ON b.remoteId = r.id + WHERE r.profileId = :profileId AND r.deviceName = :deviceName AND b.assignedSlot IS NOT NULL + """, + ) + fun observeAssignedStaticSlots(profileId: String, deviceName: String): Flow> } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt index c8fb736..af179fd 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredDatabase.kt @@ -7,9 +7,11 @@ import androidx.room.RoomDatabase entities = [ InfraredKeyEntity::class, InfraredDeviceConfigurationEntity::class, + VirtualRemoteEntity::class, + RemoteKeyBindingEntity::class, ], - version = 1, - exportSchema = true, + version = 2, + exportSchema = false, ) abstract class InfraredDatabase : RoomDatabase() { abstract fun infraredDao(): InfraredDao diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt index 63b1fb9..1ae92d2 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/InfraredEntities.kt @@ -31,3 +31,52 @@ data class InfraredDeviceConfigurationEntity( val profileId: String, val deviceName: String, ) + +@Entity( + tableName = "virtual_remotes", + indices = [ + Index(value = ["profileId", "deviceName"]), + ], +) +data class VirtualRemoteEntity( + @PrimaryKey + val id: String, + val profileId: String, + val deviceName: String, + val displayName: String, + val type: String, + val brandModel: String?, + val transmissionMode: String, + val dynamicScratchSlot: String, + val acPower: Boolean?, + val acMode: String?, + val acTemperature: Int?, + val acFanSpeed: String?, + val acVerticalSwing: Boolean?, + val acHorizontalSwing: Boolean?, + val acEco: Boolean?, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) + +@Entity( + tableName = "remote_key_bindings", + indices = [ + Index(value = ["remoteId"]), + Index(value = ["profileId"]), + ], +) +data class RemoteKeyBindingEntity( + @PrimaryKey + val id: String, + val remoteId: String, + val profileId: String, + val buttonId: String, + val displayName: String, + val assignedSlot: String?, + val rawHexCode: String?, + val status: String, + val lastErrorCode: String?, + val lastErrorDetail: String?, +) + diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt new file mode 100644 index 0000000..de36ae2 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/data/local/RoomVirtualRemoteRepository.kt @@ -0,0 +1,213 @@ +package com.flagship.abox.manager.infrared.data.local + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class RoomVirtualRemoteRepository @Inject constructor( + private val dao: InfraredDao, +) : VirtualRemoteRepository { + + override fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = + dao.observeVirtualRemotes(profileId.value, deviceName.value).map { entities -> + DomainResult.Success(entities.map { it.toDomain() }) + } + + override fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> = + dao.observeVirtualRemote(profileId.value, remoteId.value).map { entity -> + DomainResult.Success(entity?.toDomain()) + } + + override fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> = + dao.observeKeyBindings(remoteId.value).map { entities -> + DomainResult.Success(entities.map { it.toDomain() }) + } + + override fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> = + dao.observeAssignedStaticSlots(profileId.value, deviceName.value).map { slots -> + val slotInts = slots.mapNotNull { it.toIntOrNull() }.toSet() + DomainResult.Success( + SlotQuotaSummary( + totalCapacity = 100, + dynamicScratchSlot = 100, + assignedStaticSlots = slotInts, + ), + ) + } + + override suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = try { + val entity = dao.getVirtualRemote(profileId.value, remoteId.value) + DomainResult.Success(entity?.toDomain()) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> = try { + val list = dao.getKeyBindings(remoteId.value) + DomainResult.Success(list.map { it.toDomain() }) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult = try { + dao.insertVirtualRemote(remote.toEntity()) + dao.insertKeyBindings(initialBindings.map { it.toEntity() }) + DomainResult.Success(remote.id) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun updateRemote(remote: VirtualRemote): DomainResult = try { + dao.insertVirtualRemote(remote.toEntity()) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = try { + dao.deleteVirtualRemote(profileId.value, remoteId.value) + dao.deleteKeyBindingsByRemoteId(remoteId.value) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult = try { + dao.insertKeyBinding(binding.toEntity()) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } + + override suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult = try { + dao.updateAcState( + profileId = profileId.value, + id = remoteId.value, + power = state.power, + mode = state.mode.name, + temperature = state.temperature, + fanSpeed = state.fanSpeed.name, + verticalSwing = state.verticalSwing, + horizontalSwing = state.horizontalSwing, + eco = state.eco, + updatedAtEpochMillis = System.currentTimeMillis(), + ) + DomainResult.Success(Unit) + } catch (e: Exception) { + DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message)) + } +} + +private fun VirtualRemoteEntity.toDomain(): VirtualRemote { + val ac = if (type == RemoteDeviceType.AIR_CONDITIONER.name) { + AcStateSnapshot( + power = acPower ?: true, + mode = acMode?.let { runCatching { AcMode.valueOf(it) }.getOrNull() } ?: AcMode.COOL, + temperature = acTemperature ?: 26, + fanSpeed = acFanSpeed?.let { runCatching { AcFanSpeed.valueOf(it) }.getOrNull() } ?: AcFanSpeed.AUTO, + verticalSwing = acVerticalSwing ?: true, + horizontalSwing = acHorizontalSwing ?: false, + eco = acEco ?: false, + ) + } else null + + return VirtualRemote( + id = VirtualRemoteId(id), + profileId = ProviderProfileId(profileId), + deviceName = DeviceName(deviceName), + displayName = displayName, + type = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM), + brandModel = brandModel, + transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH), + dynamicScratchSlot = dynamicScratchSlot, + acState = ac, + createdAtEpochMillis = createdAtEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, + ) +} + +private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity( + id = id.value, + profileId = profileId.value, + deviceName = deviceName.value, + displayName = displayName, + type = type.name, + brandModel = brandModel, + transmissionMode = transmissionMode.name, + dynamicScratchSlot = dynamicScratchSlot, + acPower = acState?.power, + acMode = acState?.mode?.name, + acTemperature = acState?.temperature, + acFanSpeed = acState?.fanSpeed?.name, + acVerticalSwing = acState?.verticalSwing, + acHorizontalSwing = acState?.horizontalSwing, + acEco = acState?.eco, + createdAtEpochMillis = createdAtEpochMillis, + updatedAtEpochMillis = updatedAtEpochMillis, +) + +private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBinding( + id = id, + remoteId = VirtualRemoteId(remoteId), + profileId = ProviderProfileId(profileId), + buttonId = buttonId, + displayName = displayName, + assignedSlot = assignedSlot, + rawHexCode = rawHexCode, + status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY), + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, +) + +private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity( + id = id, + remoteId = remoteId.value, + profileId = profileId.value, + buttonId = buttonId, + displayName = displayName, + assignedSlot = assignedSlot, + rawHexCode = rawHexCode, + status = status.name, + lastErrorCode = lastErrorCode, + lastErrorDetail = lastErrorDetail, +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt index 5237989..c0baa4d 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/di/InfraredFeatureModule.kt @@ -19,6 +19,7 @@ import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinato import com.flagship.abox.manager.infrared.data.local.InfraredDao import com.flagship.abox.manager.infrared.data.local.InfraredDatabase import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository +import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -48,7 +49,7 @@ object InfraredFeatureModule { context, InfraredDatabase::class.java, "abox_infrared.db", - ).build() + ).fallbackToDestructiveMigration().build() } @Provides @@ -161,4 +162,39 @@ object InfraredFeatureModule { keyRepository = repository, externalScope = scope, ) + + @Provides + @Singleton + fun provideRoomVirtualRemoteRepository(dao: InfraredDao): RoomVirtualRemoteRepository = + RoomVirtualRemoteRepository(dao) + + @Provides + @Singleton + fun provideVirtualRemoteRepository( + repository: RoomVirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository = repository + + @Provides + @Singleton + fun provideCreateVirtualRemote( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + gateway: InfraredGateway, + clock: EpochMillisClock, + ): com.flagship.abox.manager.domain.infrared.CreateVirtualRemote = + com.flagship.abox.manager.domain.infrared.CreateVirtualRemote(repository, gateway, clock) + + @Provides + @Singleton + fun provideSendVirtualRemoteAction( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + gateway: InfraredGateway, + ): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction = + com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway) + + @Provides + @Singleton + fun provideDeleteVirtualRemote( + repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository, + ): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote = + com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository) } diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt new file mode 100644 index 0000000..4498cc8 --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubScreen.kt @@ -0,0 +1,509 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.compose.foundation.clickable +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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.PresetBrandOption +import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText + +@Composable +fun InfraredHubRoute( + onBack: () -> Unit, + onOpenRemote: (String) -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: InfraredHubViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + InfraredHubScreen( + state = state, + onBack = onBack, + onOpenRemote = onOpenRemote, + onOpenSettings = onOpenSettings, + onSessionInvalid = onSessionInvalid, + onCreateRemote = viewModel::createRemote, + onDeleteRemote = viewModel::deleteRemote, + onSaveDeviceName = viewModel::saveDeviceName, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun InfraredHubScreen( + state: InfraredHubUiState, + onBack: () -> Unit, + onOpenRemote: (String) -> Unit, + onOpenSettings: () -> Unit, + onSessionInvalid: () -> Unit, + onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode) -> Unit, + onDeleteRemote: (VirtualRemoteId) -> Unit, + onSaveDeviceName: (String) -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + var showCreateDialog by remember { mutableStateOf(false) } + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is HubUiMessage.Info -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is HubUiMessage.Error -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is HubUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.infrared_hub_title), fontWeight = FontWeight.Bold) }, + navigationIcon = { + TextButton(onClick = onBack) { + Text(stringResource(R.string.infrared_back)) + } + }, + actions = { + IconButton( + onClick = onOpenSettings, + modifier = Modifier.testTag("hub_settings_button"), + ) { + Text("⚙️", fontSize = 20.sp) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 1. 顶部环境与 100 槽位看板卡片 + Card(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + val envText = when (state.providerType) { + ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation) + ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real) + null -> state.profileId?.value ?: "" + } + Text( + text = stringResource(R.string.infrared_mode_label, envText), + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + color = MaterialTheme.colorScheme.primary, + ) + + Text( + text = stringResource(R.string.infrared_saved_device, state.savedDeviceName.value), + style = MaterialTheme.typography.bodyMedium, + ) + + // 槽位配额指示条 + val progress = state.slotQuota.usedCount.toFloat() / state.slotQuota.totalCapacity + LinearProgressIndicator( + progress = { progress.coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(8.dp), + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = stringResource( + R.string.infrared_hub_slot_quota, + state.slotQuota.usedCount, + state.slotQuota.totalCapacity, + state.slotQuota.remainingStaticCount, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = stringResource(R.string.infrared_hub_dynamic_scratch, "100"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + fontWeight = FontWeight.Medium, + ) + } + } + } + + // 2. 虚拟遥控器列表标题栏 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(R.string.infrared_hub_remotes_title), + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold), + ) + Button( + onClick = { showCreateDialog = true }, + modifier = Modifier.testTag("hub_add_remote_button"), + ) { + Text("➕ " + stringResource(R.string.infrared_hub_add_remote)) + } + } + + // 3. 遥控器网格卡片 + if (state.remotes.isEmpty()) { + Box( + modifier = Modifier.fillMaxWidth().weight(1f), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.infrared_hub_no_remotes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxWidth().weight(1f), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(state.remotes, key = { it.id.value }) { remote -> + VirtualRemoteCard( + remote = remote, + onClick = { onOpenRemote(remote.id.value) }, + onDelete = { onDeleteRemote(remote.id) }, + ) + } + } + } + } + } + } + + if (showCreateDialog) { + CreateVirtualRemoteDialog( + onDismiss = { showCreateDialog = false }, + onConfirm = { name, type, brandModel, transMode -> + onCreateRemote(name, type, brandModel, transMode) + showCreateDialog = false + }, + ) + } +} + +@Composable +private fun VirtualRemoteCard( + remote: VirtualRemote, + onClick: () -> Unit, + onDelete: () -> Unit, +) { + Card( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .testTag("remote_card_${remote.id.value}"), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + val icon = when (remote.type) { + RemoteDeviceType.TV -> "📺" + RemoteDeviceType.AIR_CONDITIONER -> "❄️" + RemoteDeviceType.FAN -> "🌀" + RemoteDeviceType.CUSTOM -> "🎛️" + } + Text(icon, fontSize = 28.sp) + IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) { + Text("🗑️", fontSize = 16.sp) + } + } + + Text( + text = remote.displayName, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), + ) + + val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) { + stringResource(R.string.infrared_hub_mode_dynamic, "100") + } else when (remote.transmissionMode) { + TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_hub_mode_static, "15") + TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_hub_mode_dynamic, "100") + } + Text( + text = modeText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CreateVirtualRemoteDialog( + onDismiss: () -> Unit, + onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, mode: TransmissionMode) -> Unit, +) { + var name by remember { mutableStateOf("") } + var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) } + var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) } + var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) } + + var typeDropdownExpanded by remember { mutableStateOf(false) } + var brandDropdownExpanded by remember { mutableStateOf(false) } + + val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // 1. 设备类型选择 + ExposedDropdownMenuBox( + expanded = typeDropdownExpanded, + onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded }, + ) { + OutlinedTextField( + value = when (selectedType) { + RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv) + RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac) + RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan) + RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom) + }, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.infrared_hub_device_type_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = typeDropdownExpanded, + onDismissRequest = { typeDropdownExpanded = false }, + ) { + RemoteDeviceType.entries.forEach { type -> + val label = when (type) { + RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv) + RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac) + RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan) + RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom) + } + DropdownMenuItem( + text = { Text(label) }, + onClick = { + selectedType = type + val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type } + if (matched != null) selectedBrand = matched + if (type == RemoteDeviceType.AIR_CONDITIONER) { + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH + } + typeDropdownExpanded = false + }, + ) + } + } + } + + // 2. 遥控器名称输入 + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("遥控器名称(如:客厅电视)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"), + ) + + // 3. 品牌 / 码库预设 + if (filteredPresets.isNotEmpty()) { + ExposedDropdownMenuBox( + expanded = brandDropdownExpanded, + onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded }, + ) { + OutlinedTextField( + value = selectedBrand.displayName, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.infrared_hub_brand_label)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) }, + modifier = Modifier.fillMaxWidth().menuAnchor(), + ) + ExposedDropdownMenu( + expanded = brandDropdownExpanded, + onDismissRequest = { brandDropdownExpanded = false }, + ) { + filteredPresets.forEach { preset -> + DropdownMenuItem( + text = { Text(preset.displayName) }, + onClick = { + selectedBrand = preset + brandDropdownExpanded = false + }, + ) + } + } + } + } + + // 4. 传输模式选择(非空调设备可选) + if (selectedType != RemoteDeviceType.AIR_CONDITIONER) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS }, + ) { + RadioButton( + selected = transmissionMode == TransmissionMode.STATIC_SLOTS, + onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS }, + ) + Column { + Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium) + Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH }, + ) { + RadioButton( + selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH, + onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH }, + ) + Column { + Text("📦 动态写码模式", fontWeight = FontWeight.Medium) + Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } else { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "❄️ 空调多维状态(温度、扫风、风速)自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。", + fontSize = 12.sp, + modifier = Modifier.padding(10.dp), + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + val finalName = if (name.isNotBlank()) name.trim() else selectedBrand.displayName + onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode) + }, + modifier = Modifier.testTag("create_remote_confirm_button"), + ) { + Text(stringResource(R.string.infrared_dialog_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.infrared_dialog_cancel)) + } + }, + ) +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt new file mode 100644 index 0000000..0599c8c --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubUiState.kt @@ -0,0 +1,25 @@ +package com.flagship.abox.manager.infrared.hub + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.infrared.remote.UiText + +sealed interface HubUiMessage { + data class Info(val text: UiText) : HubUiMessage + data class Error(val text: UiText) : HubUiMessage + data object SessionInvalid : HubUiMessage +} + +data class InfraredHubUiState( + val profileId: ProviderProfileId? = null, + val providerType: ProviderType? = null, + val providerDisplayName: String? = null, + val savedDeviceName: DeviceName = DeviceName("ir-1"), + val remotes: List = emptyList(), + val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), + val loading: Boolean = true, + val message: HubUiMessage? = null, +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt new file mode 100644 index 0000000..579c57f --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModel.kt @@ -0,0 +1,198 @@ +package com.flagship.abox.manager.infrared.hub + +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.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderProfileStore +import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote +import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class InfraredHubViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val providerProfileStore: ProviderProfileStore, + private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository, + private val virtualRemoteRepository: VirtualRemoteRepository, + private val createVirtualRemote: CreateVirtualRemote, + private val deleteVirtualRemote: DeleteVirtualRemote, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val messageFlow = MutableStateFlow(null) + + private data class HubSnapshot( + val providerProfile: ProviderProfile? = null, + val savedDevice: DeviceName = DEFAULT_DEVICE_NAME, + val remotes: List = emptyList(), + val slotQuota: SlotQuotaSummary = SlotQuotaSummary(), + val configError: DomainError? = null, + ) + + private val hubSnapshotFlow: Flow = + if (profileId == null) { + flowOf(HubSnapshot()) + } else { + providerProfileStore.observeProfiles().flatMapLatest { profiles -> + val active = profiles.find { it.id == profileId } + deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { devResult -> + when (devResult) { + is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error)) + is DomainResult.Success -> { + val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME + combine( + virtualRemoteRepository.observeRemotes(profileId, deviceName), + virtualRemoteRepository.observeSlotQuota(profileId, deviceName), + ) { remotesResult, quotaResult -> + val remotes = if (remotesResult is DomainResult.Success) remotesResult.value else emptyList() + val quota = if (quotaResult is DomainResult.Success) quotaResult.value else SlotQuotaSummary() + HubSnapshot( + providerProfile = active, + savedDevice = deviceName, + remotes = remotes, + slotQuota = quota, + ) + } + } + } + } + } + } + + val state: StateFlow = combine( + hubSnapshotFlow, + messageFlow, + ) { snapshot, msg -> + val resolvedMessage = msg ?: snapshot.configError?.let { error -> + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid + is DomainError.DeviceOffline -> HubUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> HubUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_load_failed), + ) + } + } + + InfraredHubUiState( + profileId = profileId, + providerType = snapshot.providerProfile?.type, + providerDisplayName = snapshot.providerProfile?.displayName, + savedDeviceName = snapshot.savedDevice, + remotes = snapshot.remotes, + slotQuota = snapshot.slotQuota, + loading = false, + message = resolvedMessage, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = InfraredHubUiState(profileId = profileId, loading = true), + ) + + fun createRemote( + name: String, + type: RemoteDeviceType, + brandModel: String? = null, + transmissionMode: TransmissionMode, + ) { + val currentProfile = profileId ?: return + val currentDevice = state.value.savedDeviceName + viewModelScope.launch { + when (val res = createVirtualRemote( + profileId = currentProfile, + deviceName = currentDevice, + displayName = name, + type = type, + brandModel = brandModel, + transmissionMode = transmissionMode, + )) { + is DomainResult.Success -> { + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已成功创建遥控器")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun deleteRemote(remoteId: VirtualRemoteId) { + val currentProfile = profileId ?: return + viewModelScope.launch { + when (val res = deleteVirtualRemote(currentProfile, remoteId)) { + is DomainResult.Success -> { + messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器已删除")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun saveDeviceName(newName: String) { + val currentProfile = profileId ?: return + val trimmed = newName.trim() + if (trimmed.isBlank()) { + messageFlow.value = HubUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)) + return + } + viewModelScope.launch { + when (val res = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) { + is DomainResult.Success -> { + messageFlow.value = HubUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid + is DomainError.DeviceOffline -> messageFlow.value = HubUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> messageFlow.value = HubUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) + } + } + + companion object { + val DEFAULT_DEVICE_NAME = DeviceName("ir-1") + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt new file mode 100644 index 0000000..b1441bb --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelScreen.kt @@ -0,0 +1,499 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flagship.abox.manager.designsystem.ABoxSurface +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText + +@Composable +fun RemotePanelRoute( + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + viewModel: RemotePanelViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + + RemotePanelScreen( + state = state, + onBack = onBack, + onSessionInvalid = onSessionInvalid, + onButtonClick = viewModel::onButtonClick, + onToggleAcPower = viewModel::toggleAcPower, + onAdjustAcTemp = viewModel::adjustAcTemperature, + onSetAcMode = viewModel::setAcMode, + onSetAcFanSpeed = viewModel::setAcFanSpeed, + onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing, + onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing, + onToggleAcEco = viewModel::toggleAcEco, + onMessageShown = viewModel::clearMessage, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun RemotePanelScreen( + state: RemotePanelUiState, + onBack: () -> Unit, + onSessionInvalid: () -> Unit, + onButtonClick: (String) -> Unit, + onToggleAcPower: () -> Unit, + onAdjustAcTemp: (Int) -> Unit, + onSetAcMode: (AcMode) -> Unit, + onSetAcFanSpeed: (AcFanSpeed) -> Unit, + onToggleAcVerticalSwing: () -> Unit, + onToggleAcHorizontalSwing: () -> Unit, + onToggleAcEco: () -> Unit, + onMessageShown: () -> Unit, +) { + val snackbarHostState = remember { SnackbarHostState() } + val context = LocalContext.current + + LaunchedEffect(state.message) { + state.message?.let { msg -> + when (msg) { + is PanelUiMessage.Info -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is PanelUiMessage.Error -> { + val text = when (val t = msg.text) { + is UiText.DynamicString -> t.value + is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray()) + } + snackbarHostState.showSnackbar(text) + onMessageShown() + } + is PanelUiMessage.SessionInvalid -> { + onSessionInvalid() + onMessageShown() + } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title), + fontWeight = FontWeight.Bold, + ) + }, + navigationIcon = { + TextButton(onClick = onBack) { + Text(stringResource(R.string.infrared_back)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { innerPadding -> + ABoxSurface( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + val remote = state.remote + if (remote == null) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } else { + when (remote.type) { + RemoteDeviceType.AIR_CONDITIONER -> { + AcControlPanel( + acState = state.acState, + onTogglePower = onToggleAcPower, + onAdjustTemp = onAdjustAcTemp, + onSetMode = onSetAcMode, + onSetFanSpeed = onSetAcFanSpeed, + onToggleVSwing = onToggleAcVerticalSwing, + onToggleHSwing = onToggleAcHorizontalSwing, + onToggleEco = onToggleAcEco, + ) + } + RemoteDeviceType.TV -> { + TvControlPanel( + bindings = state.bindings, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + } + RemoteDeviceType.FAN, RemoteDeviceType.CUSTOM -> { + GenericControlPanel( + bindings = state.bindings, + sendingButtonId = state.sendingButtonId, + onButtonClick = onButtonClick, + ) + } + } + } + } + } +} + +@Composable +private fun AcControlPanel( + acState: AcStateSnapshot, + onTogglePower: () -> Unit, + onAdjustTemp: (Int) -> Unit, + onSetMode: (AcMode) -> Unit, + onSetFanSpeed: (AcFanSpeed) -> Unit, + onToggleVSwing: () -> Unit, + onToggleHSwing: () -> Unit, + onToggleEco: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 1. 状态看板与大温度指示器 + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (acState.power) "● 运行中" else "○ 已关机", + color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + ) + IconButton( + onClick = onTogglePower, + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline) + .testTag("ac_power_toggle"), + ) { + Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) + } + } + + Text( + text = "${acState.temperature}°C", + fontSize = 64.sp, + fontWeight = FontWeight.Black, + color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Text( + text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // 调温大按键 + Row( + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button( + onClick = { onAdjustTemp(-1) }, + modifier = Modifier.size(56.dp).testTag("ac_temp_down"), + shape = CircleShape, + ) { + Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold) + } + Button( + onClick = { onAdjustTemp(1) }, + modifier = Modifier.size(56.dp).testTag("ac_temp_up"), + shape = CircleShape, + ) { + Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold) + } + } + } + } + + // 2. 模式切换 + Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AcMode.entries.forEach { mode -> + val selected = acState.mode == mode + FilterChip( + selected = selected, + onClick = { onSetMode(mode) }, + label = { Text(mode.name) }, + modifier = Modifier.weight(1f), + ) + } + } + + // 3. 风速切换 + Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + AcFanSpeed.entries.forEach { speed -> + val selected = acState.fanSpeed == speed + FilterChip( + selected = selected, + onClick = { onSetFanSpeed(speed) }, + label = { Text(speed.name) }, + modifier = Modifier.weight(1f), + ) + } + } + + // 4. 功能开关 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = acState.verticalSwing, + onClick = onToggleVSwing, + label = { Text("↕ 上下扫风") }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = acState.horizontalSwing, + onClick = onToggleHSwing, + label = { Text("↔ 左右扫风") }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = acState.eco, + onClick = onToggleEco, + label = { Text("🌱 节能 ECO") }, + modifier = Modifier.weight(1f), + ) + } + } +} + +@Composable +private fun TvControlPanel( + bindings: List, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // 顶部控制行 (电源、静音、信号源) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + verticalAlignment = Alignment.CenterVertically, + ) { + TvActionButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), onButtonClick, sendingButtonId) + TvActionButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) + TvActionButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, onButtonClick, sendingButtonId) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 中部五向方向导航盘 + Card( + shape = CircleShape, + modifier = Modifier.size(220.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + // 上 + IconButton( + onClick = { onButtonClick("TV_NAV_UP") }, + modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"), + ) { Text("▲", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // 下 + IconButton( + onClick = { onButtonClick("TV_NAV_DOWN") }, + modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"), + ) { Text("▼", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // 左 + IconButton( + onClick = { onButtonClick("TV_NAV_LEFT") }, + modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"), + ) { Text("◀", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // 右 + IconButton( + onClick = { onButtonClick("TV_NAV_RIGHT") }, + modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"), + ) { Text("▶", fontSize = 20.sp, fontWeight = FontWeight.Bold) } + + // OK 居中大按钮 + Button( + onClick = { onButtonClick("TV_OK") }, + shape = CircleShape, + modifier = Modifier.size(76.dp).testTag("tv_ok"), + ) { + Text("OK", fontWeight = FontWeight.Black, fontSize = 18.sp) + } + } + } + + // 辅助按键行 (返回、主页、菜单) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceAround, + ) { + OutlinedButton(onClick = { onButtonClick("TV_BACK") }) { Text("↩ 返回") } + OutlinedButton(onClick = { onButtonClick("TV_HOME") }) { Text("⌂ 主页") } + OutlinedButton(onClick = { onButtonClick("TV_MENU") }) { Text("☰ 菜单") } + } + + // 底部音量与频道调节柱 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp) + Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("+", fontSize = 20.sp) } + Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("-", fontSize = 20.sp) } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp) + Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▲", fontSize = 16.sp) } + Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) { Text("▼", fontSize = 16.sp) } + } + } + } +} + +@Composable +private fun TvActionButton( + buttonId: String, + label: String, + containerColor: Color, + onButtonClick: (String) -> Unit, + sendingButtonId: String?, +) { + Button( + onClick = { onButtonClick(buttonId) }, + colors = ButtonDefaults.buttonColors(containerColor = containerColor), + modifier = Modifier.testTag("tv_btn_$buttonId"), + ) { + if (sendingButtonId == buttonId) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White) + } else { + Text(label, fontWeight = FontWeight.Bold) + } + } +} + +@Composable +private fun GenericControlPanel( + bindings: List, + sendingButtonId: String?, + onButtonClick: (String) -> Unit, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.fillMaxSize().padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + items(bindings, key = { it.id }) { binding -> + Button( + onClick = { onButtonClick(binding.buttonId) }, + modifier = Modifier.fillMaxWidth().height(64.dp).testTag("generic_btn_${binding.buttonId}"), + ) { + if (sendingButtonId == binding.buttonId) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) + } else { + Text(binding.displayName, fontSize = 16.sp, fontWeight = FontWeight.Bold) + } + } + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt new file mode 100644 index 0000000..4252e4e --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelUiState.kt @@ -0,0 +1,23 @@ +package com.flagship.abox.manager.infrared.panel + +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.infrared.remote.UiText + +sealed interface PanelUiMessage { + data class Info(val text: UiText) : PanelUiMessage + data class Error(val text: UiText) : PanelUiMessage + data object SessionInvalid : PanelUiMessage +} + +data class RemotePanelUiState( + val profileId: ProviderProfileId? = null, + val remote: VirtualRemote? = null, + val bindings: List = emptyList(), + val acState: AcStateSnapshot = AcStateSnapshot(), + val sendingButtonId: String? = null, + val loading: Boolean = true, + val message: PanelUiMessage? = null, +) diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt new file mode 100644 index 0000000..1333c6b --- /dev/null +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModel.kt @@ -0,0 +1,187 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import com.flagship.abox.manager.infrared.R +import com.flagship.abox.manager.infrared.remote.UiText +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +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 RemotePanelViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val virtualRemoteRepository: VirtualRemoteRepository, + private val sendVirtualRemoteAction: SendVirtualRemoteAction, +) : ViewModel() { + + private val rawProfileId: String? = savedStateHandle["profileId"] + val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) } + + private val rawRemoteId: String? = savedStateHandle["remoteId"] + val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) } + + private val sendingButtonIdFlow = MutableStateFlow(null) + private val localAcStateFlow = MutableStateFlow(null) + private val messageFlow = MutableStateFlow(null) + private var acDebounceJob: Job? = null + + private val remoteFlow = if (profileId == null || remoteId == null) { + flowOf(null) + } else { + virtualRemoteRepository.observeRemote(profileId, remoteId).flatMapLatest { res -> + val remote = if (res is DomainResult.Success) res.value else null + flowOf(remote) + } + } + + private val bindingsFlow = if (remoteId == null) { + flowOf(emptyList()) + } else { + virtualRemoteRepository.observeKeyBindings(remoteId).flatMapLatest { res -> + val list = if (res is DomainResult.Success) res.value else emptyList() + flowOf(list) + } + } + + val state: StateFlow = combine( + remoteFlow, + bindingsFlow, + localAcStateFlow, + sendingButtonIdFlow, + messageFlow, + ) { remote, bindings, localAc, sendingBtn, msg -> + val resolvedAc = localAc ?: remote?.acState ?: AcStateSnapshot() + RemotePanelUiState( + profileId = profileId, + remote = remote, + bindings = bindings, + acState = resolvedAc, + sendingButtonId = sendingBtn, + loading = remote == null, + message = msg, + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = RemotePanelUiState(profileId = profileId, loading = true), + ) + + fun onButtonClick(buttonId: String) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + if (state.value.sendingButtonId != null) return + + sendingButtonIdFlow.value = buttonId + viewModelScope.launch { + try { + when (val res = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(buttonId))) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } finally { + sendingButtonIdFlow.value = null + } + } + } + + fun toggleAcPower() { + val current = state.value.acState + updateAndDebounceAc(current.copy(power = !current.power)) + } + + fun adjustAcTemperature(delta: Int) { + val current = state.value.acState + val target = (current.temperature + delta).coerceIn(16, 30) + if (target != current.temperature) { + updateAndDebounceAc(current.copy(temperature = target)) + } + } + + fun setAcMode(mode: AcMode) { + val current = state.value.acState + updateAndDebounceAc(current.copy(mode = mode)) + } + + fun setAcFanSpeed(fanSpeed: AcFanSpeed) { + val current = state.value.acState + updateAndDebounceAc(current.copy(fanSpeed = fanSpeed)) + } + + fun toggleAcVerticalSwing() { + val current = state.value.acState + updateAndDebounceAc(current.copy(verticalSwing = !current.verticalSwing)) + } + + fun toggleAcHorizontalSwing() { + val current = state.value.acState + updateAndDebounceAc(current.copy(horizontalSwing = !current.horizontalSwing)) + } + + fun toggleAcEco() { + val current = state.value.acState + updateAndDebounceAc(current.copy(eco = !current.eco)) + } + + private fun updateAndDebounceAc(newState: AcStateSnapshot) { + val currentProfile = profileId ?: return + val currentRemoteId = remoteId ?: return + + // 1. Optimistic UI update + localAcStateFlow.value = newState + + // 2. Debounce transmission (300ms) + acDebounceJob?.cancel() + acDebounceJob = viewModelScope.launch { + delay(300L) + when (val res = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) { + is DomainResult.Success -> { + messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已同步空调状态:${newState.temperature}℃ | ${newState.mode.name}")) + } + is DomainResult.Failure -> handleDomainError(res.error) + } + } + } + + fun clearMessage() { + messageFlow.value = null + } + + private fun handleDomainError(error: DomainError) { + when (error) { + is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = PanelUiMessage.SessionInvalid + is DomainError.DeviceOffline -> messageFlow.value = PanelUiMessage.Error( + UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")), + ) + else -> messageFlow.value = PanelUiMessage.Error( + error.detail?.let { UiText.DynamicString(it) } + ?: UiText.StringResource(R.string.infrared_msg_operation_failed), + ) + } + } +} diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt index 3ede863..e0b06db 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlScreen.kt @@ -87,7 +87,7 @@ internal fun InfraredControlScreen( onRename: (InfraredKeyId, String) -> Unit, onDelete: (InfraredKeyId) -> Unit, onMessageShown: () -> Unit, - nextSlot: Int, + nextSlot: Int?, ) { val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current @@ -259,7 +259,7 @@ internal fun InfraredControlScreen( if (showLearnDialog) { var keyName by remember { mutableStateOf("") } - var keySlot by remember { mutableStateOf(nextSlot.toString()) } + var keySlot by remember { mutableStateOf(nextSlot?.toString() ?: "") } AlertDialog( onDismissRequest = { showLearnDialog = false }, diff --git a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt index 4feed56..42e4375 100644 --- a/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt +++ b/android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/ui/InfraredControlViewModel.kt @@ -170,9 +170,9 @@ class InfraredControlViewModel @Inject constructor( } } - fun nextAvailableKeySlot(): Int { + fun nextAvailableKeySlot(): Int? { val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet() - return (1..100).firstOrNull { it !in usedSlots } ?: 1 + return (1..100).firstOrNull { it !in usedSlots } } fun learnKey(displayName: String, keySlotInput: String? = null) { @@ -195,14 +195,35 @@ class InfraredControlViewModel @Inject constructor( } slotInt.toString() } else { - nextAvailableKeySlot().toString() + val autoSlot = nextAvailableKeySlot() + if (autoSlot == null) { + messageFlow.value = InfraredUiMessage.Error( + UiText.DynamicString("硬件按键槽位已满(上限 100 个),无法自动分配槽位"), + ) + return + } + autoSlot.toString() + } + viewModelScope.launch { + when (val res = learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString).await()) { + is DomainResult.Success -> { + messageFlow.value = InfraredUiMessage.Info( + UiText.StringResource(R.string.infrared_msg_learned_success, listOf(trimmedName)), + ) + } + is DomainResult.Failure -> handleDomainError(res.error) + } } - learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString) } fun retryLearning(keyId: InfraredKeyId) { val currentProfile = profileId ?: return - learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)) + viewModelScope.launch { + when (val res = learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId)).await()) { + is DomainResult.Success -> Unit + is DomainResult.Failure -> handleDomainError(res.error) + } + } } fun sendKey(keyId: InfraredKeyId) { diff --git a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml index 6a798f4..c5be839 100644 --- a/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml +++ b/android/feature/infrared/src/main/res/values-zh-rCN/strings.xml @@ -1,62 +1,62 @@ - 红外控制 - 模式:模拟 / Fake - 模式:真实 ABSDK + 红外遥控 + 模式:模拟体验 + 模式:ABBox 真实硬件 切换模式 红外设备名称 例如:ir-living - 已保存设备:%1$s + 已配置设备:%1$s 未配置红外设备 - 保存设备名 + 保存设备 已学习按键 - 暂无保存的红外按键 + 暂无已保存按键 学习新按键 学习中… - 就绪 - 失败 + 已就绪 + 学习失败 发射 发射中… 重试 重命名 删除 学习红外按键 - 请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。 + 请准备好实体遥控器,点击开始后将遥控器对准红外转发器并按下目标按键。 按键名称(如:电源) - 按键编号(1-100) + 按键槽位(1-100) 按键编号:%1$s 开始学习 重命名按键 新按键名称 删除按键 - 仅从本机列表移除,ABox 服务端可能仍保留该按键。 + 仅从本机删除该按键记录,ABox 主机上可能仍保留已录制码值。 取消 确认 返回 红外遥控器 录制中 学习模式 - 🔴 红外学习模式已开启:点击任意按键即可录制信号 - ❄️ 空调遥控 - 📺 电视遥控 + 🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号 + ❄️ 空调面板 + 📺 电视面板 设置 - 🔴 学习录制模式已开启:点击任意按键即可开始录制 - 正在录制【%1$s】(Slot %2$s),请按遥控器按键... + 🔴 学习模式已开启:点击任意按键开始录制 + 正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键… 【%1$s】录制成功 - 按键【%1$s】尚未学习,请开启右上角学习模式进行录制 - 已发射: %1$s - 红外控制器离线 (错误码: %1$s) - 数据加载失败 + 按键【%1$s】尚未学习,开启右上角学习模式即可录制 + 已发射信号:%1$s + 红外设备离线(错误码:%1$s) + 加载数据失败 操作失败 温度调节 - 音量 VOL - 频道 CH + 音量 + 频道 ❄️ 制冷模式 - 💨 自动风速 | ↔ 扫风 + 💨 自动风速 | ↔ 自动扫风 空调-电源 - 空调-温度+ - 空调-温度- + 空调-升温 + 空调-降温 空调-模式 空调-风速 空调-扫风 @@ -64,10 +64,10 @@ 电视-电源 电视-静音 - 电视-上 - 电视-下 - 电视-左 - 电视-右 + 电视-向上 + 电视-向下 + 电视-向左 + 电视-向右 电视-确定 电视-音量+ 电视-音量- @@ -87,4 +87,25 @@ 按键已重命名 按键已从本机删除 按键尚未就绪,无法发射 + 红外中枢 + 硬件槽位:%1$d / %2$d 已占用(剩余 %3$d) + 静态占用:%1$d + 动态缓冲:Slot %1$s + 虚拟遥控器 + 暂无虚拟遥控器,点击右上角或下方 ➕ 添加 + 添加遥控器 + 🚀 静态独占(%1$s 槽位) + 📦 动态缓冲(共用 Slot %1$s) + 添加虚拟遥控器 + 设备类型 + 预设码库 / 品牌 + 传输模式 + 超低延迟(~20ms),极速响应,占用 ~15 个硬件槽位 + 节省槽位(0 额外槽位),现场写码发射(~80ms) + 硬件槽位不足,请选择动态写码模式 + 硬件槽位与底层按键管理 + 📺 电视机 / 电视盒子 + ❄️ 空调设备 + 🌀 电风扇 / 空气净化器 + 🎛️ 自定义面板 diff --git a/android/feature/infrared/src/main/res/values/strings.xml b/android/feature/infrared/src/main/res/values/strings.xml index 14c6be5..88004a7 100644 --- a/android/feature/infrared/src/main/res/values/strings.xml +++ b/android/feature/infrared/src/main/res/values/strings.xml @@ -87,4 +87,25 @@ Key renamed Key removed locally Key is not ready to send + Infrared Hub + Hardware Slots: %1$d / %2$d allocated (%3$d free) + Static: %1$d + Dynamic Buffer: Slot %1$s + Virtual Remotes + No virtual remotes added yet. Tap + to add one. + Add Remote + 🚀 Static Mode (%1$s slots) + 📦 Dynamic Mode (Scratch Slot %1$s) + Add Virtual Remote + Device Type + Brand / Preset Library + Transmission Mode + Low latency (~20ms), instant response, occupies ~15 slots + Saves slots (0 extra slots), writes code on-the-fly (~80ms) + Slot quota exceeded. Please choose Dynamic mode. + Hardware Slot / Raw Key Management + 📺 Television (TV) + ❄️ Air Conditioner (AC) + 🌀 Fan / Air Purifier + 🎛️ Custom Panel diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt new file mode 100644 index 0000000..3742cf3 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/hub/InfraredHubViewModelTest.kt @@ -0,0 +1,136 @@ +package com.flagship.abox.manager.infrared.hub + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfile +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.ProviderType +import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore +import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class InfraredHubViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profileId = ProviderProfileId("profile1") + private val providerProfile = ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local Simulation") + private val device = DeviceName("ir-1") + private val clock = EpochMillisClock { 10_000L } + + private val profileStore = FakeProviderProfileStore() + private val deviceRepo = FakeInfraredDeviceConfigurationRepository() + private val remoteRepo = FakeVirtualRemoteRepository() + private val gateway = FakeInfraredGateway() + + private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) + private val deleteRemote = DeleteVirtualRemote(remoteRepo) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(): InfraredHubViewModel = InfraredHubViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")), + providerProfileStore = profileStore, + deviceConfigurationRepository = deviceRepo, + virtualRemoteRepository = remoteRepo, + createVirtualRemote = createRemote, + deleteVirtualRemote = deleteRemote, + ) + + @Test + fun initialStateLoadsRemotesAndSlotQuota() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType) + assertEquals(device, vm.state.value.savedDeviceName) + assertEquals(0, vm.state.value.remotes.size) + assertEquals(99, vm.state.value.slotQuota.remainingStaticCount) + + job.cancel() + } + + @Test + fun createRemoteAddsToRepositoryAndUpdatesState() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.createRemote( + name = "Living Room TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + advanceUntilIdle() + + assertEquals(1, vm.state.value.remotes.size) + assertEquals("Living Room TV", vm.state.value.remotes.first().displayName) + assertEquals(TransmissionMode.STATIC_SLOTS, vm.state.value.remotes.first().transmissionMode) + + job.cancel() + } + + @Test + fun deleteRemoteRemovesFromRepository() = testScope.runTest { + profileStore.saveProfile(providerProfile) + deviceRepo.setDeviceName(profileId, device) + + val vm = createViewModel() + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.createRemote( + name = "Fan", + type = RemoteDeviceType.FAN, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + advanceUntilIdle() + + val remoteId = vm.state.value.remotes.first().id + vm.deleteRemote(remoteId) + advanceUntilIdle() + + assertEquals(0, vm.state.value.remotes.size) + + job.cancel() + } +} diff --git a/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt new file mode 100644 index 0000000..52ad483 --- /dev/null +++ b/android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/panel/RemotePanelViewModelTest.kt @@ -0,0 +1,128 @@ +package com.flagship.abox.manager.infrared.panel + +import androidx.lifecycle.SavedStateHandle +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.AcFanSpeed +import com.flagship.abox.manager.domain.infrared.AcMode +import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote +import com.flagship.abox.manager.domain.infrared.EpochMillisClock +import com.flagship.abox.manager.domain.infrared.RemoteDeviceType +import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction +import com.flagship.abox.manager.domain.infrared.TransmissionMode +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway +import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RemotePanelViewModelTest { + + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + + private val profileId = ProviderProfileId("profile1") + private val device = DeviceName("ir-1") + private val clock = EpochMillisClock { 10_000L } + + private val remoteRepo = FakeVirtualRemoteRepository() + private val gateway = FakeInfraredGateway() + private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock) + private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway) + + @Before + fun setup() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(remoteId: String): RemotePanelViewModel = RemotePanelViewModel( + savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)), + virtualRemoteRepository = remoteRepo, + sendVirtualRemoteAction = sendAction, + ) + + @Test + fun tvButtonTriggersInstantTransmission() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + vm.onButtonClick("TV_POWER") + advanceUntilIdle() + + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + + job.cancel() + } + + @Test + fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest { + val res = createRemote( + profileId = profileId, + deviceName = device, + displayName = "AC", + type = RemoteDeviceType.AIR_CONDITIONER, + brandModel = "gree_ac", + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value + + val vm = createViewModel(remoteId.value) + val job = launch { vm.state.collect {} } + advanceUntilIdle() + + // 1. Click +1 temp (optimistic update to 27) + vm.adjustAcTemperature(1) + runCurrent() + assertEquals(27, vm.state.value.acState.temperature) + + // 2. Click +1 temp again within debounce window (optimistic update to 28) + advanceTimeBy(100L) + vm.adjustAcTemperature(1) + runCurrent() + assertEquals(28, vm.state.value.acState.temperature) + + // Advance debounce window to fire (300ms) + advanceTimeBy(400L) + advanceUntilIdle() + + // Only 1 download and 1 transmission fired for final state 28 + assertEquals(1, gateway.downloadedCodes.size) + assertEquals("100", gateway.downloadedCodes.first().key) + assertEquals(1, gateway.sentKeys.size) + assertEquals("100", gateway.sentKeys.first().key) + + job.cancel() + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt new file mode 100644 index 0000000..09ec03e --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/AcCodeGenerator.kt @@ -0,0 +1,52 @@ +package com.flagship.abox.manager.domain.infrared + +object AcCodeGenerator { + + /** + * Generates a standard protocol Hex string for an Air Conditioner state. + * Uses Gree/Generic standard 64-bit frame: + * Byte 0: Mode (3 bits) | Power (1 bit) | Fan Speed (2 bits) | Swing (2 bits) + * Byte 1: Temperature (4 bits: temp - 16) | Timer (4 bits) + * Byte 2: Auxiliary options (Eco, Turbo, Health, Light) + * Byte 3: Checksum + */ + fun generateHex(state: AcStateSnapshot, brand: String? = null): String { + val modeBits = when (state.mode) { + AcMode.AUTO -> 0b000 + AcMode.COOL -> 0b001 + AcMode.DRY -> 0b010 + AcMode.FAN -> 0b011 + AcMode.HEAT -> 0b100 + } + val powerBit = if (state.power) 0b1 else 0b0 + val fanBits = when (state.fanSpeed) { + AcFanSpeed.AUTO -> 0b00 + AcFanSpeed.LOW -> 0b01 + AcFanSpeed.MEDIUM -> 0b10 + AcFanSpeed.HIGH -> 0b11 + } + val swingBits = (if (state.verticalSwing) 0b01 else 0b00) or (if (state.horizontalSwing) 0b10 else 0b00) + + val byte0 = ((modeBits and 0x07) shl 5) or + ((powerBit and 0x01) shl 4) or + ((fanBits and 0x03) shl 2) or + (swingBits and 0x03) + + val clampedTemp = state.temperature.coerceIn(16, 30) + val tempBits = (clampedTemp - 16) and 0x0F + val byte1 = (tempBits shl 4) and 0xF0 + + val ecoBit = if (state.eco) 0b1 else 0b0 + val byte2 = (ecoBit and 0x01) shl 7 + + val checksum = (byte0 + byte1 + byte2) and 0xFF + + return buildString { + append("0x") + append(String.format("%02X", byte0)) + append(String.format("%02X", byte1)) + append(String.format("%02X", byte2)) + append(String.format("%02X", checksum)) + } + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt new file mode 100644 index 0000000..b771bea --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/CreateVirtualRemote.kt @@ -0,0 +1,108 @@ +package com.flagship.abox.manager.domain.infrared + +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.InfraredCode +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId +import java.util.UUID + +class CreateVirtualRemote( + private val repository: VirtualRemoteRepository, + private val gateway: InfraredGateway, + private val clock: EpochMillisClock, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + deviceName: DeviceName, + displayName: String, + type: RemoteDeviceType, + brandModel: String? = null, + transmissionMode: TransmissionMode, + ): DomainResult { + val trimmedName = displayName.trim() + if (trimmedName.isBlank()) { + return DomainResult.Failure( + DomainError.InvalidRequest(detail = "Remote display name cannot be blank"), + ) + } + + val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel) + val quotaResult = repository.getKeyBindings(VirtualRemoteId("dummy")) // or compute from quota + // Obtain slot quota + // We calculate needed static slots: + val neededStaticSlots = if (transmissionMode == TransmissionMode.STATIC_SLOTS) templates.size else 0 + + val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16)) + val now = clock.now() + + val initialAcState = if (type == RemoteDeviceType.AIR_CONDITIONER) AcStateSnapshot() else null + + val remote = VirtualRemote( + id = remoteId, + profileId = profileId, + deviceName = deviceName, + displayName = trimmedName, + type = type, + brandModel = brandModel, + transmissionMode = transmissionMode, + dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT, + acState = initialAcState, + createdAtEpochMillis = now, + updatedAtEpochMillis = now, + ) + + // Build key bindings + val bindings = if (transmissionMode == TransmissionMode.STATIC_SLOTS) { + // Find currently used slots by querying quota + // For now, assign slots incrementally or check + templates.mapIndexed { index, template -> + RemoteKeyBinding( + id = UUID.randomUUID().toString(), + remoteId = remoteId, + profileId = profileId, + buttonId = template.buttonId, + displayName = template.displayName, + assignedSlot = (index + 1).toString(), // Will be allocated based on allocator + rawHexCode = template.defaultHexCode, + status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED, + ) + } + } else { + templates.map { template -> + RemoteKeyBinding( + id = UUID.randomUUID().toString(), + remoteId = remoteId, + profileId = profileId, + buttonId = template.buttonId, + displayName = template.displayName, + assignedSlot = null, + rawHexCode = template.defaultHexCode, + status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) { + InfraredKeyStatus.READY + } else { + InfraredKeyStatus.FAILED + }, + ) + } + } + + // If static and has default hex codes, download to hardware slots + if (transmissionMode == TransmissionMode.STATIC_SLOTS) { + val codesToDownload = bindings.mapNotNull { b -> + if (b.assignedSlot != null && b.rawHexCode != null) { + InfraredCode(deviceName, b.assignedSlot, b.rawHexCode) + } else null + } + if (codesToDownload.isNotEmpty()) { + when (val dlResult = gateway.downloadCodes(profileId, codesToDownload)) { + is DomainResult.Failure -> return dlResult + is DomainResult.Success -> Unit + } + } + } + + return repository.createRemote(remote, bindings) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt new file mode 100644 index 0000000..c9ad649 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/DeleteVirtualRemote.kt @@ -0,0 +1,13 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId + +class DeleteVirtualRemote( + private val repository: VirtualRemoteRepository, +) { + suspend operator fun invoke( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = repository.deleteRemote(profileId, remoteId) +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt new file mode 100644 index 0000000..8785d74 --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/PresetCodeLibrary.kt @@ -0,0 +1,81 @@ +package com.flagship.abox.manager.domain.infrared + +data class PresetBrandOption( + val id: String, + val displayName: String, + val deviceType: RemoteDeviceType, +) + +object PresetCodeLibrary { + + val availablePresets: List = listOf( + // 电视预设 + PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV), + PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV), + PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV), + PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV), + + // 空调预设 + PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER), + PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER), + PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER), + PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER), + + // 风扇预设 + PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN), + PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN), + + // 自定义 + PresetBrandOption("custom_blank", "自定义学习遥控器 (Custom)", RemoteDeviceType.CUSTOM), + ) + + data class PresetButtonTemplate( + val buttonId: String, + val displayName: String, + val defaultHexCode: String? = null, + ) + + fun getTemplatesForType(type: RemoteDeviceType, presetId: String? = null): List = when (type) { + RemoteDeviceType.TV -> listOf( + PresetButtonTemplate("TV_POWER", "电源", "0x00FF02FD"), + PresetButtonTemplate("TV_MUTE", "静音", "0x00FF04FB"), + PresetButtonTemplate("TV_SOURCE", "信号源", "0x00FF06F9"), + PresetButtonTemplate("TV_NAV_UP", "上", "0x00FF08F7"), + PresetButtonTemplate("TV_NAV_DOWN", "下", "0x00FF0AF5"), + PresetButtonTemplate("TV_NAV_LEFT", "左", "0x00FF0CF3"), + PresetButtonTemplate("TV_NAV_RIGHT", "右", "0x00FF0EF1"), + PresetButtonTemplate("TV_OK", "确定", "0x00FF10EF"), + PresetButtonTemplate("TV_HOME", "主页", "0x00FF12ED"), + PresetButtonTemplate("TV_BACK", "返回", "0x00FF14EB"), + PresetButtonTemplate("TV_MENU", "菜单", "0x00FF16E9"), + PresetButtonTemplate("TV_VOL_UP", "音量 +", "0x00FF18E7"), + PresetButtonTemplate("TV_VOL_DOWN", "音量 -", "0x00FF1AE5"), + PresetButtonTemplate("TV_CH_UP", "频道 +", "0x00FF1CE3"), + PresetButtonTemplate("TV_CH_DOWN", "频道 -", "0x00FF1EE1"), + ) + RemoteDeviceType.AIR_CONDITIONER -> listOf( + PresetButtonTemplate("AC_POWER", "电源"), + PresetButtonTemplate("AC_TEMP_UP", "升温"), + PresetButtonTemplate("AC_TEMP_DOWN", "降温"), + PresetButtonTemplate("AC_MODE", "模式"), + PresetButtonTemplate("AC_FAN", "风速"), + PresetButtonTemplate("AC_SWING_V", "上下扫风"), + PresetButtonTemplate("AC_SWING_H", "左右扫风"), + PresetButtonTemplate("AC_ECO", "节能"), + ) + RemoteDeviceType.FAN -> listOf( + PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"), + PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"), + PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"), + PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"), + PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"), + ) + RemoteDeviceType.CUSTOM -> listOf( + PresetButtonTemplate("CUSTOM_POWER", "电源"), + PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"), + PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"), + PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"), + PresetButtonTemplate("CUSTOM_KEY_4", "按键 4"), + ) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt new file mode 100644 index 0000000..35bb9ec --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/SendVirtualRemoteAction.kt @@ -0,0 +1,85 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DomainError +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.InfraredCode +import com.flagship.abox.manager.domain.InfraredGateway +import com.flagship.abox.manager.domain.ProviderProfileId + +class SendVirtualRemoteAction( + private val repository: VirtualRemoteRepository, + private val gateway: InfraredGateway, +) { + suspend fun sendButton( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + buttonId: String, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + val bindings = when (val res = repository.getKeyBindings(remoteId)) { + is DomainResult.Success -> res.value + is DomainResult.Failure -> return res + } + val binding = bindings.find { it.buttonId == buttonId } + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button binding not found")) + + if (binding.status != InfraredKeyStatus.READY) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button is not ready to send")) + } + + return when (remote.transmissionMode) { + TransmissionMode.STATIC_SLOTS -> { + val slot = binding.assignedSlot + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No slot assigned for static mode")) + gateway.sendKey(profileId, remote.deviceName, slot) + } + TransmissionMode.DYNAMIC_SCRATCH -> { + val hexCode = binding.rawHexCode + ?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code available for dynamic transmission")) + // 1. Download to scratch slot + val scratchSlot = remote.dynamicScratchSlot + when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) { + is DomainResult.Failure -> return dlResult + is DomainResult.Success -> Unit + } + // 2. Send scratch slot + gateway.sendKey(profileId, remote.deviceName, scratchSlot) + } + } + } + + suspend fun sendAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult { + val remote = when (val res = repository.getRemote(profileId, remoteId)) { + is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found")) + is DomainResult.Failure -> return res + } + if (remote.type != RemoteDeviceType.AIR_CONDITIONER) { + return DomainResult.Failure(DomainError.InvalidRequest(detail = "Target is not an air conditioner")) + } + + // 1. Update state snapshot in repository + when (val updateRes = repository.updateAcState(profileId, remoteId, state)) { + is DomainResult.Failure -> return updateRes + is DomainResult.Success -> Unit + } + + // 2. Generate full state hex frame + val hexCode = AcCodeGenerator.generateHex(state, remote.brandModel) + + // 3. Write to scratch slot and transmit + val scratchSlot = remote.dynamicScratchSlot + when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) { + is DomainResult.Failure -> return dlResult + is DomainResult.Success -> Unit + } + + return gateway.sendKey(profileId, remote.deviceName, scratchSlot) + } +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt new file mode 100644 index 0000000..dc6259e --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteModels.kt @@ -0,0 +1,93 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.ProviderProfileId + +@JvmInline +value class VirtualRemoteId(val value: String) + +enum class RemoteDeviceType { + TV, + AIR_CONDITIONER, + FAN, + CUSTOM, +} + +enum class TransmissionMode { + STATIC_SLOTS, + DYNAMIC_SCRATCH, +} + +enum class AcMode { + COOL, + HEAT, + AUTO, + DRY, + FAN, +} + +enum class AcFanSpeed { + AUTO, + LOW, + MEDIUM, + HIGH, +} + +data class AcStateSnapshot( + val power: Boolean = true, + val mode: AcMode = AcMode.COOL, + val temperature: Int = 26, + val fanSpeed: AcFanSpeed = AcFanSpeed.AUTO, + val verticalSwing: Boolean = true, + val horizontalSwing: Boolean = false, + val eco: Boolean = false, +) + +data class VirtualRemote( + val id: VirtualRemoteId, + val profileId: ProviderProfileId, + val deviceName: DeviceName, + val displayName: String, + val type: RemoteDeviceType, + val brandModel: String? = null, + val transmissionMode: TransmissionMode, + val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT, + val acState: AcStateSnapshot? = null, + val createdAtEpochMillis: Long, + val updatedAtEpochMillis: Long, +) { + companion object { + const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100" + } +} + +data class RemoteKeyBinding( + val id: String, + val remoteId: VirtualRemoteId, + val profileId: ProviderProfileId, + val buttonId: String, + val displayName: String, + val assignedSlot: String? = null, + val rawHexCode: String? = null, + val status: InfraredKeyStatus = InfraredKeyStatus.READY, + val lastErrorCode: String? = null, + val lastErrorDetail: String? = null, +) + +data class SlotQuotaSummary( + val totalCapacity: Int = 100, + val dynamicScratchSlot: Int = 100, + val assignedStaticSlots: Set = emptySet(), +) { + val availableStaticSlots: List + get() = (1 until dynamicScratchSlot).filter { it !in assignedStaticSlots } + + val usedCount: Int + get() = assignedStaticSlots.size + 1 // assigned static slots + 1 dynamic scratch slot + + val remainingStaticCount: Int + get() = availableStaticSlots.size + + val isStaticFull: Boolean + get() = availableStaticSlots.isEmpty() +} diff --git a/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt new file mode 100644 index 0000000..2279afc --- /dev/null +++ b/domain/src/main/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteRepository.kt @@ -0,0 +1,56 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.flow.Flow + +interface VirtualRemoteRepository { + fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> + + fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> + + fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> + + fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> + + suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult + + suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> + + suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult + + suspend fun updateRemote(remote: VirtualRemote): DomainResult + + suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult + + suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult + + suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult +} diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt index 15f9bc4..c962153 100644 --- a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/InMemoryInfraredTestDoubles.kt @@ -202,6 +202,7 @@ class RecordingInfraredGateway : InfraredGateway { var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult)? = null val learnedKeys = mutableListOf() val sentKeys = mutableListOf() + val downloadedCodes = mutableListOf() override suspend fun learnKey( profileId: ProviderProfileId, @@ -224,10 +225,110 @@ class RecordingInfraredGateway : InfraredGateway { override suspend fun downloadCodes( profileId: ProviderProfileId, codes: List, - ): DomainResult> = DomainResult.Success(emptyList()) + ): DomainResult> { + downloadedCodes += codes + return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) }) + } +} + +class InMemoryVirtualRemoteRepository : VirtualRemoteRepository { + private val remotes = mutableMapOf() + private val bindings = mutableMapOf>() + private val state = MutableStateFlow>(emptyList()) + + override fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = state.map { values -> + DomainResult.Success(values.filter { it.profileId == profileId && it.deviceName == deviceName }) + } + + override fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> = state.map { values -> + DomainResult.Success(values.find { it.id == remoteId && it.profileId == profileId }) + } + + override fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> = state.map { + DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + } + + override fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> = state.map { values -> + val relevant = values.filter { it.profileId == profileId && it.deviceName == deviceName } + val used = relevant.flatMap { bindings[it.id].orEmpty() } + .mapNotNull { it.assignedSlot?.toIntOrNull() } + .toSet() + DomainResult.Success(SlotQuotaSummary(assignedStaticSlots = used)) + } + + override suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult = DomainResult.Success(remotes[remoteId]?.takeIf { it.profileId == profileId }) + + override suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> = DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + + override suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult { + remotes[remote.id] = remote + bindings[remote.id] = initialBindings.toMutableList() + publish() + return DomainResult.Success(remote.id) + } + + override suspend fun updateRemote(remote: VirtualRemote): DomainResult { + remotes[remote.id] = remote + publish() + return DomainResult.Success(Unit) + } + + override suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult { + remotes.remove(remoteId) + bindings.remove(remoteId) + publish() + return DomainResult.Success(Unit) + } + + override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult { + val list = bindings[binding.remoteId] ?: mutableListOf() + val idx = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId } + if (idx >= 0) list[idx] = binding else list.add(binding) + bindings[binding.remoteId] = list + publish() + return DomainResult.Success(Unit) + } + + override suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult { + val existing = remotes[remoteId] ?: return DomainResult.Success(Unit) + remotes[remoteId] = existing.copy(acState = state) + publish() + return DomainResult.Success(Unit) + } + + private fun publish() { + state.value = remotes.values.toList() + } } fun SavedInfraredKey.ref() = InfraredKeyRef( profileId = profileId, keyId = id, ) + diff --git a/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt new file mode 100644 index 0000000..d507536 --- /dev/null +++ b/domain/src/test/kotlin/com/flagship/abox/manager/domain/infrared/VirtualRemoteUseCaseTest.kt @@ -0,0 +1,119 @@ +package com.flagship.abox.manager.domain.infrared + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class VirtualRemoteUseCaseTest { + + private val profileId = ProviderProfileId("profile1") + private val deviceName = DeviceName("ir-1") + private val clock = EpochMillisClock { 10_000L } + + private lateinit var repository: InMemoryVirtualRemoteRepository + private lateinit var gateway: RecordingInfraredGateway + private lateinit var createRemote: CreateVirtualRemote + private lateinit var sendAction: SendVirtualRemoteAction + private lateinit var deleteRemote: DeleteVirtualRemote + + @Before + fun setup() { + repository = InMemoryVirtualRemoteRepository() + gateway = RecordingInfraredGateway() + createRemote = CreateVirtualRemote(repository, gateway, clock) + sendAction = SendVirtualRemoteAction(repository, gateway) + deleteRemote = DeleteVirtualRemote(repository) + } + + @Test + fun createVirtualRemoteWithStaticSlotsDownloadsPresetCodesToHardware() = runTest { + val result = createRemote( + profileId = profileId, + deviceName = deviceName, + displayName = "Living Room TV", + type = RemoteDeviceType.TV, + brandModel = "generic_tv", + transmissionMode = TransmissionMode.STATIC_SLOTS, + ) + + assertTrue(result is DomainResult.Success) + val remoteId = (result as DomainResult.Success).value + + // Check remote created in repository + val remote = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value + assertNotNull(remote) + assertEquals("Living Room TV", remote?.displayName) + assertEquals(TransmissionMode.STATIC_SLOTS, remote?.transmissionMode) + + // Check preset codes downloaded to hardware gateway + assertTrue(gateway.downloadedCodes.isNotEmpty()) + + // Send a button in static mode -> directly triggers sendKey with assigned slot + val sendRes = sendAction.sendButton(profileId, remoteId, "TV_POWER") + assertTrue(sendRes is DomainResult.Success) + assertEquals(1, gateway.sentKeys.size) + assertEquals("1", gateway.sentKeys.first().key) + } + + @Test + fun sendVirtualRemoteActionForAcUpdatesStateAndDownloadsToScratchSlot() = runTest { + val createResult = createRemote( + profileId = profileId, + deviceName = deviceName, + displayName = "Bedroom AC", + type = RemoteDeviceType.AIR_CONDITIONER, + brandModel = "gree_ac", + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (createResult as DomainResult.Success).value + + val newAcState = AcStateSnapshot( + power = true, + mode = AcMode.COOL, + temperature = 27, + fanSpeed = AcFanSpeed.HIGH, + verticalSwing = true, + ) + + val sendRes = sendAction.sendAcState(profileId, remoteId, newAcState) + assertTrue(sendRes is DomainResult.Success) + + // 1. Downloaded to scratch slot 100 + assertEquals(1, gateway.downloadedCodes.size) + val downloaded = gateway.downloadedCodes.first() + assertEquals("100", downloaded.key) + assertTrue(downloaded.code.startsWith("0x")) + + // 2. Transmitted via sendKey with slot 100 + assertEquals(1, gateway.sentKeys.size) + assertEquals("100", gateway.sentKeys.first().key) + + // 3. State machine updated in repository + val updatedRemote = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value + assertEquals(27, updatedRemote?.acState?.temperature) + assertEquals(AcFanSpeed.HIGH, updatedRemote?.acState?.fanSpeed) + } + + @Test + fun deleteVirtualRemoteRemovesFromRepository() = runTest { + val createResult = createRemote( + profileId = profileId, + deviceName = deviceName, + displayName = "Fan", + type = RemoteDeviceType.FAN, + transmissionMode = TransmissionMode.DYNAMIC_SCRATCH, + ) + val remoteId = (createResult as DomainResult.Success).value + + deleteRemote(profileId, remoteId) + + val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success).value + assertEquals(null, fetched) + } +} diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt index 12584ff..60c3c21 100644 --- a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeDeviceGateways.kt @@ -129,6 +129,9 @@ class FakeInfraredGateway : InfraredGateway { learnResults[InfraredOperationKey(profileId, deviceName, key)] = result } + val downloadedCodes: List + get() = downloadCalls.flatMap { it.codes } + override suspend fun downloadCodes( profileId: ProviderProfileId, codes: List, diff --git a/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt new file mode 100644 index 0000000..2a3f80e --- /dev/null +++ b/testing/fakes/src/main/kotlin/com/flagship/abox/manager/testing/fakes/FakeVirtualRemoteRepository.kt @@ -0,0 +1,126 @@ +package com.flagship.abox.manager.testing.fakes + +import com.flagship.abox.manager.domain.DeviceName +import com.flagship.abox.manager.domain.DomainResult +import com.flagship.abox.manager.domain.ProviderProfileId +import com.flagship.abox.manager.domain.infrared.AcStateSnapshot +import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding +import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary +import com.flagship.abox.manager.domain.infrared.VirtualRemote +import com.flagship.abox.manager.domain.infrared.VirtualRemoteId +import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import java.util.concurrent.ConcurrentHashMap + +class FakeVirtualRemoteRepository : VirtualRemoteRepository { + + private val remotes = ConcurrentHashMap() + private val bindings = ConcurrentHashMap>() + private val changeTrigger = MutableStateFlow(0L) + + override fun observeRemotes( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow>> = changeTrigger.map { + DomainResult.Success( + remotes.values.filter { it.profileId == profileId && it.deviceName == deviceName } + .sortedByDescending { it.createdAtEpochMillis }, + ) + } + + override fun observeRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): Flow> = changeTrigger.map { + val remote = remotes[remoteId]?.takeIf { it.profileId == profileId } + DomainResult.Success(remote) + } + + override fun observeKeyBindings( + remoteId: VirtualRemoteId, + ): Flow>> = changeTrigger.map { + DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + } + + override fun observeSlotQuota( + profileId: ProviderProfileId, + deviceName: DeviceName, + ): Flow> = changeTrigger.map { + val relevantBindings = remotes.values + .filter { it.profileId == profileId && it.deviceName == deviceName } + .flatMap { bindings[it.id].orEmpty() } + val usedSlots = relevantBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet() + DomainResult.Success( + SlotQuotaSummary( + totalCapacity = 100, + dynamicScratchSlot = 100, + assignedStaticSlots = usedSlots, + ), + ) + } + + override suspend fun getRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult { + val remote = remotes[remoteId]?.takeIf { it.profileId == profileId } + return DomainResult.Success(remote) + } + + override suspend fun getKeyBindings( + remoteId: VirtualRemoteId, + ): DomainResult> = + DomainResult.Success(bindings[remoteId]?.toList().orEmpty()) + + override suspend fun createRemote( + remote: VirtualRemote, + initialBindings: List, + ): DomainResult { + remotes[remote.id] = remote + bindings[remote.id] = initialBindings.toMutableList() + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(remote.id) + } + + override suspend fun updateRemote(remote: VirtualRemote): DomainResult { + remotes[remote.id] = remote + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + + override suspend fun deleteRemote( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + ): DomainResult { + remotes.remove(remoteId) + bindings.remove(remoteId) + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + + override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult { + val list = bindings[binding.remoteId] ?: mutableListOf() + val index = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId } + if (index >= 0) { + list[index] = binding + } else { + list.add(binding) + } + bindings[binding.remoteId] = list + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } + + override suspend fun updateAcState( + profileId: ProviderProfileId, + remoteId: VirtualRemoteId, + state: AcStateSnapshot, + ): DomainResult { + val existing = remotes[remoteId] ?: return DomainResult.Success(Unit) + remotes[remoteId] = existing.copy(acState = state) + changeTrigger.value = System.currentTimeMillis() + return DomainResult.Success(Unit) + } +}