feat(infrared): refactor virtual remote hub with multi-device presets and quota management
This commit is contained in:
+106
@@ -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<List<VirtualRemoteEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM virtual_remotes
|
||||
WHERE profileId = :profileId AND id = :id
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
fun observeVirtualRemote(profileId: String, id: String): Flow<VirtualRemoteEntity?>
|
||||
|
||||
@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<List<RemoteKeyBindingEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM remote_key_bindings
|
||||
WHERE remoteId = :remoteId
|
||||
""",
|
||||
)
|
||||
suspend fun getKeyBindings(remoteId: String): List<RemoteKeyBindingEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertKeyBindings(entities: List<RemoteKeyBindingEntity>)
|
||||
|
||||
@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<List<String>>
|
||||
}
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+49
@@ -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?,
|
||||
)
|
||||
|
||||
|
||||
+213
@@ -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<DomainResult<List<VirtualRemote>>> =
|
||||
dao.observeVirtualRemotes(profileId.value, deviceName.value).map { entities ->
|
||||
DomainResult.Success(entities.map { it.toDomain() })
|
||||
}
|
||||
|
||||
override fun observeRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
): Flow<DomainResult<VirtualRemote?>> =
|
||||
dao.observeVirtualRemote(profileId.value, remoteId.value).map { entity ->
|
||||
DomainResult.Success(entity?.toDomain())
|
||||
}
|
||||
|
||||
override fun observeKeyBindings(
|
||||
remoteId: VirtualRemoteId,
|
||||
): Flow<DomainResult<List<RemoteKeyBinding>>> =
|
||||
dao.observeKeyBindings(remoteId.value).map { entities ->
|
||||
DomainResult.Success(entities.map { it.toDomain() })
|
||||
}
|
||||
|
||||
override fun observeSlotQuota(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<SlotQuotaSummary>> =
|
||||
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<VirtualRemote?> = 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<List<RemoteKeyBinding>> = 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<RemoteKeyBinding>,
|
||||
): DomainResult<VirtualRemoteId> = 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<Unit> = 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<Unit> = 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<Unit> = 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<Unit> = 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,
|
||||
)
|
||||
+37
-1
@@ -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)
|
||||
}
|
||||
|
||||
+509
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+25
@@ -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<VirtualRemote> = emptyList(),
|
||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||
val loading: Boolean = true,
|
||||
val message: HubUiMessage? = null,
|
||||
)
|
||||
+198
@@ -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<HubUiMessage?>(null)
|
||||
|
||||
private data class HubSnapshot(
|
||||
val providerProfile: ProviderProfile? = null,
|
||||
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
||||
val remotes: List<VirtualRemote> = emptyList(),
|
||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||
val configError: DomainError? = null,
|
||||
)
|
||||
|
||||
private val hubSnapshotFlow: Flow<HubSnapshot> =
|
||||
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<InfraredHubUiState> = 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")
|
||||
}
|
||||
}
|
||||
+499
@@ -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<RemoteKeyBinding>,
|
||||
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<RemoteKeyBinding>,
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
@@ -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<RemoteKeyBinding> = emptyList(),
|
||||
val acState: AcStateSnapshot = AcStateSnapshot(),
|
||||
val sendingButtonId: String? = null,
|
||||
val loading: Boolean = true,
|
||||
val message: PanelUiMessage? = null,
|
||||
)
|
||||
+187
@@ -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<String?>(null)
|
||||
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
|
||||
private val messageFlow = MutableStateFlow<PanelUiMessage?>(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<RemotePanelUiState> = 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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 },
|
||||
|
||||
+26
-5
@@ -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) {
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
<resources>
|
||||
<string name="infrared_title">红外控制</string>
|
||||
<string name="infrared_mode_fake">模式:模拟 / Fake</string>
|
||||
<string name="infrared_mode_real">模式:真实 ABSDK</string>
|
||||
<string name="infrared_title">红外遥控</string>
|
||||
<string name="infrared_mode_fake">模式:模拟体验</string>
|
||||
<string name="infrared_mode_real">模式:ABBox 真实硬件</string>
|
||||
<string name="infrared_switch_mode">切换模式</string>
|
||||
<string name="infrared_device_name_label">红外设备名称</string>
|
||||
<string name="infrared_device_name_hint">例如:ir-living</string>
|
||||
<string name="infrared_saved_device">已保存设备:%1$s</string>
|
||||
<string name="infrared_saved_device">已配置设备:%1$s</string>
|
||||
<string name="infrared_no_device_saved">未配置红外设备</string>
|
||||
<string name="infrared_save_device">保存设备名</string>
|
||||
<string name="infrared_save_device">保存设备</string>
|
||||
<string name="infrared_keys_title">已学习按键</string>
|
||||
<string name="infrared_no_keys">暂无保存的红外按键</string>
|
||||
<string name="infrared_no_keys">暂无已保存按键</string>
|
||||
<string name="infrared_learn_new_key">学习新按键</string>
|
||||
<string name="infrared_status_learning">学习中…</string>
|
||||
<string name="infrared_status_ready">就绪</string>
|
||||
<string name="infrared_status_failed">失败</string>
|
||||
<string name="infrared_status_ready">已就绪</string>
|
||||
<string name="infrared_status_failed">学习失败</string>
|
||||
<string name="infrared_send">发射</string>
|
||||
<string name="infrared_sending">发射中…</string>
|
||||
<string name="infrared_retry">重试</string>
|
||||
<string name="infrared_rename">重命名</string>
|
||||
<string name="infrared_delete">删除</string>
|
||||
<string name="infrared_learn_dialog_title">学习红外按键</string>
|
||||
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string>
|
||||
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器,点击开始后将遥控器对准红外转发器并按下目标按键。</string>
|
||||
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
|
||||
<string name="infrared_learn_dialog_slot_label">按键编号(1-100)</string>
|
||||
<string name="infrared_learn_dialog_slot_label">按键槽位(1-100)</string>
|
||||
<string name="infrared_key_slot_label">按键编号:%1$s</string>
|
||||
<string name="infrared_learn_dialog_start">开始学习</string>
|
||||
<string name="infrared_rename_dialog_title">重命名按键</string>
|
||||
<string name="infrared_rename_dialog_input_label">新按键名称</string>
|
||||
<string name="infrared_delete_dialog_title">删除按键</string>
|
||||
<string name="infrared_delete_dialog_message">仅从本机列表移除,ABox 服务端可能仍保留该按键。</string>
|
||||
<string name="infrared_delete_dialog_message">仅从本机删除该按键记录,ABox 主机上可能仍保留已录制码值。</string>
|
||||
<string name="infrared_dialog_cancel">取消</string>
|
||||
<string name="infrared_dialog_confirm">确认</string>
|
||||
<string name="infrared_back">返回</string>
|
||||
<string name="infrared_remote_title">红外遥控器</string>
|
||||
<string name="infrared_remote_recording">录制中</string>
|
||||
<string name="infrared_remote_learning_mode">学习模式</string>
|
||||
<string name="infrared_remote_learning_banner">🔴 红外学习模式已开启:点击任意按键即可录制信号</string>
|
||||
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
|
||||
<string name="infrared_remote_scene_tv">📺 电视遥控</string>
|
||||
<string name="infrared_remote_learning_banner">🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号</string>
|
||||
<string name="infrared_remote_scene_ac">❄️ 空调面板</string>
|
||||
<string name="infrared_remote_scene_tv">📺 电视面板</string>
|
||||
<string name="infrared_remote_settings">设置</string>
|
||||
|
||||
<string name="infrared_msg_learning_mode_enabled">🔴 学习录制模式已开启:点击任意按键即可开始录制</string>
|
||||
<string name="infrared_msg_recording_key">正在录制【%1$s】(Slot %2$s),请按遥控器按键...</string>
|
||||
<string name="infrared_msg_learning_mode_enabled">🔴 学习模式已开启:点击任意按键开始录制</string>
|
||||
<string name="infrared_msg_recording_key">正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键…</string>
|
||||
<string name="infrared_msg_learned_success">【%1$s】录制成功</string>
|
||||
<string name="infrared_msg_key_not_learned">按键【%1$s】尚未学习,请开启右上角学习模式进行录制</string>
|
||||
<string name="infrared_msg_signal_sent">已发射: %1$s</string>
|
||||
<string name="infrared_msg_device_offline">红外控制器离线 (错误码: %1$s)</string>
|
||||
<string name="infrared_msg_load_failed">数据加载失败</string>
|
||||
<string name="infrared_msg_key_not_learned">按键【%1$s】尚未学习,开启右上角学习模式即可录制</string>
|
||||
<string name="infrared_msg_signal_sent">已发射信号:%1$s</string>
|
||||
<string name="infrared_msg_device_offline">红外设备离线(错误码:%1$s)</string>
|
||||
<string name="infrared_msg_load_failed">加载数据失败</string>
|
||||
<string name="infrared_msg_operation_failed">操作失败</string>
|
||||
<string name="infrared_label_temp_control">温度调节</string>
|
||||
<string name="infrared_label_vol">音量 VOL</string>
|
||||
<string name="infrared_label_ch">频道 CH</string>
|
||||
<string name="infrared_label_vol">音量</string>
|
||||
<string name="infrared_label_ch">频道</string>
|
||||
<string name="infrared_label_cool_mode">❄️ 制冷模式</string>
|
||||
<string name="infrared_label_ac_status">💨 自动风速 | ↔ 扫风</string>
|
||||
<string name="infrared_label_ac_status">💨 自动风速 | ↔ 自动扫风</string>
|
||||
|
||||
<string name="infrared_key_ac_power">空调-电源</string>
|
||||
<string name="infrared_key_ac_temp_up">空调-温度+</string>
|
||||
<string name="infrared_key_ac_temp_down">空调-温度-</string>
|
||||
<string name="infrared_key_ac_temp_up">空调-升温</string>
|
||||
<string name="infrared_key_ac_temp_down">空调-降温</string>
|
||||
<string name="infrared_key_ac_mode">空调-模式</string>
|
||||
<string name="infrared_key_ac_fan">空调-风速</string>
|
||||
<string name="infrared_key_ac_swing">空调-扫风</string>
|
||||
@@ -64,10 +64,10 @@
|
||||
|
||||
<string name="infrared_key_tv_power">电视-电源</string>
|
||||
<string name="infrared_key_tv_mute">电视-静音</string>
|
||||
<string name="infrared_key_tv_nav_up">电视-上</string>
|
||||
<string name="infrared_key_tv_nav_down">电视-下</string>
|
||||
<string name="infrared_key_tv_nav_left">电视-左</string>
|
||||
<string name="infrared_key_tv_nav_right">电视-右</string>
|
||||
<string name="infrared_key_tv_nav_up">电视-向上</string>
|
||||
<string name="infrared_key_tv_nav_down">电视-向下</string>
|
||||
<string name="infrared_key_tv_nav_left">电视-向左</string>
|
||||
<string name="infrared_key_tv_nav_right">电视-向右</string>
|
||||
<string name="infrared_key_tv_ok">电视-确定</string>
|
||||
<string name="infrared_key_tv_vol_up">电视-音量+</string>
|
||||
<string name="infrared_key_tv_vol_down">电视-音量-</string>
|
||||
@@ -87,4 +87,25 @@
|
||||
<string name="infrared_msg_key_renamed">按键已重命名</string>
|
||||
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
|
||||
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
|
||||
<string name="infrared_hub_title">红外中枢</string>
|
||||
<string name="infrared_hub_slot_quota">硬件槽位:%1$d / %2$d 已占用(剩余 %3$d)</string>
|
||||
<string name="infrared_hub_static_quota">静态占用:%1$d</string>
|
||||
<string name="infrared_hub_dynamic_scratch">动态缓冲:Slot %1$s</string>
|
||||
<string name="infrared_hub_remotes_title">虚拟遥控器</string>
|
||||
<string name="infrared_hub_no_remotes">暂无虚拟遥控器,点击右上角或下方 ➕ 添加</string>
|
||||
<string name="infrared_hub_add_remote">添加遥控器</string>
|
||||
<string name="infrared_hub_mode_static">🚀 静态独占(%1$s 槽位)</string>
|
||||
<string name="infrared_hub_mode_dynamic">📦 动态缓冲(共用 Slot %1$s)</string>
|
||||
<string name="infrared_hub_create_dialog_title">添加虚拟遥控器</string>
|
||||
<string name="infrared_hub_device_type_label">设备类型</string>
|
||||
<string name="infrared_hub_brand_label">预设码库 / 品牌</string>
|
||||
<string name="infrared_hub_trans_mode_label">传输模式</string>
|
||||
<string name="infrared_hub_trans_mode_static_desc">超低延迟(~20ms),极速响应,占用 ~15 个硬件槽位</string>
|
||||
<string name="infrared_hub_trans_mode_dynamic_desc">节省槽位(0 额外槽位),现场写码发射(~80ms)</string>
|
||||
<string name="infrared_hub_quota_insufficient">硬件槽位不足,请选择动态写码模式</string>
|
||||
<string name="infrared_hub_raw_keys_button">硬件槽位与底层按键管理</string>
|
||||
<string name="infrared_hub_device_type_tv">📺 电视机 / 电视盒子</string>
|
||||
<string name="infrared_hub_device_type_ac">❄️ 空调设备</string>
|
||||
<string name="infrared_hub_device_type_fan">🌀 电风扇 / 空气净化器</string>
|
||||
<string name="infrared_hub_device_type_custom">🎛️ 自定义面板</string>
|
||||
</resources>
|
||||
|
||||
@@ -87,4 +87,25 @@
|
||||
<string name="infrared_msg_key_renamed">Key renamed</string>
|
||||
<string name="infrared_msg_key_deleted">Key removed locally</string>
|
||||
<string name="infrared_msg_key_not_ready">Key is not ready to send</string>
|
||||
<string name="infrared_hub_title">Infrared Hub</string>
|
||||
<string name="infrared_hub_slot_quota">Hardware Slots: %1$d / %2$d allocated (%3$d free)</string>
|
||||
<string name="infrared_hub_static_quota">Static: %1$d</string>
|
||||
<string name="infrared_hub_dynamic_scratch">Dynamic Buffer: Slot %1$s</string>
|
||||
<string name="infrared_hub_remotes_title">Virtual Remotes</string>
|
||||
<string name="infrared_hub_no_remotes">No virtual remotes added yet. Tap + to add one.</string>
|
||||
<string name="infrared_hub_add_remote">Add Remote</string>
|
||||
<string name="infrared_hub_mode_static">🚀 Static Mode (%1$s slots)</string>
|
||||
<string name="infrared_hub_mode_dynamic">📦 Dynamic Mode (Scratch Slot %1$s)</string>
|
||||
<string name="infrared_hub_create_dialog_title">Add Virtual Remote</string>
|
||||
<string name="infrared_hub_device_type_label">Device Type</string>
|
||||
<string name="infrared_hub_brand_label">Brand / Preset Library</string>
|
||||
<string name="infrared_hub_trans_mode_label">Transmission Mode</string>
|
||||
<string name="infrared_hub_trans_mode_static_desc">Low latency (~20ms), instant response, occupies ~15 slots</string>
|
||||
<string name="infrared_hub_trans_mode_dynamic_desc">Saves slots (0 extra slots), writes code on-the-fly (~80ms)</string>
|
||||
<string name="infrared_hub_quota_insufficient">Slot quota exceeded. Please choose Dynamic mode.</string>
|
||||
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
|
||||
<string name="infrared_hub_device_type_tv">📺 Television (TV)</string>
|
||||
<string name="infrared_hub_device_type_ac">❄️ Air Conditioner (AC)</string>
|
||||
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
|
||||
<string name="infrared_hub_device_type_custom">🎛️ Custom Panel</string>
|
||||
</resources>
|
||||
|
||||
+136
@@ -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()
|
||||
}
|
||||
}
|
||||
+128
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user