feat(infrared): add infrared control, learning, and virtual remote module (#9)

### 概述 / Summary
完整实现红外控制与学习模块(`:android:feature:infrared`),支持红外设备按键学习、实体发射、Room 本地持久化,以及空调 (AC) / 电视 (TV) 虚拟遥控器面板。

### 核心变更 / Key Changes
1. **领域模型与用例 (`:domain`)**:
   - 实现按键学习流程:`LearnInfraredKey`、`ManageInfraredKey`、`InfraredKeyFactory`;
   - 增加虚拟遥控器模型与用例:`CreateVirtualRemote`、`LearnVirtualRemoteKey`、`SendVirtualRemoteAction`、`AcCodeGenerator` 与预置码库 `PresetCodeLibrary`。
2. **数据持久化与仓储 (`:android:feature:infrared`)**:
   - 引入 Room 数据库 `InfraredDatabase` 及 DAO,提供 `RoomInfraredKeyRepository` 与 `RoomVirtualRemoteRepository`;
   - 支持多配置隔离(`ProviderProfileId`)、按键状态管理(`LEARNING` / `READY` / `FAILED`)。
3. **UI 与 ViewModel (`:android:feature:infrared`)**:
   - `InfraredControlScreen`:红外学习与按键调试列表;
   - `RemotePanelScreen` / `AcRemotePanel` / `TvRemotePanel`:空调/电视专属可视化遥控面板;
   - `InfraredHubScreen`:红外中枢与多遥控器管理入口;
   - 在认证后导航栈中将 `DeviceListScreen` 的红外入口无缝对接到 `InfraredHubRoute`。
4. **测试与文档**:
   - 补齐用例、Fakes、Repository、ViewModel 以及 Compose UI 自动化测试;
   - 补充红外控制设计规范与真机硬件验收清单。

Reviewed-on: #9
Co-authored-by: mxhsnd <2049295946@qq.com>
Co-authored-by: KremeCN <3265494849@qq.com>
This commit is contained in:
2026-08-25 15:00:06 +08:00
committed by KremeCN
co-authored by KremeCN
parent 249721be33
commit 0c783ca2f4
78 changed files with 12785 additions and 15 deletions
@@ -0,0 +1,194 @@
package com.flagship.abox.manager.infrared.data.local
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
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.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RoomInfraredKeyRepositoryTest {
private lateinit var database: InfraredDatabase
private lateinit var repository: RoomInfraredKeyRepository
@Before
fun setup() {
val context = ApplicationProvider.getApplicationContext<Context>()
database = Room.inMemoryDatabaseBuilder(context, InfraredDatabase::class.java)
.allowMainThreadQueries()
.build()
repository = RoomInfraredKeyRepository(database.infraredDao())
}
@After
fun teardown() {
database.close()
}
@Test
fun observesOnlyRequestedProfileAndDevice() = runTest {
repository.insert(testKey(profile = "one", device = "ir-1", id = "a", sdkKey = "key_a"))
repository.insert(testKey(profile = "one", device = "ir-2", id = "b", sdkKey = "key_b"))
repository.insert(testKey(profile = "two", device = "ir-1", id = "c", sdkKey = "key_c"))
val result = repository.observeKeys(
ProviderProfileId("one"),
DeviceName("ir-1"),
).first() as DomainResult.Success
assertEquals(listOf(InfraredKeyId("a")), result.value.map { it.id })
}
@Test
fun rejectsDuplicateSdkBinding() = runTest {
val first = repository.insert(testKey(id = "a", sdkKey = "app_ir_power"))
assertTrue(first is DomainResult.Success)
val duplicate = repository.insert(testKey(id = "b", sdkKey = "app_ir_power"))
assertTrue(duplicate is DomainResult.Failure)
}
@Test
fun transitionsStatusCorrectly() = runTest {
val key = testKey(id = "a", status = InfraredKeyStatus.LEARNING)
repository.insert(key)
val transitionResult = repository.transitionStatus(
ref = InfraredKeyRef(key.profileId, key.id),
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
targetStatus = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
updatedAtEpochMillis = 2_000L,
)
assertTrue(transitionResult is DomainResult.Success)
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, fetched.status)
assertEquals(2_000L, fetched.updatedAtEpochMillis)
}
@Test
fun renamesKeyWithoutChangingSdkKey() = runTest {
val key = testKey(id = "a", displayName = "Old Name", sdkKey = "app_ir_power")
repository.insert(key)
val renameResult = repository.rename(
ref = InfraredKeyRef(key.profileId, key.id),
displayName = "New Name",
updatedAtEpochMillis = 3_000L,
)
assertTrue(renameResult is DomainResult.Success)
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
assertEquals("New Name", fetched.displayName)
assertEquals("app_ir_power", fetched.sdkKey)
assertEquals(3_000L, fetched.updatedAtEpochMillis)
}
@Test
fun deleteRejectsLearningKeyAndRemovesReadyKey() = runTest {
val learningKey = testKey(id = "learning", status = InfraredKeyStatus.LEARNING)
val readyKey = testKey(id = "ready", sdkKey = "ready_sdk_key", status = InfraredKeyStatus.READY)
repository.insert(learningKey)
repository.insert(readyKey)
val deleteLearning = repository.deleteIfNotLearning(InfraredKeyRef(learningKey.profileId, learningKey.id))
assertTrue(deleteLearning is DomainResult.Failure)
val deleteReady = repository.deleteIfNotLearning(InfraredKeyRef(readyKey.profileId, readyKey.id))
assertTrue(deleteReady is DomainResult.Success)
val fetchedReady = (repository.getKey(InfraredKeyRef(readyKey.profileId, readyKey.id)) as DomainResult.Success).value
assertNull(fetchedReady)
}
@Test
fun failsInterruptedLearningKeys() = runTest {
repository.insert(testKey(id = "k1", sdkKey = "k1", status = InfraredKeyStatus.LEARNING))
repository.insert(testKey(id = "k2", sdkKey = "k2", status = InfraredKeyStatus.LEARNING))
repository.insert(testKey(id = "k3", sdkKey = "k3", status = InfraredKeyStatus.READY))
val countResult = repository.failInterruptedLearning(ProviderProfileId("profile1"), 5_000L) as DomainResult.Success
assertEquals(2, countResult.value)
val k1 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, k1.status)
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, k1.lastErrorDetail)
assertNull(k1.lastErrorCode)
assertEquals(5_000L, k1.updatedAtEpochMillis)
val k3 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k3"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, k3.status)
}
@Test
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L))
repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L))
val countResult = repository.failInterruptedLearning(
profileId = ProviderProfileId("profile1"),
cutoffEpochMillis = 150L,
updatedAtEpochMillis = 150L,
) as DomainResult.Success
assertEquals(1, countResult.value)
val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, oldK.status)
val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.LEARNING, newK.status)
}
@Test
fun persistsAndObservesDeviceName() = runTest {
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
assertNull(initial.value)
val setResult = repository.setDeviceName(ProviderProfileId("profile1"), DeviceName("ir_main"))
assertTrue(setResult is DomainResult.Success)
val observed = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
assertEquals(DeviceName("ir_main"), observed.value)
}
private fun testKey(
profile: String = "profile1",
device: String = "ir-1",
id: String = "key-1",
displayName: String = "Power",
sdkKey: String = "app_ir_key1",
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
): SavedInfraredKey {
return SavedInfraredKey(
id = InfraredKeyId(id),
profileId = ProviderProfileId(profile),
deviceName = DeviceName(device),
displayName = displayName,
sdkKey = sdkKey,
status = status,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 1_000L,
updatedAtEpochMillis = 1_000L,
)
}
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.infrared.remote
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredRemoteScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersAcPanelAndTvPanelCorrectly() {
val state = InfraredRemoteUiState(
selectedScene = RemoteScene.AIR_CONDITIONER,
savedDeviceName = DeviceName("ir-1"),
)
var clickedSpec: RemoteButtonSpec? = null
var settingsClicked = false
composeTestRule.setContent {
InfraredRemoteScreen(
state = state,
onBack = {},
onOpenSettings = { settingsClicked = true },
onSessionInvalid = {},
onSceneSelected = {},
onToggleLearningMode = {},
onButtonClick = { clickedSpec = it },
onMessageShown = {},
)
}
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
assertEquals("AC_POWER", clickedSpec?.id)
composeTestRule.onNodeWithTag("remote_settings_button").assertIsDisplayed().performClick()
assertTrue(settingsClicked)
}
}
@@ -0,0 +1,77 @@
package com.flagship.abox.manager.infrared.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
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.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredControlScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun rendersControlScreenAndTriggersKeyOperations() {
val testKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = ProviderProfileId("profile1"),
deviceName = DeviceName("ir-1"),
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
val state = InfraredControlUiState(
profileId = ProviderProfileId("profile1"),
providerType = ProviderType.LOCAL_EXPERIENCE,
savedDeviceName = DeviceName("ir-1"),
keys = listOf(testKey),
loading = false,
)
var sendClicked = false
var deleteClicked = false
composeTestRule.setContent {
InfraredControlScreen(
state = state,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onSaveDeviceName = {},
onLearn = { _, _ -> },
onRetry = {},
onSend = { sendClicked = true },
onRename = { _, _ -> },
onDelete = { deleteClicked = true },
onMessageShown = {},
nextSlot = 2,
)
}
composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick()
assertTrue(sendClicked)
composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick()
composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick()
assertTrue(deleteClicked)
}
}
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,72 @@
package com.flagship.abox.manager.infrared.application
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.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class InfraredLearningCoordinator(
private val learnInfraredKey: LearnInfraredKey,
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
private val relearnInfraredKey: RelearnInfraredKey,
private val keyRepository: InfraredKeyRepository,
private val externalScope: CoroutineScope,
) {
private val mutex = Mutex()
private var cleanupJob: Job? = null
fun learn(
profileId: ProviderProfileId,
deviceName: DeviceName,
displayName: String,
sdkKey: String? = null,
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
}
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
retryInfraredKeyLearning(ref)
}
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
mutex.withLock {
cleanupJob?.join()
}
relearnInfraredKey(ref)
}
fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long = System.currentTimeMillis(),
updatedAtEpochMillis: Long = cutoffEpochMillis,
): Job {
val job = externalScope.launch(NonCancellable) {
keyRepository.failInterruptedLearning(
profileId = profileId,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
cleanupJob = job
return job
}
}
@@ -0,0 +1,222 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface InfraredDao {
@Query(
"""
SELECT * FROM infrared_keys
WHERE profileId = :profileId AND deviceName = :deviceName
ORDER BY createdAtEpochMillis ASC
""",
)
fun observeKeys(profileId: String, deviceName: String): Flow<List<InfraredKeyEntity>>
@Query(
"""
SELECT * FROM infrared_keys
WHERE profileId = :profileId AND id = :id
LIMIT 1
""",
)
suspend fun getKey(profileId: String, id: String): InfraredKeyEntity?
@Insert(onConflict = OnConflictStrategy.ABORT)
suspend fun insertKey(entity: InfraredKeyEntity)
@Query(
"""
UPDATE infrared_keys
SET status = :targetStatus,
lastErrorCode = :lastErrorCode,
lastErrorDetail = :lastErrorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId
AND id = :id
AND status IN (:expectedStatuses)
""",
)
suspend fun transitionStatus(
profileId: String,
id: String,
expectedStatuses: List<String>,
targetStatus: String,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
UPDATE infrared_keys
SET displayName = :displayName,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND id = :id
""",
)
suspend fun renameKey(
profileId: String,
id: String,
displayName: String,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
DELETE FROM infrared_keys
WHERE profileId = :profileId AND id = :id AND status != 'LEARNING'
""",
)
suspend fun deleteKeyIfNotLearning(profileId: String, id: String): Int
@Query(
"""
UPDATE infrared_keys
SET status = :targetStatus,
lastErrorCode = NULL,
lastErrorDetail = :errorDetail,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId
AND status = 'LEARNING'
AND createdAtEpochMillis <= :cutoffEpochMillis
""",
)
suspend fun failInterruptedLearning(
profileId: String,
targetStatus: String,
errorDetail: String,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): Int
@Query(
"""
SELECT * FROM infrared_device_configuration
WHERE profileId = :profileId
LIMIT 1
""",
)
fun observeDeviceName(profileId: String): Flow<InfraredDeviceConfigurationEntity?>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity)
// Virtual Remotes
@Query(
"""
SELECT * FROM virtual_remotes
WHERE profileId = :profileId AND deviceName = :deviceName
ORDER BY createdAtEpochMillis DESC
""",
)
fun observeVirtualRemotes(profileId: String, deviceName: String): Flow<List<VirtualRemoteEntity>>
@Query(
"""
SELECT * FROM virtual_remotes
WHERE profileId = :profileId AND id = :id
LIMIT 1
""",
)
fun observeVirtualRemote(profileId: String, id: String): Flow<VirtualRemoteEntity?>
@Query(
"""
SELECT * FROM virtual_remotes
WHERE profileId = :profileId AND id = :id
LIMIT 1
""",
)
suspend fun getVirtualRemote(profileId: String, id: String): VirtualRemoteEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertVirtualRemote(entity: VirtualRemoteEntity)
@Query(
"""
DELETE FROM virtual_remotes
WHERE profileId = :profileId AND id = :id
""",
)
suspend fun deleteVirtualRemote(profileId: String, id: String): Int
@Query(
"""
UPDATE virtual_remotes
SET acPower = :power,
acMode = :mode,
acTemperature = :temperature,
acFanSpeed = :fanSpeed,
acVerticalSwing = :verticalSwing,
acHorizontalSwing = :horizontalSwing,
acEco = :eco,
updatedAtEpochMillis = :updatedAtEpochMillis
WHERE profileId = :profileId AND id = :id
""",
)
suspend fun updateAcState(
profileId: String,
id: String,
power: Boolean?,
mode: String?,
temperature: Int?,
fanSpeed: String?,
verticalSwing: Boolean?,
horizontalSwing: Boolean?,
eco: Boolean?,
updatedAtEpochMillis: Long,
): Int
// Remote Key Bindings
@Query(
"""
SELECT * FROM remote_key_bindings
WHERE remoteId = :remoteId
""",
)
fun observeKeyBindings(remoteId: String): Flow<List<RemoteKeyBindingEntity>>
@Query(
"""
SELECT * FROM remote_key_bindings
WHERE remoteId = :remoteId
""",
)
suspend fun getKeyBindings(remoteId: String): List<RemoteKeyBindingEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertKeyBindings(entities: List<RemoteKeyBindingEntity>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertKeyBinding(entity: RemoteKeyBindingEntity)
@Query(
"""
DELETE FROM remote_key_bindings
WHERE remoteId = :remoteId
""",
)
suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int
@Query(
"""
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>>
}
@@ -0,0 +1,18 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(
entities = [
InfraredKeyEntity::class,
InfraredDeviceConfigurationEntity::class,
VirtualRemoteEntity::class,
RemoteKeyBindingEntity::class,
],
version = 2,
exportSchema = false,
)
abstract class InfraredDatabase : RoomDatabase() {
abstract fun infraredDao(): InfraredDao
}
@@ -0,0 +1,83 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(
tableName = "infrared_keys",
indices = [
Index(value = ["profileId", "deviceName"]),
Index(value = ["profileId", "deviceName", "sdkKey"], unique = true),
],
)
data class InfraredKeyEntity(
@PrimaryKey
val id: String,
val profileId: String,
val deviceName: String,
val displayName: String,
val sdkKey: String,
val status: String,
val lastErrorCode: String?,
val lastErrorDetail: String?,
val createdAtEpochMillis: Long,
val updatedAtEpochMillis: Long,
)
@Entity(tableName = "infrared_device_configuration")
data class InfraredDeviceConfigurationEntity(
@PrimaryKey
val profileId: String,
val deviceName: String,
)
@Entity(
tableName = "virtual_remotes",
indices = [
Index(value = ["profileId", "deviceName"]),
],
)
data class VirtualRemoteEntity(
@PrimaryKey
val id: String,
val profileId: String,
val deviceName: String,
val displayName: String,
val type: String,
val brandModel: String?,
val 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?,
)
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.infrared.data.local
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
fun InfraredKeyEntity.toDomain(): SavedInfraredKey {
return SavedInfraredKey(
id = InfraredKeyId(id),
profileId = ProviderProfileId(profileId),
deviceName = DeviceName(deviceName),
displayName = displayName,
sdkKey = sdkKey,
status = InfraredKeyStatus.valueOf(status),
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
fun SavedInfraredKey.toEntity(): InfraredKeyEntity {
return InfraredKeyEntity(
id = id.value,
profileId = profileId.value,
deviceName = deviceName.value,
displayName = displayName,
sdkKey = sdkKey,
status = status.name,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
createdAtEpochMillis = createdAtEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
}
@@ -0,0 +1,201 @@
package com.flagship.abox.manager.infrared.data.local
import android.database.sqlite.SQLiteConstraintException
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
class RoomInfraredKeyRepository(
private val dao: InfraredDao,
) : InfraredKeyRepository, InfraredDeviceConfigurationRepository {
override fun observeKeys(
profileId: ProviderProfileId,
deviceName: DeviceName,
): Flow<DomainResult<List<SavedInfraredKey>>> {
return dao.observeKeys(profileId.value, deviceName.value)
.map<List<InfraredKeyEntity>, DomainResult<List<SavedInfraredKey>>> { entities ->
DomainResult.Success(entities.map { it.toDomain() })
}
.catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
override fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>> {
return dao.observeDeviceName(profileId.value)
.map<InfraredDeviceConfigurationEntity?, DomainResult<DeviceName?>> { entity ->
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
}
.catch { throwable ->
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
}
}
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
return try {
val entity = dao.getKey(ref.profileId.value, ref.keyId.value)
DomainResult.Success(entity?.toDomain())
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
return try {
dao.insertKey(key.toEntity())
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun transitionStatus(
ref: InfraredKeyRef,
expectedStatuses: Set<InfraredKeyStatus>,
targetStatus: InfraredKeyStatus,
lastErrorCode: String?,
lastErrorDetail: String?,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
return try {
val updated = dao.transitionStatus(
profileId = ref.profileId.value,
id = ref.keyId.value,
expectedStatuses = expectedStatuses.map { it.name },
targetStatus = targetStatus.name,
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
updatedAtEpochMillis = updatedAtEpochMillis,
)
if (updated > 0) {
DomainResult.Success(Unit)
} else {
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
if (existing == null) {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
} else {
DomainResult.Failure(
DomainError.InvalidRequest(
detail = "Cannot transition key from ${existing.status} to ${targetStatus.name}",
),
)
}
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun rename(
ref: InfraredKeyRef,
displayName: String,
updatedAtEpochMillis: Long,
): DomainResult<Unit> {
val trimmed = displayName.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Display name cannot be blank"))
}
return try {
val updated = dao.renameKey(
profileId = ref.profileId.value,
id = ref.keyId.value,
displayName = trimmed,
updatedAtEpochMillis = updatedAtEpochMillis,
)
if (updated > 0) {
DomainResult.Success(Unit)
} else {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
return try {
val deleted = dao.deleteKeyIfNotLearning(ref.profileId.value, ref.keyId.value)
if (deleted > 0) {
DomainResult.Success(Unit)
} else {
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
if (existing == null) {
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
} else {
DomainResult.Failure(
DomainError.InvalidRequest(detail = "Cannot delete key while learning is in progress"),
)
}
}
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun failInterruptedLearning(
profileId: ProviderProfileId,
cutoffEpochMillis: Long,
updatedAtEpochMillis: Long,
): DomainResult<Int> {
return try {
val count = dao.failInterruptedLearning(
profileId = profileId.value,
targetStatus = InfraredKeyStatus.FAILED.name,
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
cutoffEpochMillis = cutoffEpochMillis,
updatedAtEpochMillis = updatedAtEpochMillis,
)
DomainResult.Success(count)
} catch (throwable: Throwable) {
if (throwable is CancellationException) {
throw throwable
}
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
override suspend fun setDeviceName(
profileId: ProviderProfileId,
deviceName: DeviceName,
): DomainResult<Unit> {
val trimmed = deviceName.value.trim()
if (trimmed.isBlank()) {
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name cannot be blank"))
}
return try {
dao.setDeviceName(
InfraredDeviceConfigurationEntity(
profileId = profileId.value,
deviceName = trimmed,
),
)
DomainResult.Success(Unit)
} catch (throwable: Throwable) {
DomainResult.Failure(mapThrowableToDomainError(throwable))
}
}
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
if (throwable is kotlinx.coroutines.CancellationException) {
throw throwable
}
return when (throwable) {
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
else -> DomainError.Unknown(detail = "Database operation failed")
}
}
}
@@ -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,
)
@@ -0,0 +1,229 @@
package com.flagship.abox.manager.infrared.di
import android.content.Context
import androidx.room.Room
import com.flagship.abox.manager.domain.InfraredGateway
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyIdGenerator
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
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.SendInfraredKey
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import com.flagship.abox.manager.infrared.data.local.InfraredDao
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import java.util.UUID
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class InfraredApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object InfraredFeatureModule {
@Provides
@Singleton
fun provideInfraredDatabase(
@ApplicationContext context: Context,
): InfraredDatabase {
return Room.databaseBuilder(
context,
InfraredDatabase::class.java,
"abox_infrared.db",
).fallbackToDestructiveMigration().build()
}
@Provides
@Singleton
fun provideInfraredDao(database: InfraredDatabase): InfraredDao = database.infraredDao()
@Provides
@Singleton
fun provideRoomInfraredKeyRepository(dao: InfraredDao): RoomInfraredKeyRepository =
RoomInfraredKeyRepository(dao)
@Provides
@Singleton
fun provideInfraredKeyRepository(
repository: RoomInfraredKeyRepository,
): InfraredKeyRepository = repository
@Provides
@Singleton
fun provideInfraredDeviceConfigurationRepository(
repository: RoomInfraredKeyRepository,
): InfraredDeviceConfigurationRepository = repository
@Provides
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
@Provides
fun provideEpochMillisClock(): EpochMillisClock =
EpochMillisClock { System.currentTimeMillis() }
@Provides
@Singleton
fun provideInfraredKeyFactory(
idGenerator: InfraredKeyIdGenerator,
clock: EpochMillisClock,
): InfraredKeyFactory = InfraredKeyFactory(idGenerator, clock)
@Provides
@Singleton
@InfraredApplicationScope
fun provideInfraredApplicationScope(): CoroutineScope =
CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Provides
@Singleton
fun provideLearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
factory: InfraredKeyFactory,
clock: EpochMillisClock,
): LearnInfraredKey = LearnInfraredKey(repository, gateway, factory, clock)
@Provides
@Singleton
fun provideRetryInfraredKeyLearning(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): RetryInfraredKeyLearning = RetryInfraredKeyLearning(repository, gateway, clock)
@Provides
@Singleton
fun provideSendInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
): SendInfraredKey = SendInfraredKey(repository, gateway)
@Provides
@Singleton
fun provideRenameInfraredKey(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RenameInfraredKey = RenameInfraredKey(repository, clock)
@Provides
@Singleton
fun provideDeleteInfraredKey(
repository: InfraredKeyRepository,
): DeleteInfraredKey = DeleteInfraredKey(repository)
@Provides
@Singleton
fun provideRecoverInterruptedInfraredLearning(
repository: InfraredKeyRepository,
clock: EpochMillisClock,
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
@Provides
@Singleton
fun provideRelearnInfraredKey(
repository: InfraredKeyRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey =
com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock)
@Provides
@Singleton
fun provideInfraredLearningCoordinator(
learnInfraredKey: LearnInfraredKey,
retryInfraredKeyLearning: RetryInfraredKeyLearning,
relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey,
repository: InfraredKeyRepository,
@InfraredApplicationScope scope: CoroutineScope,
): InfraredLearningCoordinator = InfraredLearningCoordinator(
learnInfraredKey = learnInfraredKey,
retryInfraredKeyLearning = retryInfraredKeyLearning,
relearnInfraredKey = relearnInfraredKey,
keyRepository = repository,
externalScope = scope,
)
@Provides
@Singleton
fun provideRoomVirtualRemoteRepository(dao: InfraredDao): RoomVirtualRemoteRepository =
RoomVirtualRemoteRepository(dao)
@Provides
@Singleton
fun provideVirtualRemoteRepository(
repository: RoomVirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository = repository
@Provides
@Singleton
fun provideCreateVirtualRemote(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
gateway: InfraredGateway,
clock: EpochMillisClock,
): com.flagship.abox.manager.domain.infrared.CreateVirtualRemote =
com.flagship.abox.manager.domain.infrared.CreateVirtualRemote(repository, gateway, clock)
@Provides
@Singleton
fun provideSendVirtualRemoteAction(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
gateway: InfraredGateway,
): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction =
com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway)
@Provides
@Singleton
fun provideDeleteVirtualRemote(
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote =
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository)
@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)
}
@@ -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))
}
},
)
}
@@ -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,
)
@@ -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("红外控制器")
}
}
@@ -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("取消") }
},
)
}
@@ -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,
)
@@ -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),
)
}
}
}
@@ -0,0 +1,331 @@
package com.flagship.abox.manager.infrared.remote
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.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
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
sealed interface RemoteUiMessage {
data class Info(val text: UiText) : RemoteUiMessage
data class Error(val text: UiText) : RemoteUiMessage
data object SessionInvalid : RemoteUiMessage
}
data class InfraredRemoteUiState(
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
val isLearningMode: Boolean = false,
val profileId: ProviderProfileId? = null,
val providerType: ProviderType? = null,
val providerDisplayName: String? = null,
val savedDeviceName: DeviceName? = null,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val sendingKeySlot: String? = null,
val activeLearningSlot: String? = null,
val message: RemoteUiMessage? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredRemoteViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val keyRepository: InfraredKeyRepository,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val providerProfileStore: ProviderProfileStore,
private val learningCoordinator: InfraredLearningCoordinator,
private val sendInfraredKey: SendInfraredKey,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
private val isLearningModeFlow = MutableStateFlow(false)
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
private var activeLearningJob: Job? = null
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
init {
if (profileId != null) {
val startupCutoff = System.currentTimeMillis()
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
}
}
private data class InfraredRemoteConfigSnapshot(
val providerProfile: ProviderProfile? = null,
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
val configError: DomainError? = null,
)
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
if (profileId == null) {
flowOf(InfraredRemoteConfigSnapshot())
} else {
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
val active = profiles.find { it.id == profileId }
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Failure -> flowOf(
InfraredRemoteConfigSnapshot(
providerProfile = active,
configError = deviceResult.error,
),
)
is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
when (keysResult) {
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
providerProfile = active,
savedDeviceName = deviceName,
configError = keysResult.error,
)
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
providerProfile = active,
savedDeviceName = deviceName,
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
)
}
}
}
}
}
}
}
val state: StateFlow<InfraredRemoteUiState> = combine(
sceneFlow,
isLearningModeFlow,
configSnapshotFlow,
combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg ->
Triple(sending, learning, msg)
},
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
is DomainError.DeviceOffline -> RemoteUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> RemoteUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_load_failed),
)
}
}
InfraredRemoteUiState(
selectedScene = scene,
isLearningMode = isLearning,
profileId = profileId,
providerType = snapshot.providerProfile?.type,
providerDisplayName = snapshot.providerProfile?.displayName,
savedDeviceName = snapshot.savedDeviceName,
learnedSlotMap = snapshot.learnedSlotMap,
sendingKeySlot = sendingSlot,
activeLearningSlot = learningSlot,
message = resolvedMessage,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredRemoteUiState(profileId = profileId),
)
fun switchScene(scene: RemoteScene) {
sceneFlow.value = scene
}
fun toggleLearningMode(enabled: Boolean) {
isLearningModeFlow.value = enabled
if (!enabled) {
val shutdownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
activeLearningSlotFlow.value = null
profileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
}
} else {
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_learning_mode_enabled),
)
}
}
fun onButtonClick(spec: RemoteButtonSpec) {
val currentProfile = profileId ?: return
val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME
if (isLearningModeFlow.value) {
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
return
}
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_recording_key,
listOf(spec.displayName, spec.defaultSlot),
),
)
activeLearningSlotFlow.value = spec.defaultSlot
val deferred = when (existingKey?.status) {
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
null -> learningCoordinator.learn(
profileId = currentProfile,
deviceName = currentDevice,
displayName = spec.displayName,
sdkKey = spec.defaultSlot,
)
}
underlyingLearningDeferred = deferred
val job = viewModelScope.launch {
try {
val result = if (existingKey != null) {
@Suppress("UNCHECKED_CAST")
(deferred as kotlinx.coroutines.Deferred<DomainResult<Unit>>).await()
} else {
@Suppress("UNCHECKED_CAST")
when (val res = (deferred as kotlinx.coroutines.Deferred<DomainResult<InfraredKeyId>>).await()) {
is DomainResult.Success -> DomainResult.Success(Unit)
is DomainResult.Failure -> DomainResult.Failure(res.error)
}
}
when (result) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_learned_success,
listOf(spec.displayName),
),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} finally {
if (underlyingLearningDeferred === deferred) {
underlyingLearningDeferred = null
}
if (activeLearningSlotFlow.value == spec.defaultSlot) {
activeLearningSlotFlow.value = null
}
}
}
activeLearningJob = job
} else {
if (sendingKeySlotFlow.value != null) {
return
}
val key = state.value.learnedSlotMap[spec.defaultSlot]
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) {
messageFlow.value = RemoteUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_key_not_learned,
listOf(spec.displayName),
),
)
return
}
sendingKeySlotFlow.value = spec.defaultSlot
viewModelScope.launch {
try {
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
is DomainResult.Success -> {
messageFlow.value = RemoteUiMessage.Info(
UiText.StringResource(
R.string.infrared_msg_signal_sent,
listOf(spec.displayName),
),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
} finally {
sendingKeySlotFlow.value = null
}
}
}
}
override fun onCleared() {
super.onCleared()
val teardownCutoff = System.currentTimeMillis()
underlyingLearningDeferred?.cancel()
underlyingLearningDeferred = null
activeLearningJob?.cancel()
activeLearningJob = null
profileId?.let { profile ->
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = RemoteUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = RemoteUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_device_offline,
listOf(error.externalCode ?: "20206"),
),
)
}
else -> {
messageFlow.value = RemoteUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
}
}
}
companion object {
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
}
}
@@ -0,0 +1,61 @@
package com.flagship.abox.manager.infrared.remote
import androidx.annotation.StringRes
import com.flagship.abox.manager.infrared.R
sealed interface UiText {
data class DynamicString(val value: String) : UiText
data class StringResource(
@StringRes val resId: Int,
val args: List<Any> = emptyList(),
) : UiText
}
enum class RemoteScene {
AIR_CONDITIONER,
TELEVISION,
}
data class RemoteButtonSpec(
val id: String,
val defaultSlot: String,
@StringRes val nameRes: Int,
val displayName: String,
val symbol: String,
)
object RemoteSceneConfig {
object AirConditioner {
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻")
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲")
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼")
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀")
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨")
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔")
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱")
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
}
object Television {
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻")
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇")
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲")
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼")
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀")
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶")
val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -")
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +")
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -")
val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠")
val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩")
val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰")
val buttons = listOf(
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU,
)
}
}
@@ -0,0 +1,174 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@Composable
fun AcRemotePanel(
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
modifier: Modifier = Modifier,
) {
RemoteChassis(modifier = modifier) {
// 顶部虚拟 LCD 液晶显示区
AcLcdDisplay()
// 电源与温度控制行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// 电源按键
val powerSpec = RemoteSceneConfig.AirConditioner.POWER
val isPowerLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY
val isPowerLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot
RemoteCircleButton(
spec = powerSpec,
isLearned = isPowerLearned,
isLoading = isPowerLoading,
isLearningMode = isLearningMode,
onClick = { onButtonClick(powerSpec) },
size = 72.dp,
containerColor = Color(0xFFE53935),
contentColor = Color.White,
)
// 垂直胶囊温度调节器
val tempUpSpec = RemoteSceneConfig.AirConditioner.TEMP_UP
val tempDownSpec = RemoteSceneConfig.AirConditioner.TEMP_DOWN
VerticalCapsuleRocker(
upSpec = tempUpSpec,
downSpec = tempDownSpec,
isUpLearned = learnedSlotMap[tempUpSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[tempDownSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == tempUpSpec.defaultSlot || sendingKeySlot == tempUpSpec.defaultSlot,
isDownLoading = activeLearningSlot == tempDownSpec.defaultSlot || sendingKeySlot == tempDownSpec.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(tempUpSpec) },
onDownClick = { onButtonClick(tempDownSpec) },
label = stringResource(R.string.infrared_label_temp_control),
)
}
Spacer(modifier = Modifier.height(4.dp))
// 功能按键网格 (模式 / 风速 / 扫风 / 定时)
Column(
verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AcGridButton(RemoteSceneConfig.AirConditioner.MODE, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
AcGridButton(RemoteSceneConfig.AirConditioner.FAN, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
AcGridButton(RemoteSceneConfig.AirConditioner.SWING, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
AcGridButton(RemoteSceneConfig.AirConditioner.TIMER, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
}
}
}
@Composable
private fun AcGridButton(
spec: RemoteButtonSpec,
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
) {
RemoteRectButton(
spec = spec,
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(spec) },
width = 120.dp,
height = 52.dp,
)
}
@Composable
private fun AcLcdDisplay() {
Card(
modifier = Modifier
.fillMaxWidth()
.height(96.dp)
.shadow(4.dp, shape = RoundedCornerShape(16.dp)),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF1E293B),
),
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text(
text = stringResource(R.string.infrared_label_cool_mode),
color = Color(0xFF38BDF8),
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Text(
text = stringResource(R.string.infrared_label_ac_status),
color = Color(0xFF94A3B8),
fontSize = 12.sp,
)
}
Text(
text = "26°C",
color = Color(0xFF4ADE80),
fontSize = 38.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
}
}
}
@@ -0,0 +1,263 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
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.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
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.infrared.R
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteScene
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
import com.flagship.abox.manager.infrared.remote.UiText
@Composable
fun InfraredRemoteRoute(
onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredRemoteViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredRemoteScreen(
state = state,
onBack = onBack,
onOpenSettings = onOpenSettings,
onSessionInvalid = onSessionInvalid,
onSceneSelected = viewModel::switchScene,
onToggleLearningMode = viewModel::toggleLearningMode,
onButtonClick = viewModel::onButtonClick,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredRemoteScreen(
state: InfraredRemoteUiState,
onBack: () -> Unit,
onOpenSettings: () -> Unit,
onSessionInvalid: () -> Unit,
onSceneSelected: (RemoteScene) -> Unit,
onToggleLearningMode: (Boolean) -> Unit,
onButtonClick: (RemoteButtonSpec) -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is RemoteUiMessage.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 RemoteUiMessage.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 RemoteUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.infrared_remote_title),
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
)
},
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
actions = {
// 学习模式开关
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = if (state.isLearningMode) {
stringResource(R.string.infrared_remote_recording)
} else {
stringResource(R.string.infrared_remote_learning_mode)
},
fontSize = 12.sp,
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
)
Switch(
checked = state.isLearningMode,
onCheckedChange = onToggleLearningMode,
modifier = Modifier.testTag("remote_learning_mode_switch"),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.error,
checkedTrackColor = MaterialTheme.colorScheme.errorContainer,
),
)
}
// 按键管理 / 设置入口按钮
IconButton(
onClick = onOpenSettings,
modifier = Modifier.testTag("remote_settings_button"),
) {
Text("⚙️", fontSize = 20.sp)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 学习模式顶部横幅提示
if (state.isLearningMode) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
shape = RoundedCornerShape(12.dp),
)
.padding(vertical = 8.dp, horizontal = 12.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.infrared_remote_learning_banner),
style = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.SemiBold,
),
)
}
}
// 场景切换 Tab (空调 | 电视)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
FilterChip(
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
modifier = Modifier
.padding(end = 8.dp)
.testTag("scene_tab_ac"),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
FilterChip(
selected = state.selectedScene == RemoteScene.TELEVISION,
onClick = { onSceneSelected(RemoteScene.TELEVISION) },
label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
modifier = Modifier.testTag("scene_tab_tv"),
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
)
}
// 对应场景遥控器面板
when (state.selectedScene) {
RemoteScene.AIR_CONDITIONER -> {
AcRemotePanel(
learnedSlotMap = state.learnedSlotMap,
activeLearningSlot = state.activeLearningSlot,
sendingKeySlot = state.sendingKeySlot,
isLearningMode = state.isLearningMode,
onButtonClick = onButtonClick,
)
}
RemoteScene.TELEVISION -> {
TvRemotePanel(
learnedSlotMap = state.learnedSlotMap,
activeLearningSlot = state.activeLearningSlot,
sendingKeySlot = state.sendingKeySlot,
isLearningMode = state.isLearningMode,
onButtonClick = onButtonClick,
)
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
@@ -0,0 +1,429 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
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.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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Divider
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
@Composable
fun RemoteChassis(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Card(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
.shadow(12.dp, shape = RoundedCornerShape(32.dp)),
shape = RoundedCornerShape(32.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
content()
}
}
}
@Composable
fun RemoteCircleButton(
spec: RemoteButtonSpec,
isLearned: Boolean,
isLoading: Boolean,
isLearningMode: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
size: Dp = 64.dp,
containerColor: Color? = null,
contentColor: Color? = null,
) {
val defaultContainer = when {
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
isLearned -> containerColor ?: MaterialTheme.colorScheme.primary
else -> MaterialTheme.colorScheme.surface
}
val defaultContent = when {
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
isLearned -> contentColor ?: MaterialTheme.colorScheme.onPrimary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
}
val borderModifier = if (isLearningMode) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
} else if (!isLearned) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
} else {
Modifier
}
Surface(
modifier = modifier
.size(size)
.then(borderModifier)
.testTag("remote_button_${spec.id}"),
shape = CircleShape,
color = defaultContainer,
contentColor = defaultContent,
shadowElevation = if (isLearned) 4.dp else 1.dp,
enabled = !isLoading,
onClick = onClick,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(size * 0.45f),
strokeWidth = 2.5.dp,
color = defaultContent,
)
} else {
Text(
text = spec.symbol,
style = MaterialTheme.typography.titleMedium.copy(
fontSize = if (spec.symbol.length <= 2) 22.sp else 14.sp,
fontWeight = FontWeight.Bold,
),
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
fun RemoteRectButton(
spec: RemoteButtonSpec,
isLearned: Boolean,
isLoading: Boolean,
isLearningMode: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
width: Dp = 100.dp,
height: Dp = 48.dp,
) {
val containerColor = when {
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
isLearned -> MaterialTheme.colorScheme.secondaryContainer
else -> MaterialTheme.colorScheme.surface
}
val contentColor = when {
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
isLearned -> MaterialTheme.colorScheme.onSecondaryContainer
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
}
val borderModifier = if (isLearningMode) {
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp))
} else if (!isLearned) {
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
} else {
Modifier
}
Surface(
modifier = modifier
.width(width)
.height(height)
.then(borderModifier)
.testTag("remote_button_${spec.id}"),
shape = RoundedCornerShape(16.dp),
color = containerColor,
contentColor = contentColor,
shadowElevation = if (isLearned) 3.dp else 1.dp,
enabled = !isLoading,
onClick = onClick,
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = contentColor,
)
} else {
Text(
text = spec.symbol,
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
textAlign = TextAlign.Center,
)
}
}
}
}
@Composable
fun VerticalCapsuleRocker(
upSpec: RemoteButtonSpec,
downSpec: RemoteButtonSpec,
isUpLearned: Boolean,
isDownLearned: Boolean,
isUpLoading: Boolean,
isDownLoading: Boolean,
isLearningMode: Boolean,
onUpClick: () -> Unit,
onDownClick: () -> Unit,
modifier: Modifier = Modifier,
label: String = "",
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Card(
modifier = Modifier
.width(68.dp)
.height(136.dp)
.shadow(4.dp, shape = RoundedCornerShape(34.dp)),
shape = RoundedCornerShape(34.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
// Top half
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp))
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isUpLoading, onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isUpLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(
text = upSpec.symbol,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
}
}
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
// Bottom half
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.clip(RoundedCornerShape(bottomStart = 34.dp, bottomEnd = 34.dp))
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isDownLoading, onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isDownLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text(
text = downSpec.symbol,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
}
}
}
}
if (label.isNotBlank()) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
fun RemoteDpadWheel(
upSpec: RemoteButtonSpec,
downSpec: RemoteButtonSpec,
leftSpec: RemoteButtonSpec,
rightSpec: RemoteButtonSpec,
okSpec: RemoteButtonSpec,
isUpLearned: Boolean,
isDownLearned: Boolean,
isLeftLearned: Boolean,
isRightLearned: Boolean,
isOkLearned: Boolean,
isUpLoading: Boolean,
isDownLoading: Boolean,
isLeftLoading: Boolean,
isRightLoading: Boolean,
isOkLoading: Boolean,
isLearningMode: Boolean,
onUpClick: () -> Unit,
onDownClick: () -> Unit,
onLeftClick: () -> Unit,
onRightClick: () -> Unit,
onOkClick: () -> Unit,
modifier: Modifier = Modifier,
wheelSize: Dp = 190.dp,
) {
Card(
modifier = modifier
.size(wheelSize)
.shadow(6.dp, shape = CircleShape),
shape = CircleShape,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
// Up Button
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isUpLoading, onClick = onUpClick)
.testTag("remote_button_${upSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isUpLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(upSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Down Button
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isDownLoading, onClick = onDownClick)
.testTag("remote_button_${downSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isDownLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(downSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Left Button
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.padding(start = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isLeftLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isLeftLoading, onClick = onLeftClick)
.testTag("remote_button_${leftSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isLeftLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(leftSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Right Button
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.padding(end = 10.dp)
.size(54.dp)
.clip(CircleShape)
.background(
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
else if (isRightLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
else Color.Transparent,
)
.clickable(enabled = !isRightLoading, onClick = onRightClick)
.testTag("remote_button_${rightSpec.id}"),
contentAlignment = Alignment.Center,
) {
if (isRightLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
else Text(rightSpec.symbol, style = MaterialTheme.typography.titleLarge)
}
// Center OK Button
RemoteCircleButton(
spec = okSpec,
isLearned = isOkLearned,
isLoading = isOkLoading,
isLearningMode = isLearningMode,
onClick = onOkClick,
size = 62.dp,
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
}
@@ -0,0 +1,163 @@
package com.flagship.abox.manager.infrared.remote.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
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.remote.RemoteButtonSpec
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
@Composable
fun TvRemotePanel(
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
modifier: Modifier = Modifier,
) {
RemoteChassis(modifier = modifier) {
// 顶部电源与静音行
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val powerSpec = RemoteSceneConfig.Television.POWER
RemoteCircleButton(
spec = powerSpec,
isLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(powerSpec) },
size = 56.dp,
containerColor = Color(0xFFE53935),
contentColor = Color.White,
)
val muteSpec = RemoteSceneConfig.Television.MUTE
RemoteCircleButton(
spec = muteSpec,
isLearned = learnedSlotMap[muteSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == muteSpec.defaultSlot || sendingKeySlot == muteSpec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(muteSpec) },
size = 56.dp,
)
}
// 中心圆形方向轮盘 (D-Pad Wheel)
val upSpec = RemoteSceneConfig.Television.NAV_UP
val downSpec = RemoteSceneConfig.Television.NAV_DOWN
val leftSpec = RemoteSceneConfig.Television.NAV_LEFT
val rightSpec = RemoteSceneConfig.Television.NAV_RIGHT
val okSpec = RemoteSceneConfig.Television.OK
RemoteDpadWheel(
upSpec = upSpec,
downSpec = downSpec,
leftSpec = leftSpec,
rightSpec = rightSpec,
okSpec = okSpec,
isUpLearned = learnedSlotMap[upSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[downSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLeftLearned = learnedSlotMap[leftSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isRightLearned = learnedSlotMap[rightSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isOkLearned = learnedSlotMap[okSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == upSpec.defaultSlot || sendingKeySlot == upSpec.defaultSlot,
isDownLoading = activeLearningSlot == downSpec.defaultSlot || sendingKeySlot == downSpec.defaultSlot,
isLeftLoading = activeLearningSlot == leftSpec.defaultSlot || sendingKeySlot == leftSpec.defaultSlot,
isRightLoading = activeLearningSlot == rightSpec.defaultSlot || sendingKeySlot == rightSpec.defaultSlot,
isOkLoading = activeLearningSlot == okSpec.defaultSlot || sendingKeySlot == okSpec.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(upSpec) },
onDownClick = { onButtonClick(downSpec) },
onLeftClick = { onButtonClick(leftSpec) },
onRightClick = { onButtonClick(rightSpec) },
onOkClick = { onButtonClick(okSpec) },
)
// 系统功能按键行 (返回 / 主页 / 菜单)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
TvActionButton(RemoteSceneConfig.Television.BACK, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
TvActionButton(RemoteSceneConfig.Television.HOME, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
TvActionButton(RemoteSceneConfig.Television.MENU, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
}
Spacer(modifier = Modifier.height(4.dp))
// 音量与频道双摇杆胶囊
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
// 音量控制
val volUp = RemoteSceneConfig.Television.VOL_UP
val volDown = RemoteSceneConfig.Television.VOL_DOWN
VerticalCapsuleRocker(
upSpec = volUp,
downSpec = volDown,
isUpLearned = learnedSlotMap[volUp.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[volDown.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == volUp.defaultSlot || sendingKeySlot == volUp.defaultSlot,
isDownLoading = activeLearningSlot == volDown.defaultSlot || sendingKeySlot == volDown.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(volUp) },
onDownClick = { onButtonClick(volDown) },
label = stringResource(R.string.infrared_label_vol),
)
// 频道控制
val chUp = RemoteSceneConfig.Television.CH_UP
val chDown = RemoteSceneConfig.Television.CH_DOWN
VerticalCapsuleRocker(
upSpec = chUp,
downSpec = chDown,
isUpLearned = learnedSlotMap[chUp.defaultSlot]?.status == InfraredKeyStatus.READY,
isDownLearned = learnedSlotMap[chDown.defaultSlot]?.status == InfraredKeyStatus.READY,
isUpLoading = activeLearningSlot == chUp.defaultSlot || sendingKeySlot == chUp.defaultSlot,
isDownLoading = activeLearningSlot == chDown.defaultSlot || sendingKeySlot == chDown.defaultSlot,
isLearningMode = isLearningMode,
onUpClick = { onButtonClick(chUp) },
onDownClick = { onButtonClick(chDown) },
label = stringResource(R.string.infrared_label_ch),
)
}
}
}
@Composable
private fun TvActionButton(
spec: RemoteButtonSpec,
learnedSlotMap: Map<String, SavedInfraredKey>,
activeLearningSlot: String?,
sendingKeySlot: String?,
isLearningMode: Boolean,
onButtonClick: (RemoteButtonSpec) -> Unit,
) {
RemoteRectButton(
spec = spec,
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
isLearningMode = isLearningMode,
onClick = { onButtonClick(spec) },
width = 84.dp,
height = 44.dp,
)
}
@@ -0,0 +1,303 @@
package com.flagship.abox.manager.infrared.ui
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
@Composable
fun InfraredControlRoute(
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
viewModel: InfraredControlViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
InfraredControlScreen(
state = state,
onBack = onBack,
onSessionInvalid = onSessionInvalid,
onDeviceNameChanged = viewModel::onDeviceNameChanged,
onSaveDeviceName = viewModel::saveDeviceName,
onMessageShown = viewModel::clearMessage,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun InfraredControlScreen(
state: InfraredControlUiState,
onBack: () -> Unit,
onSessionInvalid: () -> Unit,
onDeviceNameChanged: (String) -> Unit,
onSaveDeviceName: () -> Unit,
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val scrollState = rememberScrollState()
LaunchedEffect(state.message) {
state.message?.let { msg ->
when (msg) {
is InfraredUiMessage.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 InfraredUiMessage.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 InfraredUiMessage.SessionInvalid -> {
onSessionInvalid()
onMessageShown()
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.infrared_control_title)) },
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
if (state.loading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// 1. 当前环境卡片
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "运行环境与连接信息",
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
)
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.bodyMedium,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Medium,
)
state.providerDisplayName?.let {
Text(
text = "当前 Profile: $it",
fontSize = 13.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// 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,
) {
OutlinedTextField(
value = state.deviceNameInput,
onValueChange = onDeviceNameChanged,
label = { Text("修改设备名(如:红外控制器)") },
singleLine = true,
modifier = Modifier
.weight(1f)
.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)
}
}
}
}
}
}
}
}
}
@Composable
private fun QuotaDetailProgress(quota: SlotQuotaSummary) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}",
fontWeight = FontWeight.Medium,
fontSize = 13.sp,
)
Text(
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
.fillMaxWidth()
.height(8.dp)
.clip(RoundedCornerShape(4.dp)),
)
}
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.infrared.ui
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.infrared.remote.UiText
sealed interface InfraredUiMessage {
data class Info(val text: UiText) : InfraredUiMessage
data class Error(val text: UiText) : InfraredUiMessage
data object SessionInvalid : InfraredUiMessage
}
data class InfraredControlUiState(
val profileId: ProviderProfileId? = null,
val providerType: ProviderType? = null,
val providerDisplayName: String? = null,
val deviceNameInput: String = "",
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val totalRemotesCount: Int = 0,
val loading: Boolean = true,
val message: InfraredUiMessage? = null,
)
@@ -0,0 +1,184 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderProfileStore
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
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.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class InfraredControlViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val providerProfileStore: ProviderProfileStore,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
private data class ServerState(
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val remotesCount: Int = 0,
val loading: Boolean = false,
val configError: DomainError? = null,
)
private val serverStateFlow: Flow<ServerState> =
if (profileId == null) {
flowOf(ServerState(loading = false))
} else {
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
val active = profiles.find { it.id == profileId }
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
when (deviceResult) {
is DomainResult.Failure -> flowOf(
ServerState(
providerProfile = active,
configError = deviceResult.error,
),
)
is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
combine(
virtualRemoteRepository.observeRemotes(profileId, deviceName),
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
) { remotesResult, quotaResult ->
val remotes = (remotesResult as? DomainResult.Success)?.value ?: emptyList()
val quota = (quotaResult as? DomainResult.Success)?.value
?: SlotQuotaSummary()
ServerState(
providerProfile = active,
savedDevice = deviceName,
slotQuota = quota,
remotesCount = remotes.size,
loading = false,
)
}
}
}
}
}
}
val state: StateFlow<InfraredControlUiState> = combine(
serverStateFlow,
deviceNameInputFlow,
messageFlow,
) { serverState, input, msg ->
val resolvedMessage = msg ?: serverState.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
is DomainError.DeviceOffline -> InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
)
else -> InfraredUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_load_failed),
)
}
}
val effectiveInput = input ?: serverState.savedDevice.value
InfraredControlUiState(
profileId = profileId,
providerType = serverState.providerProfile?.type,
providerDisplayName = serverState.providerProfile?.displayName,
deviceNameInput = effectiveInput,
savedDeviceName = serverState.savedDevice,
slotQuota = serverState.slotQuota,
totalRemotesCount = serverState.remotesCount,
loading = serverState.loading,
message = resolvedMessage,
)
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = InfraredControlUiState(profileId = profileId, loading = true),
)
fun onDeviceNameChanged(value: String) {
deviceNameInputFlow.value = value
}
fun saveDeviceName() {
val currentProfile = profileId ?: return
val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value
val trimmed = input.trim()
if (trimmed.isBlank()) {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(R.string.infrared_msg_device_name_blank),
)
return
}
viewModelScope.launch {
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
is DomainResult.Success -> {
messageFlow.value = InfraredUiMessage.Info(
UiText.StringResource(R.string.infrared_msg_device_name_saved),
)
}
is DomainResult.Failure -> {
handleDomainError(result.error)
}
}
}
}
fun clearMessage() {
messageFlow.value = null
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> {
messageFlow.value = InfraredUiMessage.SessionInvalid
}
is DomainError.DeviceOffline -> {
messageFlow.value = InfraredUiMessage.Error(
UiText.StringResource(
R.string.infrared_msg_device_offline,
listOf(error.externalCode ?: "20206"),
),
)
}
else -> {
messageFlow.value = InfraredUiMessage.Error(
error.detail?.let { UiText.DynamicString(it) }
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
)
}
}
}
companion object {
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
}
}
@@ -0,0 +1,116 @@
<resources>
<string name="infrared_title">红外遥控</string>
<string name="infrared_mode_fake">模式:模拟体验</string>
<string name="infrared_mode_real">模式:ABBox 真实硬件</string>
<string name="infrared_switch_mode">切换模式</string>
<string name="infrared_device_name_label">红外设备名称</string>
<string name="infrared_device_name_hint">例如:ir-living</string>
<string name="infrared_saved_device">已配置设备:%1$s</string>
<string name="infrared_no_device_saved">未配置红外设备</string>
<string name="infrared_save_device">保存设备</string>
<string name="infrared_keys_title">已学习按键</string>
<string name="infrared_no_keys">暂无已保存按键</string>
<string name="infrared_learn_new_key">学习新按键</string>
<string name="infrared_status_learning">学习中…</string>
<string name="infrared_status_ready">已就绪</string>
<string name="infrared_status_failed">学习失败</string>
<string name="infrared_send">发射</string>
<string name="infrared_sending">发射中…</string>
<string name="infrared_retry">重试</string>
<string name="infrared_rename">重命名</string>
<string name="infrared_delete">删除</string>
<string name="infrared_learn_dialog_title">学习红外按键</string>
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器,点击开始后将遥控器对准红外转发器并按下目标按键。</string>
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
<string name="infrared_learn_dialog_slot_label">按键槽位(1-100)</string>
<string name="infrared_key_slot_label">按键编号:%1$s</string>
<string name="infrared_learn_dialog_start">开始学习</string>
<string name="infrared_rename_dialog_title">重命名按键</string>
<string name="infrared_rename_dialog_input_label">新按键名称</string>
<string name="infrared_delete_dialog_title">删除按键</string>
<string name="infrared_delete_dialog_message">仅从本机删除该按键记录,ABox 主机上可能仍保留已录制码值。</string>
<string name="infrared_dialog_cancel">取消</string>
<string name="infrared_dialog_confirm">确认</string>
<string name="infrared_back">返回</string>
<string name="infrared_remote_title">红外遥控器</string>
<string name="infrared_remote_recording">录制中</string>
<string name="infrared_remote_learning_mode">学习模式</string>
<string name="infrared_remote_learning_banner">🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号</string>
<string name="infrared_remote_scene_ac">❄️ 空调面板</string>
<string name="infrared_remote_scene_tv">📺 电视面板</string>
<string name="infrared_remote_settings">设置</string>
<string name="infrared_msg_learning_mode_enabled">🔴 学习模式已开启:点击任意按键开始录制</string>
<string name="infrared_msg_recording_key">正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键…</string>
<string name="infrared_msg_learned_success">【%1$s】录制成功</string>
<string name="infrared_msg_key_not_learned">按键【%1$s】尚未学习,开启右上角学习模式即可录制</string>
<string name="infrared_msg_signal_sent">已发射信号:%1$s</string>
<string name="infrared_msg_device_offline">红外设备离线(错误码:%1$s)</string>
<string name="infrared_msg_load_failed">加载数据失败</string>
<string name="infrared_msg_operation_failed">操作失败</string>
<string name="infrared_label_temp_control">温度调节</string>
<string name="infrared_label_vol">音量</string>
<string name="infrared_label_ch">频道</string>
<string name="infrared_label_cool_mode">❄️ 制冷模式</string>
<string name="infrared_label_ac_status">💨 自动风速 | ↔ 自动扫风</string>
<string name="infrared_key_ac_power">空调-电源</string>
<string name="infrared_key_ac_temp_up">空调-升温</string>
<string name="infrared_key_ac_temp_down">空调-降温</string>
<string name="infrared_key_ac_mode">空调-模式</string>
<string name="infrared_key_ac_fan">空调-风速</string>
<string name="infrared_key_ac_swing">空调-扫风</string>
<string name="infrared_key_ac_timer">空调-定时</string>
<string name="infrared_key_tv_power">电视-电源</string>
<string name="infrared_key_tv_mute">电视-静音</string>
<string name="infrared_key_tv_nav_up">电视-向上</string>
<string name="infrared_key_tv_nav_down">电视-向下</string>
<string name="infrared_key_tv_nav_left">电视-向左</string>
<string name="infrared_key_tv_nav_right">电视-向右</string>
<string name="infrared_key_tv_ok">电视-确定</string>
<string name="infrared_key_tv_vol_up">电视-音量+</string>
<string name="infrared_key_tv_vol_down">电视-音量-</string>
<string name="infrared_key_tv_ch_up">电视-频道+</string>
<string name="infrared_key_tv_ch_down">电视-频道-</string>
<string name="infrared_key_tv_home">电视-主页</string>
<string name="infrared_key_tv_back">电视-返回</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_local_simulation">本地体验(模拟)</string>
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
<string name="infrared_msg_device_name_saved">设备名称已成功保存</string>
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
<string name="infrared_msg_key_renamed">按键已重命名</string>
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
<string name="infrared_hub_title">红外中枢</string>
<string name="infrared_hub_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>
@@ -0,0 +1,117 @@
<resources>
<string name="infrared_title">Infrared Control</string>
<string name="infrared_mode_fake">Mode: Fake / Simulated</string>
<string name="infrared_mode_real">Mode: Real ABSDK</string>
<string name="infrared_switch_mode">Switch Mode</string>
<string name="infrared_device_name_label">Infrared Device Name</string>
<string name="infrared_device_name_hint">e.g. ir-living</string>
<string name="infrared_saved_device">Saved Device: %1$s</string>
<string name="infrared_no_device_saved">No infrared device configured</string>
<string name="infrared_save_device">Save Device</string>
<string name="infrared_keys_title">Learned Keys</string>
<string name="infrared_no_keys">No infrared keys saved</string>
<string name="infrared_learn_new_key">Learn New Key</string>
<string name="infrared_status_learning">Learning…</string>
<string name="infrared_status_ready">Ready</string>
<string name="infrared_status_failed">Failed</string>
<string name="infrared_send">Send</string>
<string name="infrared_sending">Sending…</string>
<string name="infrared_retry">Retry</string>
<string name="infrared_rename">Rename</string>
<string name="infrared_delete">Delete</string>
<string name="infrared_learn_dialog_title">Learn Infrared Key</string>
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
<string name="infrared_learn_dialog_slot_label">Key Slot (1-100)</string>
<string name="infrared_key_slot_label">Key Slot: %1$s</string>
<string name="infrared_learn_dialog_start">Start Learning</string>
<string name="infrared_rename_dialog_title">Rename Key</string>
<string name="infrared_rename_dialog_input_label">New Display Name</string>
<string name="infrared_delete_dialog_title">Delete Key</string>
<string name="infrared_delete_dialog_message">Only removes the key locally from this app. ABox server may retain the learned key.</string>
<string name="infrared_dialog_cancel">Cancel</string>
<string name="infrared_dialog_confirm">Confirm</string>
<string name="infrared_back">Back</string>
<string name="infrared_remote_title">Infrared Remote</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_banner">🔴 Learn mode active: tap any button to record</string>
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
<string name="infrared_remote_scene_tv">📺 Television</string>
<string name="infrared_remote_settings">Settings</string>
<string name="infrared_msg_learning_mode_enabled">🔴 Learn mode active: tap any button to record</string>
<string name="infrared_msg_recording_key">Recording [%1$s] (Slot %2$s), press remote button…</string>
<string name="infrared_msg_learned_success">[%1$s] recorded successfully</string>
<string name="infrared_msg_key_not_learned">Button [%1$s] is not learned. Enable learn mode to record.</string>
<string name="infrared_msg_signal_sent">Sent: %1$s</string>
<string name="infrared_msg_device_offline">Infrared controller offline (code: %1$s)</string>
<string name="infrared_msg_load_failed">Failed to load data</string>
<string name="infrared_msg_operation_failed">Operation failed</string>
<string name="infrared_label_temp_control">Temp Control</string>
<string name="infrared_label_vol">VOL</string>
<string name="infrared_label_ch">CH</string>
<string name="infrared_label_cool_mode">❄️ Cool Mode</string>
<string name="infrared_label_ac_status">💨 Auto Fan | ↔ Swing</string>
<string name="infrared_key_ac_power">AC Power</string>
<string name="infrared_key_ac_temp_up">AC Temp +</string>
<string name="infrared_key_ac_temp_down">AC Temp -</string>
<string name="infrared_key_ac_mode">AC Mode</string>
<string name="infrared_key_ac_fan">AC Fan Speed</string>
<string name="infrared_key_ac_swing">AC Swing</string>
<string name="infrared_key_ac_timer">AC Timer</string>
<string name="infrared_key_tv_power">TV Power</string>
<string name="infrared_key_tv_mute">TV Mute</string>
<string name="infrared_key_tv_nav_up">TV Up</string>
<string name="infrared_key_tv_nav_down">TV Down</string>
<string name="infrared_key_tv_nav_left">TV Left</string>
<string name="infrared_key_tv_nav_right">TV Right</string>
<string name="infrared_key_tv_ok">TV OK</string>
<string name="infrared_key_tv_vol_up">TV Vol +</string>
<string name="infrared_key_tv_vol_down">TV Vol -</string>
<string name="infrared_key_tv_ch_up">TV CH +</string>
<string name="infrared_key_tv_ch_down">TV CH -</string>
<string name="infrared_key_tv_home">TV Home</string>
<string name="infrared_key_tv_back">TV Back</string>
<string name="infrared_key_tv_menu">TV Menu</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_local_simulation">Local Experience (Simulation)</string>
<string name="infrared_mode_abox_real">ABox (Real Hardware)</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_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_key_renamed">Key renamed</string>
<string name="infrared_msg_key_deleted">Key removed locally</string>
<string name="infrared_msg_key_not_ready">Key is not ready to send</string>
<string name="infrared_hub_title">Infrared Hub</string>
<string name="infrared_hub_slot_quota">Hardware Slots: %1$d / %2$d allocated (%3$d free)</string>
<string name="infrared_hub_static_quota">Static: %1$d</string>
<string name="infrared_hub_dynamic_scratch">Dynamic Buffer: Slot %1$s</string>
<string name="infrared_hub_remotes_title">Virtual Remotes</string>
<string name="infrared_hub_no_remotes">No virtual remotes added yet. Tap + to add one.</string>
<string name="infrared_hub_add_remote">Add Remote</string>
<string name="infrared_hub_mode_static">🚀 Static Mode (%1$s slots)</string>
<string name="infrared_hub_mode_dynamic">📦 Dynamic Mode (Scratch Slot %1$s)</string>
<string name="infrared_hub_create_dialog_title">Add Virtual Remote</string>
<string name="infrared_hub_device_type_label">Device Type</string>
<string name="infrared_hub_brand_label">Brand / Preset Library</string>
<string name="infrared_hub_trans_mode_label">Transmission Mode</string>
<string name="infrared_hub_trans_mode_static_desc">Low latency (~20ms), instant response, occupies ~15 slots</string>
<string name="infrared_hub_trans_mode_dynamic_desc">Saves slots (0 extra slots), writes code on-the-fly (~80ms)</string>
<string name="infrared_hub_quota_insufficient">Slot quota exceeded. Please choose Dynamic mode.</string>
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
<string name="infrared_hub_device_type_tv">📺 Television (TV)</string>
<string name="infrared_hub_device_type_ac">❄️ Air Conditioner (AC)</string>
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
<string name="infrared_hub_device_type_custom">🎛️ Custom Panel</string>
</resources>
@@ -0,0 +1,156 @@
package com.flagship.abox.manager.infrared.application
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.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
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.RelearnInfraredKey
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredLearningCoordinatorTest {
private val profile = ProviderProfileId("profile1")
private val device = DeviceName("ir-1")
private val clock = EpochMillisClock { 1_000L }
private val repository = FakeInfraredKeyRepository()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
private val relearn = RelearnInfraredKey(repository, gateway, clock)
@Test
fun learnRunsAsynchronouslyInExternalScope() = runTest {
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.learn(profile, device, "Power")
advanceUntilIdle()
val keysResult = repository.observeKeys(profile, device).first()
assertTrue(keysResult is DomainResult.Success)
val keys = (keysResult as DomainResult.Success).value
assertEquals(1, keys.size)
assertEquals(InfraredKeyStatus.READY, keys.single().status)
}
@Test
fun retryRunsAsynchronouslyInExternalScope() = runTest {
val failedKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(failedKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
@Test
fun relearnRunsAsynchronouslyInExternalScope() = runTest {
val readyKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(readyKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
@Test
fun failInterruptedLearningFailsLearningKeys() = runTest {
val learningKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "app_ir_key1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(learningKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.failInterruptedLearning(profile, 2_000L)
advanceUntilIdle()
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.FAILED, updated.status)
}
@Test
fun subsequentLearnWaitsForCleanupCompletion() = runTest {
val learningKey = SavedInfraredKey(
id = InfraredKeyId("key1"),
profileId = profile,
deviceName = device,
displayName = "Power",
sdkKey = "1",
status = InfraredKeyStatus.READY,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 500L,
updatedAtEpochMillis = 500L,
)
repository.insert(learningKey)
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
coordinator.failInterruptedLearning(profile, cutoffEpochMillis = 1000L)
val relearnJob = coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
advanceUntilIdle()
val relearnResult = relearnJob.await()
assertTrue(relearnResult is DomainResult.Success)
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -0,0 +1,350 @@
package com.flagship.abox.manager.infrared.remote
import androidx.lifecycle.SavedStateHandle
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.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
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.application.InfraredLearningCoordinator
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
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.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredRemoteViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profile = ProviderProfileId("abox")
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val keyRepo = FakeInfraredKeyRepository()
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore()
private val gateway = FakeInfraredGateway()
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, 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 lateinit var coordinator: InfraredLearningCoordinator
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredRemoteViewModel {
return InfraredRemoteViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
keyRepository = keyRepo,
deviceConfigurationRepository = deviceRepo,
providerProfileStore = profileStore,
learningCoordinator = coordinator,
sendInfraredKey = send,
)
}
@Test
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
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.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
assertEquals("1", gateway.sentKeys.first().key)
job.cancel()
}
@Test
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.toggleLearningMode(false)
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
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.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.sentKeys.size)
job.cancel()
}
@Test
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val failedKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.FAILED,
lastErrorCode = "20206",
lastErrorDetail = "offline",
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(failedKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun learningFailurePropagatesErrorMessage() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(
RemoteUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_offline, listOf("20206"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message)
job.cancel()
}
@Test
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
vm.toggleLearningMode(false)
advanceUntilIdle()
assertEquals(null, vm.state.value.activeLearningSlot)
job.cancel()
}
@Test
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val readyKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
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.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
deviceRepo.setDeviceName(profile, device)
val learningKey = SavedInfraredKey(
id = InfraredKeyId("k1"),
profileId = profile,
deviceName = device,
displayName = "空调-电源",
sdkKey = "1",
status = InfraredKeyStatus.LEARNING,
lastErrorCode = null,
lastErrorDetail = null,
createdAtEpochMillis = 100L,
updatedAtEpochMillis = 100L,
)
keyRepo.insert(learningKey)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.toggleLearningMode(true)
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
advanceUntilIdle()
assertEquals(1, gateway.learnedKeys.size)
assertEquals("1", gateway.learnedKeys.first().key)
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
assertEquals(InfraredKeyStatus.READY, updated.status)
assertEquals(
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
vm.state.value.message,
)
job.cancel()
}
@Test
fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest {
deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure")
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message)
job.cancel()
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.infrared.remote
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class RemoteModelsTest {
@Test
fun acSpecsHaveUniqueSlotsBetween1And20() {
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 1..20 })
}
@Test
fun tvSpecsHaveUniqueSlotsBetween21And50() {
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
assertEquals(slots.size, slots.distinct().size)
assertTrue(slots.all { it.toInt() in 21..50 })
}
}
@@ -0,0 +1,149 @@
package com.flagship.abox.manager.infrared.ui
import androidx.lifecycle.SavedStateHandle
import com.flagship.abox.manager.domain.DeviceName
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.EpochMillisClock
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.UiText
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.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class InfraredControlViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val profileId = ProviderProfileId("profile1")
private val providerProfile = ProviderProfile(
id = profileId,
type = ProviderType.LOCAL_EXPERIENCE,
displayName = "Local Simulation",
)
private val device = DeviceName("ir-living")
private val clock = EpochMillisClock { 10_000L }
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
private val remoteRepo = FakeVirtualRemoteRepository()
private val profileStore = FakeProviderProfileStore()
private val gateway = FakeInfraredGateway()
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
}
@After
fun teardown() {
Dispatchers.resetMain()
}
private fun createViewModel(): InfraredControlViewModel {
return InfraredControlViewModel(
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
deviceConfigurationRepository = deviceRepo,
virtualRemoteRepository = remoteRepo,
providerProfileStore = profileStore,
)
}
@Test
fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = 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("ir-living", vm.state.value.deviceNameInput)
assertEquals(0, vm.state.value.totalRemotesCount)
job.cancel()
}
@Test
fun saveDeviceNameUpdatesDeviceConfiguration() = testScope.runTest {
profileStore.saveProfile(providerProfile)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged("ir-bedroom")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(DeviceName("ir-bedroom"), vm.state.value.savedDeviceName)
assertEquals(
InfraredUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)),
vm.state.value.message,
)
job.cancel()
}
@Test
fun blankDeviceNameEmitsErrorMessage() = testScope.runTest {
profileStore.saveProfile(providerProfile)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
vm.onDeviceNameChanged(" ")
vm.saveDeviceName()
advanceUntilIdle()
assertEquals(
InfraredUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)),
vm.state.value.message,
)
job.cancel()
}
@Test
fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest {
profileStore.saveProfile(providerProfile)
deviceRepo.setDeviceName(profileId, device)
createRemote(
profileId = profileId,
deviceName = device,
displayName = "Living TV",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
)
val vm = createViewModel()
val job = launch { vm.state.collect {} }
advanceUntilIdle()
assertEquals(1, vm.state.value.totalRemotesCount)
assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size)
assertEquals(16, vm.state.value.slotQuota.usedCount)
job.cancel()
}
}