Compare commits
6
Commits
86af7f4c91
...
2cc70b0ca3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cc70b0ca3 | ||
|
|
dab1bef5aa | ||
|
|
ec51ae1db7 | ||
|
|
849b7e4aea | ||
|
|
63cac722c2 | ||
|
|
7f9fb8c2b6 |
@@ -21,7 +21,8 @@ import com.flagship.abox.manager.designsystem.ABoxSurface
|
|||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
import com.flagship.abox.manager.domain.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.Username
|
import com.flagship.abox.manager.domain.Username
|
||||||
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteRoute
|
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
|
||||||
|
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
|
||||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
||||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
@@ -103,27 +104,43 @@ private fun AuthenticatedNavHost(
|
|||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
||||||
DeviceListScreen(
|
DeviceListScreen(
|
||||||
onInfraredClick = {
|
onInfraredClick = {
|
||||||
navController.navigate("infrared_remote/$encodedProfileId")
|
navController.navigate("infrared_hub/$encodedProfileId")
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(
|
composable(
|
||||||
route = "infrared_remote/{profileId}",
|
route = "infrared_hub/{profileId}",
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
navArgument("profileId") { type = NavType.StringType },
|
||||||
),
|
),
|
||||||
) {
|
) {
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
||||||
InfraredRemoteRoute(
|
InfraredHubRoute(
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
|
onOpenRemote = { remoteId ->
|
||||||
|
val encodedRemoteId = android.net.Uri.encode(remoteId)
|
||||||
|
navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId")
|
||||||
|
},
|
||||||
onOpenSettings = {
|
onOpenSettings = {
|
||||||
navController.navigate("infrared_settings/$encodedProfileId")
|
navController.navigate("infrared_settings/$encodedProfileId")
|
||||||
},
|
},
|
||||||
onSessionInvalid = onLogout,
|
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(
|
composable(
|
||||||
route = "infrared_settings/{profileId}",
|
route = "infrared_settings/{profileId}",
|
||||||
arguments = listOf(
|
arguments = listOf(
|
||||||
|
|||||||
+114
@@ -105,4 +105,118 @@ interface InfraredDao {
|
|||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity)
|
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(
|
||||||
|
"""
|
||||||
|
DELETE FROM remote_key_bindings
|
||||||
|
WHERE remoteId = :remoteId AND buttonId = :buttonId
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
suspend fun deleteKeyBinding(remoteId: String, buttonId: String): Int
|
||||||
|
|
||||||
|
@Query(
|
||||||
|
"""
|
||||||
|
SELECT b.assignedSlot FROM remote_key_bindings b
|
||||||
|
INNER JOIN virtual_remotes r ON b.remoteId = r.id
|
||||||
|
WHERE r.profileId = :profileId AND r.deviceName = :deviceName AND b.assignedSlot IS NOT NULL
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
fun observeAssignedStaticSlots(profileId: String, deviceName: String): Flow<List<String>>
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -7,9 +7,11 @@ import androidx.room.RoomDatabase
|
|||||||
entities = [
|
entities = [
|
||||||
InfraredKeyEntity::class,
|
InfraredKeyEntity::class,
|
||||||
InfraredDeviceConfigurationEntity::class,
|
InfraredDeviceConfigurationEntity::class,
|
||||||
|
VirtualRemoteEntity::class,
|
||||||
|
RemoteKeyBindingEntity::class,
|
||||||
],
|
],
|
||||||
version = 1,
|
version = 2,
|
||||||
exportSchema = true,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class InfraredDatabase : RoomDatabase() {
|
abstract class InfraredDatabase : RoomDatabase() {
|
||||||
abstract fun infraredDao(): InfraredDao
|
abstract fun infraredDao(): InfraredDao
|
||||||
|
|||||||
+50
@@ -31,3 +31,53 @@ data class InfraredDeviceConfigurationEntity(
|
|||||||
val profileId: String,
|
val profileId: String,
|
||||||
val deviceName: 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 layoutStyle: 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?,
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
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 deleteKeyBinding(
|
||||||
|
remoteId: VirtualRemoteId,
|
||||||
|
buttonId: String,
|
||||||
|
): DomainResult<Unit> = try {
|
||||||
|
dao.deleteKeyBinding(remoteId.value, buttonId)
|
||||||
|
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
|
||||||
|
|
||||||
|
val devType = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM)
|
||||||
|
val parsedLayout = layoutStyle?.let { runCatching { com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.valueOf(it) }.getOrNull() }
|
||||||
|
?: VirtualRemote.defaultLayoutFor(devType)
|
||||||
|
|
||||||
|
return VirtualRemote(
|
||||||
|
id = VirtualRemoteId(id),
|
||||||
|
profileId = ProviderProfileId(profileId),
|
||||||
|
deviceName = DeviceName(deviceName),
|
||||||
|
displayName = displayName,
|
||||||
|
type = devType,
|
||||||
|
brandModel = brandModel,
|
||||||
|
layoutStyle = parsedLayout,
|
||||||
|
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,
|
||||||
|
layoutStyle = layoutStyle.name,
|
||||||
|
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,
|
||||||
|
)
|
||||||
+66
-1
@@ -19,6 +19,7 @@ import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinato
|
|||||||
import com.flagship.abox.manager.infrared.data.local.InfraredDao
|
import com.flagship.abox.manager.infrared.data.local.InfraredDao
|
||||||
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
|
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
|
||||||
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
|
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
|
||||||
|
import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -48,7 +49,7 @@ object InfraredFeatureModule {
|
|||||||
context,
|
context,
|
||||||
InfraredDatabase::class.java,
|
InfraredDatabase::class.java,
|
||||||
"abox_infrared.db",
|
"abox_infrared.db",
|
||||||
).build()
|
).fallbackToDestructiveMigration().build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@@ -161,4 +162,68 @@ object InfraredFeatureModule {
|
|||||||
keyRepository = repository,
|
keyRepository = repository,
|
||||||
externalScope = scope,
|
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)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideLearnVirtualRemoteKey(
|
||||||
|
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||||
|
gateway: InfraredGateway,
|
||||||
|
): com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey =
|
||||||
|
com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey(repository, gateway)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAddVirtualRemoteKey(
|
||||||
|
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||||
|
): com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey =
|
||||||
|
com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey(repository)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideDeleteVirtualRemoteKey(
|
||||||
|
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||||
|
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey =
|
||||||
|
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey(repository)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideUpdateVirtualRemoteLayout(
|
||||||
|
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||||
|
): com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout =
|
||||||
|
com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout(repository)
|
||||||
}
|
}
|
||||||
|
|||||||
+593
@@ -0,0 +1,593 @@
|
|||||||
|
package com.flagship.abox.manager.infrared.hub
|
||||||
|
|
||||||
|
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.AlertDialog
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||||
|
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
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.Surface
|
||||||
|
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.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.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||||
|
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
|
||||||
|
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.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,
|
||||||
|
onNavigateToSettings = onOpenSettings,
|
||||||
|
onOpenRemote = onOpenRemote,
|
||||||
|
onDeleteRemote = viewModel::deleteRemote,
|
||||||
|
onCreateRemote = viewModel::createRemote,
|
||||||
|
onSessionInvalid = onSessionInvalid,
|
||||||
|
onMessageShown = viewModel::clearMessage,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun InfraredHubScreen(
|
||||||
|
state: InfraredHubUiState,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
onNavigateToSettings: () -> Unit,
|
||||||
|
onOpenRemote: (String) -> Unit,
|
||||||
|
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||||
|
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
|
||||||
|
onSessionInvalid: () -> 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 = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.infrared_hub_title),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
state.providerDisplayName?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
TextButton(onClick = onBack) {
|
||||||
|
Text(stringResource(R.string.infrared_back))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = onNavigateToSettings,
|
||||||
|
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 槽位预算)
|
||||||
|
QuotaSummaryCard(quota = state.slotQuota)
|
||||||
|
|
||||||
|
// 2. 遥控器列表标题与新建按钮
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.infrared_hub_my_remotes),
|
||||||
|
style = MaterialTheme.typography.titleMedium.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, layoutStyle ->
|
||||||
|
onCreateRemote(name, type, brandModel, transMode, layoutStyle)
|
||||||
|
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) {
|
||||||
|
"❄️ 状态机动态写码"
|
||||||
|
} else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
|
||||||
|
"🚀 静态独占槽位"
|
||||||
|
} else {
|
||||||
|
"📦 动态写码"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "${remote.layoutStyle.name} · $modeText",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "硬件槽位配额 (总计 100 槽位)",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(8.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun CreateVirtualRemoteDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) }
|
||||||
|
var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) }
|
||||||
|
var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) }
|
||||||
|
var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) }
|
||||||
|
|
||||||
|
var typeDropdownExpanded by remember { mutableStateOf(false) }
|
||||||
|
var brandDropdownExpanded by remember { mutableStateOf(false) }
|
||||||
|
var layoutDropdownExpanded 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
|
||||||
|
} else if (type == RemoteDeviceType.CUSTOM) {
|
||||||
|
transmissionMode = TransmissionMode.STATIC_SLOTS
|
||||||
|
}
|
||||||
|
typeDropdownExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 遥控器名称输入
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("遥控器名称(如:客厅电视、我的遥控器)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 3. 自定义遥控器时:选择初始布局模板
|
||||||
|
if (selectedType == RemoteDeviceType.CUSTOM) {
|
||||||
|
ExposedDropdownMenuBox(
|
||||||
|
expanded = layoutDropdownExpanded,
|
||||||
|
onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded },
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = when (selectedCustomLayout) {
|
||||||
|
PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)"
|
||||||
|
PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)"
|
||||||
|
PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)"
|
||||||
|
PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局"
|
||||||
|
},
|
||||||
|
onValueChange = {},
|
||||||
|
readOnly = true,
|
||||||
|
label = { Text("选择初始布局模板") },
|
||||||
|
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) },
|
||||||
|
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||||
|
)
|
||||||
|
ExposedDropdownMenu(
|
||||||
|
expanded = layoutDropdownExpanded,
|
||||||
|
onDismissRequest = { layoutDropdownExpanded = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") },
|
||||||
|
onClick = {
|
||||||
|
selectedCustomLayout = PanelLayoutStyle.TV_NAV
|
||||||
|
layoutDropdownExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") },
|
||||||
|
onClick = {
|
||||||
|
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
|
||||||
|
layoutDropdownExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("🎛️ 自由网格布局 (可自由增删按键)") },
|
||||||
|
onClick = {
|
||||||
|
selectedCustomLayout = PanelLayoutStyle.GRID
|
||||||
|
layoutDropdownExpanded = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
modifier = Modifier.padding(10.dp),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else 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 && selectedType != RemoteDeviceType.CUSTOM) {
|
||||||
|
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 if (selectedType == RemoteDeviceType.AIR_CONDITIONER) {
|
||||||
|
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 if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName
|
||||||
|
val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null
|
||||||
|
onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout)
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag("create_remote_confirm_button"),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.infrared_dialog_confirm))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text(stringResource(R.string.infrared_dialog_cancel))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
package com.flagship.abox.manager.infrared.hub
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
|
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||||
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
|
|
||||||
|
sealed interface HubUiMessage {
|
||||||
|
data class Info(val text: UiText) : HubUiMessage
|
||||||
|
data class Error(val text: UiText) : HubUiMessage
|
||||||
|
data object SessionInvalid : HubUiMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
data class InfraredHubUiState(
|
||||||
|
val profileId: ProviderProfileId? = null,
|
||||||
|
val providerType: ProviderType? = null,
|
||||||
|
val providerDisplayName: String? = null,
|
||||||
|
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
|
||||||
|
val remotes: List<VirtualRemote> = emptyList(),
|
||||||
|
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val message: HubUiMessage? = null,
|
||||||
|
)
|
||||||
+200
@@ -0,0 +1,200 @@
|
|||||||
|
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,
|
||||||
|
layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle? = null,
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
layoutStyle = layoutStyle,
|
||||||
|
)) {
|
||||||
|
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("红外控制器")
|
||||||
|
}
|
||||||
|
}
|
||||||
+801
@@ -0,0 +1,801 @@
|
|||||||
|
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.AlertDialog
|
||||||
|
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.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
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.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.InfraredKeyStatus
|
||||||
|
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||||
|
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,
|
||||||
|
onToggleLearnMode = viewModel::toggleLearnMode,
|
||||||
|
onAddCustomButton = viewModel::addCustomButton,
|
||||||
|
onDeleteButton = viewModel::deleteButton,
|
||||||
|
onToggleAcPower = viewModel::toggleAcPower,
|
||||||
|
onAdjustAcTemp = viewModel::adjustAcTemperature,
|
||||||
|
onSetAcMode = viewModel::setAcMode,
|
||||||
|
onSetFanSpeed = 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,
|
||||||
|
onToggleLearnMode: () -> Unit,
|
||||||
|
onAddCustomButton: (String) -> Unit,
|
||||||
|
onDeleteButton: (String) -> Unit,
|
||||||
|
onToggleAcPower: () -> Unit,
|
||||||
|
onAdjustAcTemp: (Int) -> Unit,
|
||||||
|
onSetAcMode: (AcMode) -> Unit,
|
||||||
|
onSetFanSpeed: (AcFanSpeed) -> Unit,
|
||||||
|
onToggleAcVerticalSwing: () -> Unit,
|
||||||
|
onToggleAcHorizontalSwing: () -> Unit,
|
||||||
|
onToggleAcEco: () -> Unit,
|
||||||
|
onMessageShown: () -> Unit,
|
||||||
|
) {
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
var showAddKeyDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
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 = {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp,
|
||||||
|
)
|
||||||
|
state.remote?.let {
|
||||||
|
val layoutLabel = when (it.layoutStyle) {
|
||||||
|
PanelLayoutStyle.TV_NAV -> "电视导航布局"
|
||||||
|
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放布局"
|
||||||
|
PanelLayoutStyle.GRID -> "自由网格布局"
|
||||||
|
PanelLayoutStyle.AC_COMPACT -> "空调全状态布局"
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = layoutLabel,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
TextButton(onClick = onBack) {
|
||||||
|
Text(stringResource(R.string.infrared_back))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
// 非空调面板显示学习模式开关
|
||||||
|
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
|
||||||
|
FilterChip(
|
||||||
|
selected = state.isLearnMode,
|
||||||
|
onClick = onToggleLearnMode,
|
||||||
|
label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") },
|
||||||
|
colors = FilterChipDefaults.filterChipColors(
|
||||||
|
selectedContainerColor = Color(0xFFFF5252),
|
||||||
|
selectedLabelColor = Color.White,
|
||||||
|
),
|
||||||
|
modifier = Modifier.testTag("learn_mode_toggle"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
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 {
|
||||||
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// 学习模式指示横幅
|
||||||
|
if (state.isLearnMode) {
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE)),
|
||||||
|
shape = RoundedCornerShape(0.dp),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Text("🔴", fontSize = 16.sp)
|
||||||
|
Text(
|
||||||
|
text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码",
|
||||||
|
color = Color(0xFFC62828),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 面板内容区
|
||||||
|
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
|
||||||
|
when (remote.layoutStyle) {
|
||||||
|
PanelLayoutStyle.AC_COMPACT -> {
|
||||||
|
AcControlPanel(
|
||||||
|
acState = state.acState,
|
||||||
|
onTogglePower = onToggleAcPower,
|
||||||
|
onAdjustTemp = onAdjustAcTemp,
|
||||||
|
onSetMode = onSetAcMode,
|
||||||
|
onSetFanSpeed = onSetFanSpeed,
|
||||||
|
onToggleVSwing = onToggleAcVerticalSwing,
|
||||||
|
onToggleHSwing = onToggleAcHorizontalSwing,
|
||||||
|
onToggleEco = onToggleAcEco,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PanelLayoutStyle.TV_NAV -> {
|
||||||
|
TvControlPanel(
|
||||||
|
bindings = state.bindings,
|
||||||
|
isLearnMode = state.isLearnMode,
|
||||||
|
learningButtonId = state.learningButtonId,
|
||||||
|
sendingButtonId = state.sendingButtonId,
|
||||||
|
onButtonClick = onButtonClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PanelLayoutStyle.MEDIA_PLAYER -> {
|
||||||
|
MediaPlayerControlPanel(
|
||||||
|
bindings = state.bindings,
|
||||||
|
isLearnMode = state.isLearnMode,
|
||||||
|
learningButtonId = state.learningButtonId,
|
||||||
|
sendingButtonId = state.sendingButtonId,
|
||||||
|
onButtonClick = onButtonClick,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PanelLayoutStyle.GRID -> {
|
||||||
|
GenericGridControlPanel(
|
||||||
|
bindings = state.bindings,
|
||||||
|
isLearnMode = state.isLearnMode,
|
||||||
|
learningButtonId = state.learningButtonId,
|
||||||
|
sendingButtonId = state.sendingButtonId,
|
||||||
|
onButtonClick = onButtonClick,
|
||||||
|
onDeleteButton = onDeleteButton,
|
||||||
|
onAddButton = { showAddKeyDialog = true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showAddKeyDialog) {
|
||||||
|
AddCustomKeyDialog(
|
||||||
|
onDismiss = { showAddKeyDialog = false },
|
||||||
|
onConfirm = { name ->
|
||||||
|
onAddCustomButton(name)
|
||||||
|
showAddKeyDialog = false
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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),
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
FilterChip(
|
||||||
|
selected = acState.mode == mode,
|
||||||
|
onClick = { onSetMode(mode) },
|
||||||
|
label = { Text(mode.name) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
FilterChip(
|
||||||
|
selected = acState.fanSpeed == speed,
|
||||||
|
onClick = { onSetFanSpeed(speed) },
|
||||||
|
label = { Text(speed.name) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
isLearnMode: Boolean,
|
||||||
|
learningButtonId: String?,
|
||||||
|
sendingButtonId: String?,
|
||||||
|
onButtonClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
|
||||||
|
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"),
|
||||||
|
) {
|
||||||
|
ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下
|
||||||
|
IconButton(
|
||||||
|
onClick = { onButtonClick("TV_NAV_DOWN") },
|
||||||
|
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
|
||||||
|
) {
|
||||||
|
ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 左
|
||||||
|
IconButton(
|
||||||
|
onClick = { onButtonClick("TV_NAV_LEFT") },
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
|
||||||
|
) {
|
||||||
|
ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 右
|
||||||
|
IconButton(
|
||||||
|
onClick = { onButtonClick("TV_NAV_RIGHT") },
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
|
||||||
|
) {
|
||||||
|
ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK 居中大按钮
|
||||||
|
Button(
|
||||||
|
onClick = { onButtonClick("TV_OK") },
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(76.dp).testTag("tv_ok"),
|
||||||
|
) {
|
||||||
|
ButtonIndicatorText("TV_OK", "OK", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 辅助按键行 (返回、主页、菜单)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceAround,
|
||||||
|
) {
|
||||||
|
InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部音量与频道调节柱
|
||||||
|
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)) {
|
||||||
|
ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||||
|
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||||
|
ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MediaPlayerControlPanel(
|
||||||
|
bindings: List<RemoteKeyBinding>,
|
||||||
|
isLearnMode: Boolean,
|
||||||
|
learningButtonId: String?,
|
||||||
|
sendingButtonId: String?,
|
||||||
|
onButtonClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// 播放控制中心大圆盘
|
||||||
|
Card(
|
||||||
|
shape = CircleShape,
|
||||||
|
modifier = Modifier.size(240.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
// 快进 >>
|
||||||
|
IconButton(onClick = { onButtonClick("MEDIA_FF") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) {
|
||||||
|
ButtonIndicatorText("MEDIA_FF", "⏩", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
// 快退 <<
|
||||||
|
IconButton(onClick = { onButtonClick("MEDIA_REWIND") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) {
|
||||||
|
ButtonIndicatorText("MEDIA_REWIND", "⏪", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
// 上一曲
|
||||||
|
IconButton(onClick = { onButtonClick("MEDIA_PREV") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp)) {
|
||||||
|
ButtonIndicatorText("MEDIA_PREV", "⏮", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
// 下一曲
|
||||||
|
IconButton(onClick = { onButtonClick("MEDIA_NEXT") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp)) {
|
||||||
|
ButtonIndicatorText("MEDIA_NEXT", "⏭", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
// 播放/暂停
|
||||||
|
Button(onClick = { onButtonClick("MEDIA_PLAY_PAUSE") }, shape = CircleShape, modifier = Modifier.size(80.dp)) {
|
||||||
|
ButtonIndicatorText("MEDIA_PLAY_PAUSE", "⏯", learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GenericGridControlPanel(
|
||||||
|
bindings: List<RemoteKeyBinding>,
|
||||||
|
isLearnMode: Boolean,
|
||||||
|
learningButtonId: String?,
|
||||||
|
sendingButtonId: String?,
|
||||||
|
onButtonClick: (String) -> Unit,
|
||||||
|
onDeleteButton: (String) -> Unit,
|
||||||
|
onAddButton: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
|
Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) {
|
||||||
|
Text("➕ 添加按键")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindings.isEmpty()) {
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
|
||||||
|
Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(2),
|
||||||
|
modifier = Modifier.fillMaxSize().weight(1f),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(bindings, key = { it.id }) { binding ->
|
||||||
|
GridButtonCard(
|
||||||
|
binding = binding,
|
||||||
|
isLearnMode = isLearnMode,
|
||||||
|
isLearning = learningButtonId == binding.buttonId,
|
||||||
|
isSending = sendingButtonId == binding.buttonId,
|
||||||
|
onClick = { onButtonClick(binding.buttonId) },
|
||||||
|
onDelete = { onDeleteButton(binding.buttonId) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun GridButtonCard(
|
||||||
|
binding: RemoteKeyBinding,
|
||||||
|
isLearnMode: Boolean,
|
||||||
|
isLearning: Boolean,
|
||||||
|
isSending: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onDelete: () -> Unit,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(76.dp)
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.testTag("generic_btn_${binding.buttonId}"),
|
||||||
|
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||||
|
val statusText = when {
|
||||||
|
isLearning -> "🟡 正在录制信号..."
|
||||||
|
binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})"
|
||||||
|
else -> "⚪ 待录制 (点击录码)"
|
||||||
|
}
|
||||||
|
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSending || isLearning) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||||
|
} else {
|
||||||
|
// 始终提供删除操作按钮
|
||||||
|
IconButton(
|
||||||
|
onClick = onDelete,
|
||||||
|
modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"),
|
||||||
|
) {
|
||||||
|
Text("🗑️", fontSize = 15.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InteractiveRemoteButton(
|
||||||
|
buttonId: String,
|
||||||
|
label: String,
|
||||||
|
containerColor: Color,
|
||||||
|
binding: RemoteKeyBinding?,
|
||||||
|
isLearnMode: Boolean,
|
||||||
|
learningButtonId: String?,
|
||||||
|
sendingButtonId: String?,
|
||||||
|
onButtonClick: (String) -> Unit,
|
||||||
|
) {
|
||||||
|
val isUnlearned = binding != null && binding.status != InfraredKeyStatus.READY
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { onButtonClick(buttonId) },
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = when {
|
||||||
|
learningButtonId == buttonId -> Color(0xFFFF9800)
|
||||||
|
isUnlearned -> MaterialTheme.colorScheme.surfaceVariant
|
||||||
|
else -> containerColor
|
||||||
|
},
|
||||||
|
contentColor = if (isUnlearned) MaterialTheme.colorScheme.onSurfaceVariant else Color.White,
|
||||||
|
),
|
||||||
|
modifier = Modifier.testTag("tv_btn_$buttonId"),
|
||||||
|
) {
|
||||||
|
ButtonIndicatorText(buttonId, label, learningButtonId, sendingButtonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ButtonIndicatorText(
|
||||||
|
buttonId: String,
|
||||||
|
defaultLabel: String,
|
||||||
|
learningButtonId: String?,
|
||||||
|
sendingButtonId: String?,
|
||||||
|
) {
|
||||||
|
if (learningButtonId == buttonId) {
|
||||||
|
Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||||
|
} else if (sendingButtonId == buttonId) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
|
||||||
|
} else {
|
||||||
|
Text(defaultLabel, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AddCustomKeyDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onConfirm: (name: String) -> Unit,
|
||||||
|
) {
|
||||||
|
var name by remember { mutableStateOf("") }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("添加自定义按键") },
|
||||||
|
text = {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Text("输入按键显示名称,创建后点击按键或开启【学习模式】即可录制实体按键:", fontSize = 13.sp)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = name,
|
||||||
|
onValueChange = { name = it },
|
||||||
|
label = { Text("按键名称(如:静音、低音增强)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (name.isNotBlank()) onConfirm(name.trim())
|
||||||
|
},
|
||||||
|
modifier = Modifier.testTag("add_key_confirm_button"),
|
||||||
|
) {
|
||||||
|
Text("确认添加")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text("取消") }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
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 isLearnMode: Boolean = false,
|
||||||
|
val learningButtonId: String? = null,
|
||||||
|
val sendingButtonId: String? = null,
|
||||||
|
val loading: Boolean = true,
|
||||||
|
val message: PanelUiMessage? = null,
|
||||||
|
)
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
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.AddVirtualRemoteKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||||
|
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||||
|
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||||
|
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
|
||||||
|
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,
|
||||||
|
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
|
||||||
|
private val addVirtualRemoteKey: AddVirtualRemoteKey,
|
||||||
|
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
|
||||||
|
private val updateVirtualRemoteLayout: UpdateVirtualRemoteLayout,
|
||||||
|
) : 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 isLearnModeFlow = MutableStateFlow(false)
|
||||||
|
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class RemoteSnapshot(
|
||||||
|
val remote: VirtualRemote?,
|
||||||
|
val bindings: List<RemoteKeyBinding>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val snapshotFlow = combine(remoteFlow, bindingsFlow) { remote, bindings ->
|
||||||
|
RemoteSnapshot(remote, bindings)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ActionState(
|
||||||
|
val isLearnMode: Boolean,
|
||||||
|
val learningButtonId: String?,
|
||||||
|
val sendingButtonId: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val actionStateFlow = combine(
|
||||||
|
isLearnModeFlow,
|
||||||
|
learningButtonIdFlow,
|
||||||
|
sendingButtonIdFlow,
|
||||||
|
) { isLearn, learningBtn, sendingBtn ->
|
||||||
|
ActionState(isLearn, learningBtn, sendingBtn)
|
||||||
|
}
|
||||||
|
|
||||||
|
val state: StateFlow<RemotePanelUiState> = combine(
|
||||||
|
snapshotFlow,
|
||||||
|
localAcStateFlow,
|
||||||
|
actionStateFlow,
|
||||||
|
messageFlow,
|
||||||
|
) { snapshot, localAc, action, msg ->
|
||||||
|
val resolvedAc = localAc ?: snapshot.remote?.acState ?: AcStateSnapshot()
|
||||||
|
RemotePanelUiState(
|
||||||
|
profileId = profileId,
|
||||||
|
remote = snapshot.remote,
|
||||||
|
bindings = snapshot.bindings,
|
||||||
|
acState = resolvedAc,
|
||||||
|
isLearnMode = action.isLearnMode,
|
||||||
|
learningButtonId = action.learningButtonId,
|
||||||
|
sendingButtonId = action.sendingButtonId,
|
||||||
|
loading = snapshot.remote == null,
|
||||||
|
message = msg,
|
||||||
|
)
|
||||||
|
}.stateIn(
|
||||||
|
scope = viewModelScope,
|
||||||
|
started = SharingStarted.WhileSubscribed(5_000),
|
||||||
|
initialValue = RemotePanelUiState(profileId = profileId, loading = true),
|
||||||
|
)
|
||||||
|
|
||||||
|
fun toggleLearnMode() {
|
||||||
|
val newMode = !isLearnModeFlow.value
|
||||||
|
isLearnModeFlow.value = newMode
|
||||||
|
if (newMode) {
|
||||||
|
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onButtonClick(buttonId: String) {
|
||||||
|
if (isLearnModeFlow.value) {
|
||||||
|
learnButton(buttonId)
|
||||||
|
} else {
|
||||||
|
sendButton(buttonId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendButton(buttonId: String) {
|
||||||
|
val currentProfile = profileId ?: return
|
||||||
|
val currentRemoteId = remoteId ?: return
|
||||||
|
if (state.value.sendingButtonId != null || state.value.learningButtonId != 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 learnButton(buttonId: String) {
|
||||||
|
val currentProfile = profileId ?: return
|
||||||
|
val currentRemoteId = remoteId ?: return
|
||||||
|
if (state.value.learningButtonId != null) return
|
||||||
|
|
||||||
|
learningButtonIdFlow.value = buttonId
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf(buttonId)))
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> handleDomainError(res.error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
learningButtonIdFlow.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCustomButton(displayName: String) {
|
||||||
|
val currentProfile = profileId ?: return
|
||||||
|
val currentRemoteId = remoteId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val res = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已添加按键【${displayName}】,开启学习模式即可录制"))
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> handleDomainError(res.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteButton(buttonId: String) {
|
||||||
|
val currentProfile = profileId ?: return
|
||||||
|
val currentRemoteId = remoteId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val res = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("按键已删除"))
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> handleDomainError(res.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun switchLayout(newLayout: PanelLayoutStyle) {
|
||||||
|
val currentProfile = profileId ?: return
|
||||||
|
val currentRemoteId = remoteId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val res = updateVirtualRemoteLayout(currentProfile, currentRemoteId, newLayout)) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已切换面板布局风格"))
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> handleDomainError(res.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -326,6 +326,6 @@ class InfraredRemoteViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val DEFAULT_DEVICE_NAME = DeviceName("ir-1")
|
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+160
-325
@@ -9,16 +9,16 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -29,23 +29,21 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
import com.flagship.abox.manager.infrared.R
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
|
|
||||||
@@ -63,13 +61,7 @@ fun InfraredControlRoute(
|
|||||||
onSessionInvalid = onSessionInvalid,
|
onSessionInvalid = onSessionInvalid,
|
||||||
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
||||||
onSaveDeviceName = viewModel::saveDeviceName,
|
onSaveDeviceName = viewModel::saveDeviceName,
|
||||||
onLearn = viewModel::learnKey,
|
|
||||||
onRetry = viewModel::retryLearning,
|
|
||||||
onSend = viewModel::sendKey,
|
|
||||||
onRename = viewModel::renameKey,
|
|
||||||
onDelete = viewModel::deleteKey,
|
|
||||||
onMessageShown = viewModel::clearMessage,
|
onMessageShown = viewModel::clearMessage,
|
||||||
nextSlot = viewModel.nextAvailableKeySlot(),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,20 +73,11 @@ internal fun InfraredControlScreen(
|
|||||||
onSessionInvalid: () -> Unit,
|
onSessionInvalid: () -> Unit,
|
||||||
onDeviceNameChanged: (String) -> Unit,
|
onDeviceNameChanged: (String) -> Unit,
|
||||||
onSaveDeviceName: () -> Unit,
|
onSaveDeviceName: () -> Unit,
|
||||||
onLearn: (name: String, slot: String?) -> Unit,
|
|
||||||
onRetry: (InfraredKeyId) -> Unit,
|
|
||||||
onSend: (InfraredKeyId) -> Unit,
|
|
||||||
onRename: (InfraredKeyId, String) -> Unit,
|
|
||||||
onDelete: (InfraredKeyId) -> Unit,
|
|
||||||
onMessageShown: () -> Unit,
|
onMessageShown: () -> Unit,
|
||||||
nextSlot: Int,
|
|
||||||
) {
|
) {
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val scrollState = rememberScrollState()
|
||||||
var showLearnDialog by remember { mutableStateOf(false) }
|
|
||||||
var keyToRename by remember { mutableStateOf<SavedInfraredKey?>(null) }
|
|
||||||
var keyToDelete by remember { mutableStateOf<SavedInfraredKey?>(null) }
|
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
LaunchedEffect(state.message) {
|
||||||
state.message?.let { msg ->
|
state.message?.let { msg ->
|
||||||
@@ -141,113 +124,143 @@ internal fun InfraredControlScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding),
|
||||||
) {
|
) {
|
||||||
Column(
|
if (state.loading) {
|
||||||
modifier = Modifier
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
.fillMaxSize()
|
CircularProgressIndicator()
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// 环境与设备卡片
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.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,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.deviceNameInput,
|
|
||||||
onValueChange = onDeviceNameChanged,
|
|
||||||
label = { Text(stringResource(R.string.infrared_device_name_label)) },
|
|
||||||
placeholder = { Text(stringResource(R.string.infrared_device_name_hint)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.testTag("infrared_device_name_input"),
|
|
||||||
)
|
|
||||||
Button(
|
|
||||||
onClick = onSaveDeviceName,
|
|
||||||
modifier = Modifier.testTag("infrared_save_device_button"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_save_device))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
// 已学习按键列表卡片
|
Column(
|
||||||
Card(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
modifier = Modifier
|
||||||
Column(
|
.fillMaxSize()
|
||||||
modifier = Modifier
|
.verticalScroll(scrollState)
|
||||||
.fillMaxSize()
|
.padding(16.dp),
|
||||||
.padding(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
) {
|
||||||
) {
|
// 1. 当前环境卡片
|
||||||
Row(
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Column(
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
modifier = Modifier
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.infrared_keys_title),
|
text = "运行环境与连接信息",
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
)
|
)
|
||||||
Button(
|
|
||||||
onClick = { showLearnDialog = true },
|
|
||||||
modifier = Modifier.testTag("infrared_learn_new_key_button"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_learn_new_key))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.keys.isEmpty()) {
|
val envText = when (state.providerType) {
|
||||||
Box(
|
ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation)
|
||||||
modifier = Modifier
|
ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real)
|
||||||
.fillMaxWidth()
|
null -> state.profileId?.value ?: "未知环境"
|
||||||
.weight(1f),
|
}
|
||||||
contentAlignment = Alignment.Center,
|
Text(
|
||||||
) {
|
text = stringResource(R.string.infrared_mode_label, envText),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
|
||||||
|
state.providerDisplayName?.let {
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(R.string.infrared_no_keys),
|
text = "当前 Profile: $it",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
fontSize = 13.sp,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
LazyColumn(
|
}
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
// 2. ABSDK 硬件设备名称配置(修改当前设备名)
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.infrared_device_config_section),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.infrared_device_config_desc),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.infrared_device_name_current, state.savedDeviceName.value),
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
items(state.keys, key = { it.id.value }) { key ->
|
OutlinedTextField(
|
||||||
InfraredKeyRow(
|
value = state.deviceNameInput,
|
||||||
key = key,
|
onValueChange = onDeviceNameChanged,
|
||||||
isSending = state.sendingKeyIds.contains(key.id),
|
label = { Text("修改设备名(如:红外控制器)") },
|
||||||
onSend = { onSend(key.id) },
|
singleLine = true,
|
||||||
onRetry = { onRetry(key.id) },
|
modifier = Modifier
|
||||||
onRename = { keyToRename = key },
|
.weight(1f)
|
||||||
onDelete = { keyToDelete = key },
|
.testTag("infrared_device_name_input"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = onSaveDeviceName,
|
||||||
|
enabled = state.deviceNameInput.isNotBlank() && state.deviceNameInput != state.savedDeviceName.value,
|
||||||
|
modifier = Modifier.testTag("infrared_save_device_button"),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.infrared_device_save_changes))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 硬件槽位与遥控器概览
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.infrared_device_stats_section),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
|
||||||
|
QuotaDetailProgress(quota = state.slotQuota)
|
||||||
|
|
||||||
|
Card(
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.background,
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
) {
|
||||||
|
Text("💡 槽位工作原理:", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||||
|
Text("• 静态独占槽位 (Slot 1..99):用于物理学习录制的按键或预设静态按键,硬件直发低延迟(~20ms)。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
|
Text("• 动态共享缓冲 (Slot 100):用于空调全状态算法写码与动态模式遥控器,按需现场写入发射,不消耗独占槽位。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,213 +269,35 @@ internal fun InfraredControlScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showLearnDialog) {
|
|
||||||
var keyName by remember { mutableStateOf("") }
|
|
||||||
var keySlot by remember { mutableStateOf(nextSlot.toString()) }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { showLearnDialog = false },
|
|
||||||
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text(stringResource(R.string.infrared_learn_dialog_prompt))
|
|
||||||
OutlinedTextField(
|
|
||||||
value = keyName,
|
|
||||||
onValueChange = { keyName = it },
|
|
||||||
label = { Text(stringResource(R.string.infrared_learn_dialog_input_label)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().testTag("infrared_learn_name_input"),
|
|
||||||
)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = keySlot,
|
|
||||||
onValueChange = { keySlot = it },
|
|
||||||
label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().testTag("infrared_learn_slot_input"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onLearn(keyName, keySlot)
|
|
||||||
showLearnDialog = false
|
|
||||||
},
|
|
||||||
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
|
|
||||||
modifier = Modifier.testTag("infrared_learn_dialog_confirm"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_learn_dialog_start))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { showLearnDialog = false }) {
|
|
||||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
keyToRename?.let { targetKey ->
|
|
||||||
var newName by remember { mutableStateOf(targetKey.displayName) }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { keyToRename = null },
|
|
||||||
title = { Text(stringResource(R.string.infrared_rename_dialog_title)) },
|
|
||||||
text = {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = newName,
|
|
||||||
onValueChange = { newName = it },
|
|
||||||
label = { Text(stringResource(R.string.infrared_rename_dialog_input_label)) },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().testTag("infrared_rename_input"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onRename(targetKey.id, newName)
|
|
||||||
keyToRename = null
|
|
||||||
},
|
|
||||||
enabled = newName.isNotBlank(),
|
|
||||||
modifier = Modifier.testTag("infrared_rename_confirm"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_dialog_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { keyToRename = null }) {
|
|
||||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
keyToDelete?.let { targetKey ->
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = { keyToDelete = null },
|
|
||||||
title = { Text(stringResource(R.string.infrared_delete_dialog_title)) },
|
|
||||||
text = { Text(stringResource(R.string.infrared_delete_dialog_message)) },
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
onDelete(targetKey.id)
|
|
||||||
keyToDelete = null
|
|
||||||
},
|
|
||||||
modifier = Modifier.testTag("infrared_delete_confirm"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_dialog_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = { keyToDelete = null }) {
|
|
||||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun InfraredKeyRow(
|
private fun QuotaDetailProgress(quota: SlotQuotaSummary) {
|
||||||
key: SavedInfraredKey,
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
isSending: Boolean,
|
Row(
|
||||||
onSend: () -> Unit,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onRetry: () -> Unit,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
onRename: () -> Unit,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
onDelete: () -> Unit,
|
) {
|
||||||
) {
|
Text(
|
||||||
Card(
|
text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}",
|
||||||
modifier = Modifier
|
fontWeight = FontWeight.Medium,
|
||||||
.fillMaxWidth()
|
fontSize = 13.sp,
|
||||||
.testTag("infrared_key_${key.id.value}"),
|
)
|
||||||
) {
|
Text(
|
||||||
Column(
|
text = "剩余可用静态槽位: ${quota.remainingStaticCount}",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(12.dp),
|
.height(8.dp)
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
.clip(RoundedCornerShape(4.dp)),
|
||||||
) {
|
)
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = key.displayName,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val statusLabel = when (key.status) {
|
|
||||||
InfraredKeyStatus.LEARNING -> stringResource(R.string.infrared_status_learning)
|
|
||||||
InfraredKeyStatus.READY -> stringResource(R.string.infrared_status_ready)
|
|
||||||
InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_status_failed)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = statusLabel,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = when (key.status) {
|
|
||||||
InfraredKeyStatus.LEARNING -> MaterialTheme.colorScheme.primary
|
|
||||||
InfraredKeyStatus.READY -> MaterialTheme.colorScheme.tertiary
|
|
||||||
InfraredKeyStatus.FAILED -> MaterialTheme.colorScheme.error
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.End,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
if (key.status == InfraredKeyStatus.READY) {
|
|
||||||
Button(
|
|
||||||
onClick = onSend,
|
|
||||||
enabled = !isSending,
|
|
||||||
modifier = Modifier.testTag("infrared_send_${key.id.value}"),
|
|
||||||
) {
|
|
||||||
if (isSending) {
|
|
||||||
Text(stringResource(R.string.infrared_sending))
|
|
||||||
} else {
|
|
||||||
Text(stringResource(R.string.infrared_send))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.status == InfraredKeyStatus.FAILED) {
|
|
||||||
Button(
|
|
||||||
onClick = onRetry,
|
|
||||||
modifier = Modifier.testTag("infrared_retry_${key.id.value}"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_retry))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.status != InfraredKeyStatus.LEARNING) {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onRename,
|
|
||||||
modifier = Modifier.testTag("infrared_rename_btn_${key.id.value}"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_rename))
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onDelete,
|
|
||||||
modifier = Modifier.testTag("infrared_delete_btn_${key.id.value}"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_delete))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.height(24.dp).width(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-10
@@ -3,9 +3,7 @@ package com.flagship.abox.manager.infrared.ui
|
|||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
|
|
||||||
sealed interface InfraredUiMessage {
|
sealed interface InfraredUiMessage {
|
||||||
@@ -19,12 +17,9 @@ data class InfraredControlUiState(
|
|||||||
val providerType: ProviderType? = null,
|
val providerType: ProviderType? = null,
|
||||||
val providerDisplayName: String? = null,
|
val providerDisplayName: String? = null,
|
||||||
val deviceNameInput: String = "",
|
val deviceNameInput: String = "",
|
||||||
val savedDeviceName: DeviceName = DeviceName("ir-1"),
|
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
|
||||||
val keys: List<SavedInfraredKey> = emptyList(),
|
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||||
|
val totalRemotesCount: Int = 0,
|
||||||
val loading: Boolean = true,
|
val loading: Boolean = true,
|
||||||
val sendingKeyIds: Set<InfraredKeyId> = emptySet(),
|
|
||||||
val message: InfraredUiMessage? = null,
|
val message: InfraredUiMessage? = null,
|
||||||
) {
|
)
|
||||||
val hasActiveLearning: Boolean
|
|
||||||
get() = keys.any { it.status == InfraredKeyStatus.LEARNING }
|
|
||||||
}
|
|
||||||
|
|||||||
+30
-153
@@ -8,18 +8,10 @@ import com.flagship.abox.manager.domain.DomainError
|
|||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||||
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
import com.flagship.abox.manager.infrared.R
|
||||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
@@ -30,7 +22,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
@@ -39,34 +30,22 @@ import javax.inject.Inject
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class InfraredControlViewModel @Inject constructor(
|
class InfraredControlViewModel @Inject constructor(
|
||||||
savedStateHandle: SavedStateHandle,
|
savedStateHandle: SavedStateHandle,
|
||||||
private val keyRepository: InfraredKeyRepository,
|
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||||
|
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||||
private val providerProfileStore: ProviderProfileStore,
|
private val providerProfileStore: ProviderProfileStore,
|
||||||
private val learningCoordinator: InfraredLearningCoordinator,
|
|
||||||
private val sendInfraredKey: SendInfraredKey,
|
|
||||||
private val renameInfraredKey: RenameInfraredKey,
|
|
||||||
private val deleteInfraredKey: DeleteInfraredKey,
|
|
||||||
private val recoverInterruptedInfraredLearning: RecoverInterruptedInfraredLearning,
|
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||||
|
|
||||||
private val deviceNameInputFlow = MutableStateFlow("")
|
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
|
||||||
private val sendingKeyIdsFlow = MutableStateFlow<Set<InfraredKeyId>>(emptySet())
|
|
||||||
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
||||||
|
|
||||||
init {
|
|
||||||
if (profileId != null) {
|
|
||||||
val startupCutoff = System.currentTimeMillis()
|
|
||||||
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ServerState(
|
private data class ServerState(
|
||||||
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
|
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
|
||||||
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
||||||
val keys: List<SavedInfraredKey> = emptyList(),
|
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||||
|
val remotesCount: Int = 0,
|
||||||
val loading: Boolean = false,
|
val loading: Boolean = false,
|
||||||
val configError: DomainError? = null,
|
val configError: DomainError? = null,
|
||||||
)
|
)
|
||||||
@@ -87,19 +66,20 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
is DomainResult.Success -> {
|
is DomainResult.Success -> {
|
||||||
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
||||||
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
|
combine(
|
||||||
when (keysResult) {
|
virtualRemoteRepository.observeRemotes(profileId, deviceName),
|
||||||
is DomainResult.Failure -> ServerState(
|
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
|
||||||
providerProfile = active,
|
) { remotesResult, quotaResult ->
|
||||||
savedDevice = deviceName,
|
val remotes = (remotesResult as? DomainResult.Success)?.value ?: emptyList()
|
||||||
configError = keysResult.error,
|
val quota = (quotaResult as? DomainResult.Success)?.value
|
||||||
)
|
?: SlotQuotaSummary()
|
||||||
is DomainResult.Success -> ServerState(
|
ServerState(
|
||||||
providerProfile = active,
|
providerProfile = active,
|
||||||
savedDevice = deviceName,
|
savedDevice = deviceName,
|
||||||
keys = keysResult.value,
|
slotQuota = quota,
|
||||||
)
|
remotesCount = remotes.size,
|
||||||
}
|
loading = false,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,9 +90,8 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
val state: StateFlow<InfraredControlUiState> = combine(
|
val state: StateFlow<InfraredControlUiState> = combine(
|
||||||
serverStateFlow,
|
serverStateFlow,
|
||||||
deviceNameInputFlow,
|
deviceNameInputFlow,
|
||||||
sendingKeyIdsFlow,
|
|
||||||
messageFlow,
|
messageFlow,
|
||||||
) { serverState, input, sendingIds, msg ->
|
) { serverState, input, msg ->
|
||||||
val resolvedMessage = msg ?: serverState.configError?.let { error ->
|
val resolvedMessage = msg ?: serverState.configError?.let { error ->
|
||||||
when (error) {
|
when (error) {
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
|
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
|
||||||
@@ -125,15 +104,18 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val effectiveInput = input ?: serverState.savedDevice.value
|
||||||
|
|
||||||
InfraredControlUiState(
|
InfraredControlUiState(
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
providerType = serverState.providerProfile?.type,
|
providerType = serverState.providerProfile?.type,
|
||||||
providerDisplayName = serverState.providerProfile?.displayName,
|
providerDisplayName = serverState.providerProfile?.displayName,
|
||||||
deviceNameInput = input,
|
deviceNameInput = effectiveInput,
|
||||||
savedDeviceName = serverState.savedDevice,
|
savedDeviceName = serverState.savedDevice,
|
||||||
keys = serverState.keys,
|
slotQuota = serverState.slotQuota,
|
||||||
|
totalRemotesCount = serverState.remotesCount,
|
||||||
loading = serverState.loading,
|
loading = serverState.loading,
|
||||||
sendingKeyIds = sendingIds,
|
|
||||||
message = resolvedMessage,
|
message = resolvedMessage,
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
@@ -148,7 +130,8 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun saveDeviceName() {
|
fun saveDeviceName() {
|
||||||
val currentProfile = profileId ?: return
|
val currentProfile = profileId ?: return
|
||||||
val trimmed = deviceNameInputFlow.value.trim()
|
val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value
|
||||||
|
val trimmed = input.trim()
|
||||||
if (trimmed.isBlank()) {
|
if (trimmed.isBlank()) {
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
messageFlow.value = InfraredUiMessage.Error(
|
||||||
UiText.StringResource(R.string.infrared_msg_device_name_blank),
|
UiText.StringResource(R.string.infrared_msg_device_name_blank),
|
||||||
@@ -161,112 +144,6 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
messageFlow.value = InfraredUiMessage.Info(
|
messageFlow.value = InfraredUiMessage.Info(
|
||||||
UiText.StringResource(R.string.infrared_msg_device_name_saved),
|
UiText.StringResource(R.string.infrared_msg_device_name_saved),
|
||||||
)
|
)
|
||||||
deviceNameInputFlow.value = ""
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
handleDomainError(result.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun nextAvailableKeySlot(): Int {
|
|
||||||
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
|
|
||||||
return (1..100).firstOrNull { it !in usedSlots } ?: 1
|
|
||||||
}
|
|
||||||
|
|
||||||
fun learnKey(displayName: String, keySlotInput: String? = null) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val savedDevice = state.value.savedDeviceName
|
|
||||||
val trimmedName = displayName.trim()
|
|
||||||
if (trimmedName.isBlank()) {
|
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_key_name_blank),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) {
|
|
||||||
val slotInt = keySlotInput.trim().toIntOrNull()
|
|
||||||
if (slotInt == null || slotInt !in 1..100) {
|
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_slot_invalid),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
slotInt.toString()
|
|
||||||
} else {
|
|
||||||
nextAvailableKeySlot().toString()
|
|
||||||
}
|
|
||||||
learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun retryLearning(keyId: InfraredKeyId) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
learningCoordinator.retry(InfraredKeyRef(currentProfile, keyId))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendKey(keyId: InfraredKeyId) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
if (state.value.sendingKeyIds.contains(keyId)) return
|
|
||||||
|
|
||||||
val key = state.value.keys.find { it.id == keyId }
|
|
||||||
if (key == null || key.status != InfraredKeyStatus.READY) {
|
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_key_not_ready),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value + keyId
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = InfraredUiMessage.Info(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(key.displayName)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
handleDomainError(result.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sendingKeyIdsFlow.value = sendingKeyIdsFlow.value - keyId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun renameKey(keyId: InfraredKeyId, displayName: String) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val trimmed = displayName.trim()
|
|
||||||
if (trimmed.isBlank()) {
|
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_key_name_blank),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val result = renameInfraredKey(InfraredKeyRef(currentProfile, keyId), trimmed)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = InfraredUiMessage.Info(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_key_renamed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
handleDomainError(result.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteKey(keyId: InfraredKeyId) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val result = deleteInfraredKey(InfraredKeyRef(currentProfile, keyId))) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = InfraredUiMessage.Info(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_key_deleted),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is DomainResult.Failure -> {
|
is DomainResult.Failure -> {
|
||||||
handleDomainError(result.error)
|
handleDomainError(result.error)
|
||||||
@@ -302,6 +179,6 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val DEFAULT_DEVICE_NAME = DeviceName("ir-1")
|
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,62 +1,62 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="infrared_title">红外控制</string>
|
<string name="infrared_title">红外遥控</string>
|
||||||
<string name="infrared_mode_fake">模式:模拟 / Fake</string>
|
<string name="infrared_mode_fake">模式:模拟体验</string>
|
||||||
<string name="infrared_mode_real">模式:真实 ABSDK</string>
|
<string name="infrared_mode_real">模式:ABBox 真实硬件</string>
|
||||||
<string name="infrared_switch_mode">切换模式</string>
|
<string name="infrared_switch_mode">切换模式</string>
|
||||||
<string name="infrared_device_name_label">红外设备名称</string>
|
<string name="infrared_device_name_label">红外设备名称</string>
|
||||||
<string name="infrared_device_name_hint">例如:ir-living</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_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_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_learn_new_key">学习新按键</string>
|
||||||
<string name="infrared_status_learning">学习中…</string>
|
<string name="infrared_status_learning">学习中…</string>
|
||||||
<string name="infrared_status_ready">就绪</string>
|
<string name="infrared_status_ready">已就绪</string>
|
||||||
<string name="infrared_status_failed">失败</string>
|
<string name="infrared_status_failed">学习失败</string>
|
||||||
<string name="infrared_send">发射</string>
|
<string name="infrared_send">发射</string>
|
||||||
<string name="infrared_sending">发射中…</string>
|
<string name="infrared_sending">发射中…</string>
|
||||||
<string name="infrared_retry">重试</string>
|
<string name="infrared_retry">重试</string>
|
||||||
<string name="infrared_rename">重命名</string>
|
<string name="infrared_rename">重命名</string>
|
||||||
<string name="infrared_delete">删除</string>
|
<string name="infrared_delete">删除</string>
|
||||||
<string name="infrared_learn_dialog_title">学习红外按键</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_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_key_slot_label">按键编号:%1$s</string>
|
||||||
<string name="infrared_learn_dialog_start">开始学习</string>
|
<string name="infrared_learn_dialog_start">开始学习</string>
|
||||||
<string name="infrared_rename_dialog_title">重命名按键</string>
|
<string name="infrared_rename_dialog_title">重命名按键</string>
|
||||||
<string name="infrared_rename_dialog_input_label">新按键名称</string>
|
<string name="infrared_rename_dialog_input_label">新按键名称</string>
|
||||||
<string name="infrared_delete_dialog_title">删除按键</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_cancel">取消</string>
|
||||||
<string name="infrared_dialog_confirm">确认</string>
|
<string name="infrared_dialog_confirm">确认</string>
|
||||||
<string name="infrared_back">返回</string>
|
<string name="infrared_back">返回</string>
|
||||||
<string name="infrared_remote_title">红外遥控器</string>
|
<string name="infrared_remote_title">红外遥控器</string>
|
||||||
<string name="infrared_remote_recording">录制中</string>
|
<string name="infrared_remote_recording">录制中</string>
|
||||||
<string name="infrared_remote_learning_mode">学习模式</string>
|
<string name="infrared_remote_learning_mode">学习模式</string>
|
||||||
<string name="infrared_remote_learning_banner">🔴 红外学习模式已开启:点击任意按键即可录制信号</string>
|
<string name="infrared_remote_learning_banner">🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号</string>
|
||||||
<string name="infrared_remote_scene_ac">❄️ 空调遥控</string>
|
<string name="infrared_remote_scene_ac">❄️ 空调面板</string>
|
||||||
<string name="infrared_remote_scene_tv">📺 电视遥控</string>
|
<string name="infrared_remote_scene_tv">📺 电视面板</string>
|
||||||
<string name="infrared_remote_settings">设置</string>
|
<string name="infrared_remote_settings">设置</string>
|
||||||
|
|
||||||
<string name="infrared_msg_learning_mode_enabled">🔴 学习录制模式已开启:点击任意按键即可开始录制</string>
|
<string name="infrared_msg_learning_mode_enabled">🔴 学习模式已开启:点击任意按键开始录制</string>
|
||||||
<string name="infrared_msg_recording_key">正在录制【%1$s】(Slot %2$s),请按遥控器按键...</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_learned_success">【%1$s】录制成功</string>
|
||||||
<string name="infrared_msg_key_not_learned">按键【%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_signal_sent">已发射信号:%1$s</string>
|
||||||
<string name="infrared_msg_device_offline">红外控制器离线 (错误码: %1$s)</string>
|
<string name="infrared_msg_device_offline">红外设备离线(错误码:%1$s)</string>
|
||||||
<string name="infrared_msg_load_failed">数据加载失败</string>
|
<string name="infrared_msg_load_failed">加载数据失败</string>
|
||||||
<string name="infrared_msg_operation_failed">操作失败</string>
|
<string name="infrared_msg_operation_failed">操作失败</string>
|
||||||
<string name="infrared_label_temp_control">温度调节</string>
|
<string name="infrared_label_temp_control">温度调节</string>
|
||||||
<string name="infrared_label_vol">音量 VOL</string>
|
<string name="infrared_label_vol">音量</string>
|
||||||
<string name="infrared_label_ch">频道 CH</string>
|
<string name="infrared_label_ch">频道</string>
|
||||||
<string name="infrared_label_cool_mode">❄️ 制冷模式</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_power">空调-电源</string>
|
||||||
<string name="infrared_key_ac_temp_up">空调-温度+</string>
|
<string name="infrared_key_ac_temp_up">空调-升温</string>
|
||||||
<string name="infrared_key_ac_temp_down">空调-温度-</string>
|
<string name="infrared_key_ac_temp_down">空调-降温</string>
|
||||||
<string name="infrared_key_ac_mode">空调-模式</string>
|
<string name="infrared_key_ac_mode">空调-模式</string>
|
||||||
<string name="infrared_key_ac_fan">空调-风速</string>
|
<string name="infrared_key_ac_fan">空调-风速</string>
|
||||||
<string name="infrared_key_ac_swing">空调-扫风</string>
|
<string name="infrared_key_ac_swing">空调-扫风</string>
|
||||||
@@ -64,10 +64,10 @@
|
|||||||
|
|
||||||
<string name="infrared_key_tv_power">电视-电源</string>
|
<string name="infrared_key_tv_power">电视-电源</string>
|
||||||
<string name="infrared_key_tv_mute">电视-静音</string>
|
<string name="infrared_key_tv_mute">电视-静音</string>
|
||||||
<string name="infrared_key_tv_nav_up">电视-上</string>
|
<string name="infrared_key_tv_nav_up">电视-向上</string>
|
||||||
<string name="infrared_key_tv_nav_down">电视-下</string>
|
<string name="infrared_key_tv_nav_down">电视-向下</string>
|
||||||
<string name="infrared_key_tv_nav_left">电视-左</string>
|
<string name="infrared_key_tv_nav_left">电视-向左</string>
|
||||||
<string name="infrared_key_tv_nav_right">电视-右</string>
|
<string name="infrared_key_tv_nav_right">电视-向右</string>
|
||||||
<string name="infrared_key_tv_ok">电视-确定</string>
|
<string name="infrared_key_tv_ok">电视-确定</string>
|
||||||
<string name="infrared_key_tv_vol_up">电视-音量+</string>
|
<string name="infrared_key_tv_vol_up">电视-音量+</string>
|
||||||
<string name="infrared_key_tv_vol_down">电视-音量-</string>
|
<string name="infrared_key_tv_vol_down">电视-音量-</string>
|
||||||
@@ -75,16 +75,42 @@
|
|||||||
<string name="infrared_key_tv_ch_down">电视-频道-</string>
|
<string name="infrared_key_tv_ch_down">电视-频道-</string>
|
||||||
<string name="infrared_key_tv_home">电视-主页</string>
|
<string name="infrared_key_tv_home">电视-主页</string>
|
||||||
<string name="infrared_key_tv_back">电视-返回</string>
|
<string name="infrared_key_tv_back">电视-返回</string>
|
||||||
<string name="infrared_key_tv_menu">电视-菜单</string>
|
<string name="infrared_control_title">红外设备与环境配置</string>
|
||||||
<string name="infrared_control_title">红外按键管理</string>
|
<string name="infrared_device_config_section">ABSDK 设备名称配置</string>
|
||||||
|
<string name="infrared_device_config_desc">ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string>
|
||||||
|
<string name="infrared_device_name_current">当前绑定设备:%1$s</string>
|
||||||
|
<string name="infrared_device_save_changes">保存修改</string>
|
||||||
|
<string name="infrared_device_stats_section">硬件槽位与遥控器概览</string>
|
||||||
<string name="infrared_mode_label">运行环境:%1$s</string>
|
<string name="infrared_mode_label">运行环境:%1$s</string>
|
||||||
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
|
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
|
||||||
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
|
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
|
||||||
<string name="infrared_msg_device_name_saved">设备名称已保存</string>
|
<string name="infrared_msg_device_name_saved">设备名称已成功保存</string>
|
||||||
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
|
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
|
||||||
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
|
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
|
||||||
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
|
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
|
||||||
<string name="infrared_msg_key_renamed">按键已重命名</string>
|
<string name="infrared_msg_key_renamed">按键已重命名</string>
|
||||||
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
|
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
|
||||||
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
|
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
|
||||||
|
<string name="infrared_hub_title">红外中枢</string>
|
||||||
|
<string name="infrared_hub_my_remotes">我的遥控器</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>
|
</resources>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
<string name="infrared_back">Back</string>
|
<string name="infrared_back">Back</string>
|
||||||
<string name="infrared_remote_title">Infrared Remote</string>
|
<string name="infrared_remote_title">Infrared Remote</string>
|
||||||
<string name="infrared_remote_recording">Recording</string>
|
<string name="infrared_remote_recording">Recording</string>
|
||||||
|
<string name="infrared_hub_my_remotes">My Remotes</string>
|
||||||
<string name="infrared_remote_learning_mode">Learn Mode</string>
|
<string name="infrared_remote_learning_mode">Learn Mode</string>
|
||||||
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
|
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
|
||||||
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
|
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
|
||||||
@@ -76,15 +77,41 @@
|
|||||||
<string name="infrared_key_tv_home">TV Home</string>
|
<string name="infrared_key_tv_home">TV Home</string>
|
||||||
<string name="infrared_key_tv_back">TV Back</string>
|
<string name="infrared_key_tv_back">TV Back</string>
|
||||||
<string name="infrared_key_tv_menu">TV Menu</string>
|
<string name="infrared_key_tv_menu">TV Menu</string>
|
||||||
<string name="infrared_control_title">Infrared Key Management</string>
|
<string name="infrared_control_title">Device Configuration</string>
|
||||||
|
<string name="infrared_device_config_section">ABSDK Device Name</string>
|
||||||
|
<string name="infrared_device_config_desc">ABSDK matches hardware devices by device name (default: 红外控制器). Commands will be dispatched to this target device.</string>
|
||||||
|
<string name="infrared_device_name_current">Current Bound Device: %1$s</string>
|
||||||
|
<string name="infrared_device_save_changes">Save Changes</string>
|
||||||
|
<string name="infrared_device_stats_section">Hardware Slot Budget</string>
|
||||||
<string name="infrared_mode_label">Environment: %1$s</string>
|
<string name="infrared_mode_label">Environment: %1$s</string>
|
||||||
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
|
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
|
||||||
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
|
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
|
||||||
<string name="infrared_msg_device_name_saved">Device name saved</string>
|
<string name="infrared_msg_device_name_saved">Device name saved successfully</string>
|
||||||
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
|
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
|
||||||
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
|
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
|
||||||
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
|
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
|
||||||
<string name="infrared_msg_key_renamed">Key renamed</string>
|
<string name="infrared_msg_key_renamed">Key renamed</string>
|
||||||
<string name="infrared_msg_key_deleted">Key removed locally</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_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>
|
</resources>
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun createCustomRemoteWithTvLayoutAssignsLayoutAndStaticSlots() = testScope.runTest {
|
||||||
|
profileStore.saveProfile(providerProfile)
|
||||||
|
deviceRepo.setDeviceName(profileId, device)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
val job = launch { vm.state.collect {} }
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.createRemote(
|
||||||
|
name = "Custom TV Remote",
|
||||||
|
type = RemoteDeviceType.CUSTOM,
|
||||||
|
brandModel = null,
|
||||||
|
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||||
|
layoutStyle = com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV,
|
||||||
|
)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, vm.state.value.remotes.size)
|
||||||
|
val created = vm.state.value.remotes.first()
|
||||||
|
assertEquals("Custom TV Remote", created.displayName)
|
||||||
|
assertEquals(com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, created.layoutStyle)
|
||||||
|
assertEquals(TransmissionMode.STATIC_SLOTS, created.transmissionMode)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
+229
@@ -0,0 +1,229 @@
|
|||||||
|
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.AddVirtualRemoteKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
||||||
|
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||||
|
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
|
||||||
|
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||||
|
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.UpdateVirtualRemoteLayout
|
||||||
|
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.Assert.assertTrue
|
||||||
|
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)
|
||||||
|
private val learnKey = LearnVirtualRemoteKey(remoteRepo, gateway)
|
||||||
|
private val addKey = AddVirtualRemoteKey(remoteRepo)
|
||||||
|
private val deleteKey = DeleteVirtualRemoteKey(remoteRepo)
|
||||||
|
private val updateLayout = UpdateVirtualRemoteLayout(remoteRepo)
|
||||||
|
|
||||||
|
@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,
|
||||||
|
learnVirtualRemoteKey = learnKey,
|
||||||
|
addVirtualRemoteKey = addKey,
|
||||||
|
deleteVirtualRemoteKey = deleteKey,
|
||||||
|
updateVirtualRemoteLayout = updateLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tvButtonTriggersInstantTransmissionInNormalMode() = 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 learnModeToggledTriggersLearningWhenButtonIsClicked() = 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()
|
||||||
|
|
||||||
|
// 1. Toggle learn mode ON
|
||||||
|
vm.toggleLearnMode()
|
||||||
|
runCurrent()
|
||||||
|
assertTrue(vm.state.value.isLearnMode)
|
||||||
|
|
||||||
|
// 2. Click button -> triggers learn instead of send
|
||||||
|
vm.onButtonClick("TV_POWER")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(1, gateway.learnedKeys.size)
|
||||||
|
assertEquals("1", gateway.learnedKeys.first().key)
|
||||||
|
assertEquals(0, gateway.sentKeys.size)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addAndRemoveCustomButtonUpdatesBindings() = testScope.runTest {
|
||||||
|
val res = createRemote(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = device,
|
||||||
|
displayName = "Custom",
|
||||||
|
type = RemoteDeviceType.CUSTOM,
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Add button
|
||||||
|
vm.addCustomButton("Sound Effect")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val added = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
|
||||||
|
assertNotNull(added)
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
vm.deleteButton(added!!.buttonId)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val afterDelete = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
|
||||||
|
assertEquals(null, afterDelete)
|
||||||
|
|
||||||
|
job.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun switchLayoutUpdatesRemoteLayoutStyle() = testScope.runTest {
|
||||||
|
val res = createRemote(
|
||||||
|
profileId = profileId,
|
||||||
|
deviceName = device,
|
||||||
|
displayName = "Custom Box",
|
||||||
|
type = RemoteDeviceType.CUSTOM,
|
||||||
|
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()
|
||||||
|
|
||||||
|
vm.switchLayout(PanelLayoutStyle.TV_NAV)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(PanelLayoutStyle.TV_NAV, vm.state.value.remote?.layoutStyle)
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-145
@@ -2,29 +2,19 @@ package com.flagship.abox.manager.infrared.ui
|
|||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
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.ProviderProfile
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
import com.flagship.abox.manager.domain.ProviderType
|
||||||
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
|
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
import com.flagship.abox.manager.infrared.R
|
||||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
import com.flagship.abox.manager.infrared.remote.UiText
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||||
|
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -36,8 +26,6 @@ import kotlinx.coroutines.test.runTest
|
|||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@@ -55,25 +43,15 @@ class InfraredControlViewModelTest {
|
|||||||
private val device = DeviceName("ir-living")
|
private val device = DeviceName("ir-living")
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
private val clock = EpochMillisClock { 10_000L }
|
||||||
|
|
||||||
private val keyRepo = FakeInfraredKeyRepository()
|
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||||
|
private val remoteRepo = FakeVirtualRemoteRepository()
|
||||||
private val profileStore = FakeProviderProfileStore()
|
private val profileStore = FakeProviderProfileStore()
|
||||||
private val gateway = FakeInfraredGateway()
|
private val gateway = FakeInfraredGateway()
|
||||||
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
|
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
|
||||||
|
|
||||||
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
|
|
||||||
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
|
|
||||||
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock)
|
|
||||||
private val send = SendInfraredKey(keyRepo, gateway)
|
|
||||||
private val rename = RenameInfraredKey(keyRepo, clock)
|
|
||||||
private val delete = DeleteInfraredKey(keyRepo)
|
|
||||||
private val recover = RecoverInterruptedInfraredLearning(keyRepo, clock)
|
|
||||||
private lateinit var coordinator: InfraredLearningCoordinator
|
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setup() {
|
fun setup() {
|
||||||
Dispatchers.setMain(testDispatcher)
|
Dispatchers.setMain(testDispatcher)
|
||||||
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
@@ -84,34 +62,16 @@ class InfraredControlViewModelTest {
|
|||||||
private fun createViewModel(): InfraredControlViewModel {
|
private fun createViewModel(): InfraredControlViewModel {
|
||||||
return InfraredControlViewModel(
|
return InfraredControlViewModel(
|
||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
||||||
keyRepository = keyRepo,
|
|
||||||
deviceConfigurationRepository = deviceRepo,
|
deviceConfigurationRepository = deviceRepo,
|
||||||
|
virtualRemoteRepository = remoteRepo,
|
||||||
providerProfileStore = profileStore,
|
providerProfileStore = profileStore,
|
||||||
learningCoordinator = coordinator,
|
|
||||||
sendInfraredKey = send,
|
|
||||||
renameInfraredKey = rename,
|
|
||||||
deleteInfraredKey = delete,
|
|
||||||
recoverInterruptedInfraredLearning = recover,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun initialStateLoadsKeysAndDeviceNameForProfile() = testScope.runTest {
|
fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = testScope.runTest {
|
||||||
profileStore.saveProfile(providerProfile)
|
profileStore.saveProfile(providerProfile)
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
deviceRepo.setDeviceName(profileId, device)
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
@@ -119,8 +79,8 @@ class InfraredControlViewModelTest {
|
|||||||
|
|
||||||
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
||||||
assertEquals(device, vm.state.value.savedDeviceName)
|
assertEquals(device, vm.state.value.savedDeviceName)
|
||||||
assertEquals(1, vm.state.value.keys.size)
|
assertEquals("ir-living", vm.state.value.deviceNameInput)
|
||||||
assertEquals("Power", vm.state.value.keys.first().displayName)
|
assertEquals(0, vm.state.value.totalRemotesCount)
|
||||||
|
|
||||||
job.cancel()
|
job.cancel()
|
||||||
}
|
}
|
||||||
@@ -165,111 +125,24 @@ class InfraredControlViewModelTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun learnKeyTriggersCoordinator() = testScope.runTest {
|
fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest {
|
||||||
profileStore.saveProfile(providerProfile)
|
profileStore.saveProfile(providerProfile)
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
deviceRepo.setDeviceName(profileId, device)
|
||||||
val vm = createViewModel()
|
createRemote(
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.learnKey("Volume Up", "5")
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
assertEquals("5", gateway.learnedKeys.first().key)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sendKeyTriggersSendInfraredKeyWhenReady() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
deviceName = device,
|
deviceName = device,
|
||||||
displayName = "Power",
|
displayName = "Living TV",
|
||||||
sdkKey = "1",
|
type = RemoteDeviceType.TV,
|
||||||
status = InfraredKeyStatus.READY,
|
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
)
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
val job = launch { vm.state.collect {} }
|
val job = launch { vm.state.collect {} }
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
|
|
||||||
vm.sendKey(InfraredKeyId("k1"))
|
assertEquals(1, vm.state.value.totalRemotesCount)
|
||||||
advanceUntilIdle()
|
assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size)
|
||||||
|
assertEquals(16, vm.state.value.slotQuota.usedCount)
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
assertEquals("1", gateway.sentKeys.first().key)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renameKeyUpdatesKeyName() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.renameKey(InfraredKeyId("k1"), "Main Power")
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val updated = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals("Main Power", updated.displayName)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteKeyRemovesKey() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.deleteKey(InfraredKeyId("k1"))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val result = (keyRepo.getKey(InfraredKeyRef(profileId, InfraredKeyId("k1"))) as DomainResult.Success).value
|
|
||||||
assertEquals(null, result)
|
|
||||||
|
|
||||||
job.cancel()
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
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.ProviderProfileId
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class AddVirtualRemoteKey(
|
||||||
|
private val repository: VirtualRemoteRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
remoteId: VirtualRemoteId,
|
||||||
|
displayName: String,
|
||||||
|
): DomainResult<RemoteKeyBinding> {
|
||||||
|
val trimmed = displayName.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button name cannot be blank"))
|
||||||
|
}
|
||||||
|
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 buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase()
|
||||||
|
val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
|
||||||
|
// Find next available slot
|
||||||
|
val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
|
||||||
|
val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
||||||
|
val nextSlot = (1..99).firstOrNull { it !in usedSlots }
|
||||||
|
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"))
|
||||||
|
nextSlot.toString()
|
||||||
|
} else null
|
||||||
|
|
||||||
|
val binding = RemoteKeyBinding(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
remoteId = remoteId,
|
||||||
|
profileId = profileId,
|
||||||
|
buttonId = buttonId,
|
||||||
|
displayName = trimmed,
|
||||||
|
assignedSlot = assignedSlot,
|
||||||
|
rawHexCode = null,
|
||||||
|
status = InfraredKeyStatus.FAILED, // Initially unlearned
|
||||||
|
)
|
||||||
|
|
||||||
|
when (val res = repository.updateKeyBinding(binding)) {
|
||||||
|
is DomainResult.Failure -> return res
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
return DomainResult.Success(binding)
|
||||||
|
}
|
||||||
|
}
|
||||||
+110
@@ -0,0 +1,110 @@
|
|||||||
|
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 = if (type == RemoteDeviceType.CUSTOM) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH,
|
||||||
|
layoutStyle: PanelLayoutStyle? = null,
|
||||||
|
): DomainResult<VirtualRemoteId> {
|
||||||
|
val trimmedName = displayName.trim()
|
||||||
|
if (trimmedName.isBlank()) {
|
||||||
|
return DomainResult.Failure(
|
||||||
|
DomainError.InvalidRequest(detail = "Remote display name cannot be blank"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvedLayout = layoutStyle ?: VirtualRemote.defaultLayoutFor(type)
|
||||||
|
val resolvedTransMode = if (type == RemoteDeviceType.CUSTOM) {
|
||||||
|
TransmissionMode.STATIC_SLOTS
|
||||||
|
} else {
|
||||||
|
transmissionMode
|
||||||
|
}
|
||||||
|
|
||||||
|
val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel, resolvedLayout)
|
||||||
|
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,
|
||||||
|
layoutStyle = resolvedLayout,
|
||||||
|
transmissionMode = resolvedTransMode,
|
||||||
|
dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT,
|
||||||
|
acState = initialAcState,
|
||||||
|
createdAtEpochMillis = now,
|
||||||
|
updatedAtEpochMillis = now,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build key bindings
|
||||||
|
val bindings = if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) {
|
||||||
|
templates.mapIndexed { index, template ->
|
||||||
|
RemoteKeyBinding(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
remoteId = remoteId,
|
||||||
|
profileId = profileId,
|
||||||
|
buttonId = template.buttonId,
|
||||||
|
displayName = template.displayName,
|
||||||
|
assignedSlot = (index + 1).toString(),
|
||||||
|
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 (resolvedTransMode == 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -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)
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
package com.flagship.abox.manager.domain.infrared
|
||||||
|
|
||||||
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
class DeleteVirtualRemoteKey(
|
||||||
|
private val repository: VirtualRemoteRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
remoteId: VirtualRemoteId,
|
||||||
|
buttonId: String,
|
||||||
|
): DomainResult<Unit> = repository.deleteKeyBinding(remoteId, buttonId)
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
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.InfraredGateway
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
|
||||||
|
class LearnVirtualRemoteKey(
|
||||||
|
private val repository: VirtualRemoteRepository,
|
||||||
|
private val gateway: InfraredGateway,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
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 not found"))
|
||||||
|
|
||||||
|
val targetSlot = binding.assignedSlot ?: run {
|
||||||
|
val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
|
||||||
|
val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
||||||
|
val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1
|
||||||
|
nextSlot.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set status to LEARNING while hardware listens
|
||||||
|
repository.updateKeyBinding(
|
||||||
|
binding.copy(
|
||||||
|
status = InfraredKeyStatus.LEARNING,
|
||||||
|
assignedSlot = targetSlot,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return when (val learnResult = gateway.learnKey(profileId, remote.deviceName, targetSlot)) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
repository.updateKeyBinding(
|
||||||
|
binding.copy(
|
||||||
|
status = InfraredKeyStatus.READY,
|
||||||
|
assignedSlot = targetSlot,
|
||||||
|
lastErrorCode = null,
|
||||||
|
lastErrorDetail = null,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
DomainResult.Success(Unit)
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
repository.updateKeyBinding(
|
||||||
|
binding.copy(
|
||||||
|
status = InfraredKeyStatus.FAILED,
|
||||||
|
assignedSlot = targetSlot,
|
||||||
|
lastErrorCode = learnResult.error.externalCode,
|
||||||
|
lastErrorDetail = learnResult.error.detail,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
learnResult
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
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,
|
||||||
|
layoutStyle: PanelLayoutStyle? = null,
|
||||||
|
): List<PresetButtonTemplate> = when (type) {
|
||||||
|
RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true)
|
||||||
|
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 -> when (layoutStyle) {
|
||||||
|
PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false)
|
||||||
|
PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false)
|
||||||
|
PanelLayoutStyle.AC_COMPACT -> emptyList()
|
||||||
|
PanelLayoutStyle.GRID, null -> listOf(
|
||||||
|
PresetButtonTemplate("CUSTOM_POWER", "电源"),
|
||||||
|
PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"),
|
||||||
|
PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"),
|
||||||
|
PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTvTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
|
||||||
|
PresetButtonTemplate("TV_POWER", "电源", if (hasHexCode) "0x00FF02FD" else null),
|
||||||
|
PresetButtonTemplate("TV_MUTE", "静音", if (hasHexCode) "0x00FF04FB" else null),
|
||||||
|
PresetButtonTemplate("TV_SOURCE", "信号源", if (hasHexCode) "0x00FF06F9" else null),
|
||||||
|
PresetButtonTemplate("TV_NAV_UP", "上", if (hasHexCode) "0x00FF08F7" else null),
|
||||||
|
PresetButtonTemplate("TV_NAV_DOWN", "下", if (hasHexCode) "0x00FF0AF5" else null),
|
||||||
|
PresetButtonTemplate("TV_NAV_LEFT", "左", if (hasHexCode) "0x00FF0CF3" else null),
|
||||||
|
PresetButtonTemplate("TV_NAV_RIGHT", "右", if (hasHexCode) "0x00FF0EF1" else null),
|
||||||
|
PresetButtonTemplate("TV_OK", "确定", if (hasHexCode) "0x00FF10EF" else null),
|
||||||
|
PresetButtonTemplate("TV_HOME", "主页", if (hasHexCode) "0x00FF12ED" else null),
|
||||||
|
PresetButtonTemplate("TV_BACK", "返回", if (hasHexCode) "0x00FF14EB" else null),
|
||||||
|
PresetButtonTemplate("TV_MENU", "菜单", if (hasHexCode) "0x00FF16E9" else null),
|
||||||
|
PresetButtonTemplate("TV_VOL_UP", "音量 +", if (hasHexCode) "0x00FF18E7" else null),
|
||||||
|
PresetButtonTemplate("TV_VOL_DOWN", "音量 -", if (hasHexCode) "0x00FF1AE5" else null),
|
||||||
|
PresetButtonTemplate("TV_CH_UP", "频道 +", if (hasHexCode) "0x00FF1CE3" else null),
|
||||||
|
PresetButtonTemplate("TV_CH_DOWN", "频道 -", if (hasHexCode) "0x00FF1EE1" else null),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun getMediaPlayerTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
|
||||||
|
PresetButtonTemplate("MEDIA_POWER", "电源", if (hasHexCode) "0x02FD02FD" else null),
|
||||||
|
PresetButtonTemplate("MEDIA_MUTE", "静音", if (hasHexCode) "0x02FD04FB" else null),
|
||||||
|
PresetButtonTemplate("MEDIA_PLAY_PAUSE", "播放/暂停", if (hasHexCode) "0x02FD06F9" else null),
|
||||||
|
PresetButtonTemplate("MEDIA_PREV", "上一曲", if (hasHexCode) "0x02FD08F7" else null),
|
||||||
|
PresetButtonTemplate("MEDIA_NEXT", "下一曲", if (hasHexCode) "0x02FD0AF5" else null),
|
||||||
|
PresetButtonTemplate("MEDIA_REWIND", "快退", if (hasHexCode) "0x02FD0CF3" else null),
|
||||||
|
PresetButtonTemplate("MEDIA_FF", "快进", if (hasHexCode) "0x02FD0EF1" else null),
|
||||||
|
)
|
||||||
|
}
|
||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
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 -> {
|
||||||
|
if (binding.rawHexCode != null) {
|
||||||
|
// 1. Download to scratch slot
|
||||||
|
val scratchSlot = remote.dynamicScratchSlot
|
||||||
|
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, binding.rawHexCode)))) {
|
||||||
|
is DomainResult.Failure -> return dlResult
|
||||||
|
is DomainResult.Success -> Unit
|
||||||
|
}
|
||||||
|
// 2. Send scratch slot
|
||||||
|
gateway.sendKey(profileId, remote.deviceName, scratchSlot)
|
||||||
|
} else if (binding.assignedSlot != null) {
|
||||||
|
// Fallback to sending learned hardware slot
|
||||||
|
gateway.sendKey(profileId, remote.deviceName, binding.assignedSlot)
|
||||||
|
} else {
|
||||||
|
DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code or slot available for transmission"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
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.ProviderProfileId
|
||||||
|
|
||||||
|
class UpdateVirtualRemoteLayout(
|
||||||
|
private val repository: VirtualRemoteRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
remoteId: VirtualRemoteId,
|
||||||
|
layoutStyle: PanelLayoutStyle,
|
||||||
|
): 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 updated = remote.copy(
|
||||||
|
layoutStyle = layoutStyle,
|
||||||
|
updatedAtEpochMillis = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
return repository.updateRemote(updated)
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
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 PanelLayoutStyle {
|
||||||
|
TV_NAV,
|
||||||
|
GRID,
|
||||||
|
MEDIA_PLAYER,
|
||||||
|
AC_COMPACT,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 layoutStyle: PanelLayoutStyle = defaultLayoutFor(type),
|
||||||
|
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"
|
||||||
|
|
||||||
|
fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) {
|
||||||
|
RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV
|
||||||
|
RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT
|
||||||
|
RemoteDeviceType.FAN -> PanelLayoutStyle.GRID
|
||||||
|
RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
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 deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult<Unit>
|
||||||
|
|
||||||
|
suspend fun updateAcState(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
remoteId: VirtualRemoteId,
|
||||||
|
state: AcStateSnapshot,
|
||||||
|
): DomainResult<Unit>
|
||||||
|
}
|
||||||
+113
-1
@@ -202,6 +202,7 @@ class RecordingInfraredGateway : InfraredGateway {
|
|||||||
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
|
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
|
||||||
val learnedKeys = mutableListOf<InfraredLearningCall>()
|
val learnedKeys = mutableListOf<InfraredLearningCall>()
|
||||||
val sentKeys = mutableListOf<InfraredSendCall>()
|
val sentKeys = mutableListOf<InfraredSendCall>()
|
||||||
|
val downloadedCodes = mutableListOf<InfraredCode>()
|
||||||
|
|
||||||
override suspend fun learnKey(
|
override suspend fun learnKey(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
@@ -224,10 +225,121 @@ class RecordingInfraredGateway : InfraredGateway {
|
|||||||
override suspend fun downloadCodes(
|
override suspend fun downloadCodes(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
codes: List<InfraredCode>,
|
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 deleteKeyBinding(
|
||||||
|
remoteId: VirtualRemoteId,
|
||||||
|
buttonId: String,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
|
||||||
|
list.removeAll { it.buttonId == buttonId }
|
||||||
|
bindings[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(
|
fun SavedInfraredKey.ref() = InfraredKeyRef(
|
||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
keyId = id,
|
keyId = id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+119
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -129,6 +129,9 @@ class FakeInfraredGateway : InfraredGateway {
|
|||||||
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val downloadedCodes: List<InfraredCode>
|
||||||
|
get() = downloadCalls.flatMap { it.codes }
|
||||||
|
|
||||||
override suspend fun downloadCodes(
|
override suspend fun downloadCodes(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
codes: List<InfraredCode>,
|
codes: List<InfraredCode>,
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
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 deleteKeyBinding(
|
||||||
|
remoteId: VirtualRemoteId,
|
||||||
|
buttonId: String,
|
||||||
|
): DomainResult<Unit> {
|
||||||
|
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
|
||||||
|
list.removeAll { it.buttonId == buttonId }
|
||||||
|
bindings[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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user