feat(infrared): refactor virtual remote hub with multi-device presets and quota management

This commit is contained in:
2026-08-25 14:11:49 +08:00
parent 86af7f4c91
commit 7f9fb8c2b6
29 changed files with 3072 additions and 44 deletions
@@ -21,7 +21,8 @@ import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
import com.flagship.abox.manager.ui.login.LoginRoute
@@ -103,27 +104,43 @@ private fun AuthenticatedNavHost(
val encodedProfileId = android.net.Uri.encode(profile.id.value)
DeviceListScreen(
onInfraredClick = {
navController.navigate("infrared_remote/$encodedProfileId")
navController.navigate("infrared_hub/$encodedProfileId")
},
)
},
)
}
composable(
route = "infrared_remote/{profileId}",
route = "infrared_hub/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
InfraredRemoteRoute(
InfraredHubRoute(
onBack = { navController.popBackStack() },
onOpenRemote = { remoteId ->
val encodedRemoteId = android.net.Uri.encode(remoteId)
navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId")
},
onOpenSettings = {
navController.navigate("infrared_settings/$encodedProfileId")
},
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_panel/{profileId}/{remoteId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("remoteId") { type = NavType.StringType },
),
) {
RemotePanelRoute(
onBack = { navController.popBackStack() },
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_settings/{profileId}",
arguments = listOf(
@@ -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>>
}
@@ -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
@@ -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?,
)
@@ -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,
)
@@ -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)
}
@@ -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))
}
},
)
}
@@ -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,
)
@@ -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")
}
}
@@ -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)
}
}
}
}
}
@@ -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,
)
@@ -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),
)
}
}
}
@@ -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 },
@@ -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>
@@ -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()
}
}
@@ -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()
}
}
@@ -0,0 +1,52 @@
package com.flagship.abox.manager.domain.infrared
object AcCodeGenerator {
/**
* Generates a standard protocol Hex string for an Air Conditioner state.
* Uses Gree/Generic standard 64-bit frame:
* Byte 0: Mode (3 bits) | Power (1 bit) | Fan Speed (2 bits) | Swing (2 bits)
* Byte 1: Temperature (4 bits: temp - 16) | Timer (4 bits)
* Byte 2: Auxiliary options (Eco, Turbo, Health, Light)
* Byte 3: Checksum
*/
fun generateHex(state: AcStateSnapshot, brand: String? = null): String {
val modeBits = when (state.mode) {
AcMode.AUTO -> 0b000
AcMode.COOL -> 0b001
AcMode.DRY -> 0b010
AcMode.FAN -> 0b011
AcMode.HEAT -> 0b100
}
val powerBit = if (state.power) 0b1 else 0b0
val fanBits = when (state.fanSpeed) {
AcFanSpeed.AUTO -> 0b00
AcFanSpeed.LOW -> 0b01
AcFanSpeed.MEDIUM -> 0b10
AcFanSpeed.HIGH -> 0b11
}
val swingBits = (if (state.verticalSwing) 0b01 else 0b00) or (if (state.horizontalSwing) 0b10 else 0b00)
val byte0 = ((modeBits and 0x07) shl 5) or
((powerBit and 0x01) shl 4) or
((fanBits and 0x03) shl 2) or
(swingBits and 0x03)
val clampedTemp = state.temperature.coerceIn(16, 30)
val tempBits = (clampedTemp - 16) and 0x0F
val byte1 = (tempBits shl 4) and 0xF0
val ecoBit = if (state.eco) 0b1 else 0b0
val byte2 = (ecoBit and 0x01) shl 7
val checksum = (byte0 + byte1 + byte2) and 0xFF
return buildString {
append("0x")
append(String.format("%02X", byte0))
append(String.format("%02X", byte1))
append(String.format("%02X", byte2))
append(String.format("%02X", checksum))
}
}
}
@@ -0,0 +1,108 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
import java.util.UUID
class CreateVirtualRemote(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
private val clock: EpochMillisClock,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
type: RemoteDeviceType,
brandModel: String? = null,
transmissionMode: TransmissionMode,
): DomainResult<VirtualRemoteId> {
val trimmedName = displayName.trim()
if (trimmedName.isBlank()) {
return DomainResult.Failure(
DomainError.InvalidRequest(detail = "Remote display name cannot be blank"),
)
}
val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel)
val quotaResult = repository.getKeyBindings(VirtualRemoteId("dummy")) // or compute from quota
// Obtain slot quota
// We calculate needed static slots:
val neededStaticSlots = if (transmissionMode == TransmissionMode.STATIC_SLOTS) templates.size else 0
val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16))
val now = clock.now()
val initialAcState = if (type == RemoteDeviceType.AIR_CONDITIONER) AcStateSnapshot() else null
val remote = VirtualRemote(
id = remoteId,
profileId = profileId,
deviceName = deviceName,
displayName = trimmedName,
type = type,
brandModel = brandModel,
transmissionMode = transmissionMode,
dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT,
acState = initialAcState,
createdAtEpochMillis = now,
updatedAtEpochMillis = now,
)
// Build key bindings
val bindings = if (transmissionMode == TransmissionMode.STATIC_SLOTS) {
// Find currently used slots by querying quota
// For now, assign slots incrementally or check
templates.mapIndexed { index, template ->
RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = (index + 1).toString(), // Will be allocated based on allocator
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED,
)
}
} else {
templates.map { template ->
RemoteKeyBinding(
id = UUID.randomUUID().toString(),
remoteId = remoteId,
profileId = profileId,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = null,
rawHexCode = template.defaultHexCode,
status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) {
InfraredKeyStatus.READY
} else {
InfraredKeyStatus.FAILED
},
)
}
}
// If static and has default hex codes, download to hardware slots
if (transmissionMode == TransmissionMode.STATIC_SLOTS) {
val codesToDownload = bindings.mapNotNull { b ->
if (b.assignedSlot != null && b.rawHexCode != null) {
InfraredCode(deviceName, b.assignedSlot, b.rawHexCode)
} else null
}
if (codesToDownload.isNotEmpty()) {
when (val dlResult = gateway.downloadCodes(profileId, codesToDownload)) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
}
}
return repository.createRemote(remote, bindings)
}
}
@@ -0,0 +1,13 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
class DeleteVirtualRemote(
private val repository: VirtualRemoteRepository,
) {
suspend operator fun invoke(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> = repository.deleteRemote(profileId, remoteId)
}
@@ -0,0 +1,81 @@
package com.flagship.abox.manager.domain.infrared
data class PresetBrandOption(
val id: String,
val displayName: String,
val deviceType: RemoteDeviceType,
)
object PresetCodeLibrary {
val availablePresets: List<PresetBrandOption> = listOf(
// 电视预设
PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV),
PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV),
PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV),
PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV),
// 空调预设
PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER),
PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER),
// 风扇预设
PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN),
PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN),
// 自定义
PresetBrandOption("custom_blank", "自定义学习遥控器 (Custom)", RemoteDeviceType.CUSTOM),
)
data class PresetButtonTemplate(
val buttonId: String,
val displayName: String,
val defaultHexCode: String? = null,
)
fun getTemplatesForType(type: RemoteDeviceType, presetId: String? = null): List<PresetButtonTemplate> = when (type) {
RemoteDeviceType.TV -> listOf(
PresetButtonTemplate("TV_POWER", "电源", "0x00FF02FD"),
PresetButtonTemplate("TV_MUTE", "静音", "0x00FF04FB"),
PresetButtonTemplate("TV_SOURCE", "信号源", "0x00FF06F9"),
PresetButtonTemplate("TV_NAV_UP", "上", "0x00FF08F7"),
PresetButtonTemplate("TV_NAV_DOWN", "下", "0x00FF0AF5"),
PresetButtonTemplate("TV_NAV_LEFT", "左", "0x00FF0CF3"),
PresetButtonTemplate("TV_NAV_RIGHT", "右", "0x00FF0EF1"),
PresetButtonTemplate("TV_OK", "确定", "0x00FF10EF"),
PresetButtonTemplate("TV_HOME", "主页", "0x00FF12ED"),
PresetButtonTemplate("TV_BACK", "返回", "0x00FF14EB"),
PresetButtonTemplate("TV_MENU", "菜单", "0x00FF16E9"),
PresetButtonTemplate("TV_VOL_UP", "音量 +", "0x00FF18E7"),
PresetButtonTemplate("TV_VOL_DOWN", "音量 -", "0x00FF1AE5"),
PresetButtonTemplate("TV_CH_UP", "频道 +", "0x00FF1CE3"),
PresetButtonTemplate("TV_CH_DOWN", "频道 -", "0x00FF1EE1"),
)
RemoteDeviceType.AIR_CONDITIONER -> listOf(
PresetButtonTemplate("AC_POWER", "电源"),
PresetButtonTemplate("AC_TEMP_UP", "升温"),
PresetButtonTemplate("AC_TEMP_DOWN", "降温"),
PresetButtonTemplate("AC_MODE", "模式"),
PresetButtonTemplate("AC_FAN", "风速"),
PresetButtonTemplate("AC_SWING_V", "上下扫风"),
PresetButtonTemplate("AC_SWING_H", "左右扫风"),
PresetButtonTemplate("AC_ECO", "节能"),
)
RemoteDeviceType.FAN -> listOf(
PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"),
PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"),
PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"),
PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"),
PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"),
)
RemoteDeviceType.CUSTOM -> listOf(
PresetButtonTemplate("CUSTOM_POWER", "电源"),
PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"),
PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"),
PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"),
PresetButtonTemplate("CUSTOM_KEY_4", "按键 4"),
)
}
}
@@ -0,0 +1,85 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.InfraredCode
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class SendVirtualRemoteAction(
private val repository: VirtualRemoteRepository,
private val gateway: InfraredGateway,
) {
suspend fun sendButton(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
buttonId: String,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
is DomainResult.Success -> res.value
is DomainResult.Failure -> return res
}
val binding = bindings.find { it.buttonId == buttonId }
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button binding not found"))
if (binding.status != InfraredKeyStatus.READY) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button is not ready to send"))
}
return when (remote.transmissionMode) {
TransmissionMode.STATIC_SLOTS -> {
val slot = binding.assignedSlot
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No slot assigned for static mode"))
gateway.sendKey(profileId, remote.deviceName, slot)
}
TransmissionMode.DYNAMIC_SCRATCH -> {
val hexCode = binding.rawHexCode
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code available for dynamic transmission"))
// 1. Download to scratch slot
val scratchSlot = remote.dynamicScratchSlot
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
// 2. Send scratch slot
gateway.sendKey(profileId, remote.deviceName, scratchSlot)
}
}
}
suspend fun sendAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> {
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
is DomainResult.Failure -> return res
}
if (remote.type != RemoteDeviceType.AIR_CONDITIONER) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Target is not an air conditioner"))
}
// 1. Update state snapshot in repository
when (val updateRes = repository.updateAcState(profileId, remoteId, state)) {
is DomainResult.Failure -> return updateRes
is DomainResult.Success -> Unit
}
// 2. Generate full state hex frame
val hexCode = AcCodeGenerator.generateHex(state, remote.brandModel)
// 3. Write to scratch slot and transmit
val scratchSlot = remote.dynamicScratchSlot
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) {
is DomainResult.Failure -> return dlResult
is DomainResult.Success -> Unit
}
return gateway.sendKey(profileId, remote.deviceName, scratchSlot)
}
}
@@ -0,0 +1,93 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
@JvmInline
value class VirtualRemoteId(val value: String)
enum class RemoteDeviceType {
TV,
AIR_CONDITIONER,
FAN,
CUSTOM,
}
enum class TransmissionMode {
STATIC_SLOTS,
DYNAMIC_SCRATCH,
}
enum class AcMode {
COOL,
HEAT,
AUTO,
DRY,
FAN,
}
enum class AcFanSpeed {
AUTO,
LOW,
MEDIUM,
HIGH,
}
data class AcStateSnapshot(
val power: Boolean = true,
val mode: AcMode = AcMode.COOL,
val temperature: Int = 26,
val fanSpeed: AcFanSpeed = AcFanSpeed.AUTO,
val verticalSwing: Boolean = true,
val horizontalSwing: Boolean = false,
val eco: Boolean = false,
)
data class VirtualRemote(
val id: VirtualRemoteId,
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val displayName: String,
val type: RemoteDeviceType,
val brandModel: String? = null,
val transmissionMode: TransmissionMode,
val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT,
val acState: AcStateSnapshot? = null,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
) {
companion object {
const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100"
}
}
data class RemoteKeyBinding(
val id: String,
val remoteId: VirtualRemoteId,
val profileId: ProviderProfileId,
val buttonId: String,
val displayName: String,
val assignedSlot: String? = null,
val rawHexCode: String? = null,
val status: InfraredKeyStatus = InfraredKeyStatus.READY,
val lastErrorCode: String? = null,
val lastErrorDetail: String? = null,
)
data class SlotQuotaSummary(
val totalCapacity: Int = 100,
val dynamicScratchSlot: Int = 100,
val assignedStaticSlots: Set<Int> = emptySet(),
) {
val availableStaticSlots: List<Int>
get() = (1 until dynamicScratchSlot).filter { it !in assignedStaticSlots }
val usedCount: Int
get() = assignedStaticSlots.size + 1 // assigned static slots + 1 dynamic scratch slot
val remainingStaticCount: Int
get() = availableStaticSlots.size
val isStaticFull: Boolean
get() = availableStaticSlots.isEmpty()
}
@@ -0,0 +1,56 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
interface VirtualRemoteRepository {
fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>>
fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>>
fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>>
fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>>
suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?>
suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>>
suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId>
suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit>
suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit>
suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit>
suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit>
}
@@ -202,6 +202,7 @@ class RecordingInfraredGateway : InfraredGateway {
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
val learnedKeys = mutableListOf<InfraredLearningCall>()
val sentKeys = mutableListOf<InfraredSendCall>()
val downloadedCodes = mutableListOf<InfraredCode>()
override suspend fun learnKey(
profileId: ProviderProfileId,
@@ -224,10 +225,110 @@ class RecordingInfraredGateway : InfraredGateway {
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
): DomainResult<List<InfraredCodeDownloadResult>> = DomainResult.Success(emptyList())
): DomainResult<List<InfraredCodeDownloadResult>> {
downloadedCodes += codes
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
}
}
class InMemoryVirtualRemoteRepository : VirtualRemoteRepository {
private val remotes = mutableMapOf<VirtualRemoteId, VirtualRemote>()
private val bindings = mutableMapOf<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
private val state = MutableStateFlow<List<VirtualRemote>>(emptyList())
override fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>> = state.map { values ->
DomainResult.Success(values.filter { it.profileId == profileId && it.deviceName == deviceName })
}
override fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>> = state.map { values ->
DomainResult.Success(values.find { it.id == remoteId && it.profileId == profileId })
}
override fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>> = state.map {
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
}
override fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>> = state.map { values ->
val relevant = values.filter { it.profileId == profileId && it.deviceName == deviceName }
val used = relevant.flatMap { bindings[it.id].orEmpty() }
.mapNotNull { it.assignedSlot?.toIntOrNull() }
.toSet()
DomainResult.Success(SlotQuotaSummary(assignedStaticSlots = used))
}
override suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?> = DomainResult.Success(remotes[remoteId]?.takeIf { it.profileId == profileId })
override suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>> = DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
override suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId> {
remotes[remote.id] = remote
bindings[remote.id] = initialBindings.toMutableList()
publish()
return DomainResult.Success(remote.id)
}
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
remotes[remote.id] = remote
publish()
return DomainResult.Success(Unit)
}
override suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> {
remotes.remove(remoteId)
bindings.remove(remoteId)
publish()
return DomainResult.Success(Unit)
}
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
val list = bindings[binding.remoteId] ?: mutableListOf()
val idx = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
if (idx >= 0) list[idx] = binding else list.add(binding)
bindings[binding.remoteId] = list
publish()
return DomainResult.Success(Unit)
}
override suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> {
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
remotes[remoteId] = existing.copy(acState = state)
publish()
return DomainResult.Success(Unit)
}
private fun publish() {
state.value = remotes.values.toList()
}
}
fun SavedInfraredKey.ref() = InfraredKeyRef(
profileId = profileId,
keyId = id,
)
@@ -0,0 +1,119 @@
package com.flagship.abox.manager.domain.infrared
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
class VirtualRemoteUseCaseTest {
private val profileId = ProviderProfileId("profile1")
private val deviceName = DeviceName("ir-1")
private val clock = EpochMillisClock { 10_000L }
private lateinit var repository: InMemoryVirtualRemoteRepository
private lateinit var gateway: RecordingInfraredGateway
private lateinit var createRemote: CreateVirtualRemote
private lateinit var sendAction: SendVirtualRemoteAction
private lateinit var deleteRemote: DeleteVirtualRemote
@Before
fun setup() {
repository = InMemoryVirtualRemoteRepository()
gateway = RecordingInfraredGateway()
createRemote = CreateVirtualRemote(repository, gateway, clock)
sendAction = SendVirtualRemoteAction(repository, gateway)
deleteRemote = DeleteVirtualRemote(repository)
}
@Test
fun createVirtualRemoteWithStaticSlotsDownloadsPresetCodesToHardware() = runTest {
val result = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Living Room TV",
type = RemoteDeviceType.TV,
brandModel = "generic_tv",
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
assertTrue(result is DomainResult.Success)
val remoteId = (result as DomainResult.Success).value
// Check remote created in repository
val remote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertNotNull(remote)
assertEquals("Living Room TV", remote?.displayName)
assertEquals(TransmissionMode.STATIC_SLOTS, remote?.transmissionMode)
// Check preset codes downloaded to hardware gateway
assertTrue(gateway.downloadedCodes.isNotEmpty())
// Send a button in static mode -> directly triggers sendKey with assigned slot
val sendRes = sendAction.sendButton(profileId, remoteId, "TV_POWER")
assertTrue(sendRes is DomainResult.Success)
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
}
@Test
fun sendVirtualRemoteActionForAcUpdatesStateAndDownloadsToScratchSlot() = runTest {
val createResult = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Bedroom AC",
type = RemoteDeviceType.AIR_CONDITIONER,
brandModel = "gree_ac",
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
val newAcState = AcStateSnapshot(
power = true,
mode = AcMode.COOL,
temperature = 27,
fanSpeed = AcFanSpeed.HIGH,
verticalSwing = true,
)
val sendRes = sendAction.sendAcState(profileId, remoteId, newAcState)
assertTrue(sendRes is DomainResult.Success)
// 1. Downloaded to scratch slot 100
assertEquals(1, gateway.downloadedCodes.size)
val downloaded = gateway.downloadedCodes.first()
assertEquals("100", downloaded.key)
assertTrue(downloaded.code.startsWith("0x"))
// 2. Transmitted via sendKey with slot 100
assertEquals(1, gateway.sentKeys.size)
assertEquals("100", gateway.sentKeys.first().key)
// 3. State machine updated in repository
val updatedRemote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertEquals(27, updatedRemote?.acState?.temperature)
assertEquals(AcFanSpeed.HIGH, updatedRemote?.acState?.fanSpeed)
}
@Test
fun deleteVirtualRemoteRemovesFromRepository() = runTest {
val createResult = createRemote(
profileId = profileId,
deviceName = deviceName,
displayName = "Fan",
type = RemoteDeviceType.FAN,
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
)
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
deleteRemote(profileId, remoteId)
val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
assertEquals(null, fetched)
}
}
@@ -129,6 +129,9 @@ class FakeInfraredGateway : InfraredGateway {
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
}
val downloadedCodes: List<InfraredCode>
get() = downloadCalls.flatMap { it.codes }
override suspend fun downloadCodes(
profileId: ProviderProfileId,
codes: List<InfraredCode>,
@@ -0,0 +1,126 @@
package com.flagship.abox.manager.testing.fakes
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import java.util.concurrent.ConcurrentHashMap
class FakeVirtualRemoteRepository : VirtualRemoteRepository {
private val remotes = ConcurrentHashMap<VirtualRemoteId, VirtualRemote>()
private val bindings = ConcurrentHashMap<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
private val changeTrigger = MutableStateFlow(0L)
override fun observeRemotes(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<VirtualRemote>>> = changeTrigger.map {
DomainResult.Success(
remotes.values.filter { it.profileId == profileId && it.deviceName == deviceName }
.sortedByDescending { it.createdAtEpochMillis },
)
}
override fun observeRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): Flow<DomainResult<VirtualRemote?>> = changeTrigger.map {
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
DomainResult.Success(remote)
}
override fun observeKeyBindings(
remoteId: VirtualRemoteId,
): Flow<DomainResult<List<RemoteKeyBinding>>> = changeTrigger.map {
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
}
override fun observeSlotQuota(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<SlotQuotaSummary>> = changeTrigger.map {
val relevantBindings = remotes.values
.filter { it.profileId == profileId && it.deviceName == deviceName }
.flatMap { bindings[it.id].orEmpty() }
val usedSlots = relevantBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
DomainResult.Success(
SlotQuotaSummary(
totalCapacity = 100,
dynamicScratchSlot = 100,
assignedStaticSlots = usedSlots,
),
)
}
override suspend fun getRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<VirtualRemote?> {
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
return DomainResult.Success(remote)
}
override suspend fun getKeyBindings(
remoteId: VirtualRemoteId,
): DomainResult<List<RemoteKeyBinding>> =
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
override suspend fun createRemote(
remote: VirtualRemote,
initialBindings: List<RemoteKeyBinding>,
): DomainResult<VirtualRemoteId> {
remotes[remote.id] = remote
bindings[remote.id] = initialBindings.toMutableList()
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(remote.id)
}
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
remotes[remote.id] = remote
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
override suspend fun deleteRemote(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
): DomainResult<Unit> {
remotes.remove(remoteId)
bindings.remove(remoteId)
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
val list = bindings[binding.remoteId] ?: mutableListOf()
val index = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
if (index >= 0) {
list[index] = binding
} else {
list.add(binding)
}
bindings[binding.remoteId] = list
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
override suspend fun updateAcState(
profileId: ProviderProfileId,
remoteId: VirtualRemoteId,
state: AcStateSnapshot,
): DomainResult<Unit> {
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
remotes[remoteId] = existing.copy(acState = state)
changeTrigger.value = System.currentTimeMillis()
return DomainResult.Success(Unit)
}
}