feat(ui): complete ABox app experience redesign (#10)

## Summary

- rebuild the authenticated shell, device dashboard, device detail pages, scenes, activity timeline, account sheet, and settings experience
- redesign infrared remote management and controls, including custom remotes, learn-state handling, slot isolation, and safe default content
- add persisted theme controls with internal accent, color-style, and color-standard resolution while keeping dynamic color authoritative
- add ABox profile defaults for the three exact gateway device names, editable starter scenes, and custom infrared remotes
- persist real activity events for device commands, infrared actions, and scene execution
- stabilize navigation and back behavior, including duplicate top-back protection and removal of the unfinished predictive-back option
- update launcher icons and bilingual application name

## Verification

- `:android:app:assembleDebug`
- installed the debug APK on `emulator-5554`
- manually exercised device detail navigation, settings/theme navigation, infrared management, activity records, and repeated back actions

## Notes

- no automated test suite was run during the visual iteration cycle
- `docs/superpowers/**` and local reference repositories are excluded from this PR

Reviewed-on: #10
Co-authored-by: KremeCN <3265494849@qq.com>
This commit was merged in pull request #10.
This commit is contained in:
2026-08-27 23:25:07 +08:00
committed by KremeCN
parent 1570a95f30
commit 86b7e5849b
194 changed files with 11198 additions and 1444 deletions
@@ -0,0 +1,55 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager.automation"
compileSdk = 37
defaultConfig {
minSdk = 26
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures { compose = true }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions { jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) }
}
kapt {
correctErrorTypes = true
}
dependencies {
api(project(":domain"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
kapt(libs.room.compiler)
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
implementation(libs.androidx.work.runtime.ktx)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.hilt.navigation.compose)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
implementation(libs.kotlinx.coroutines.core)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.room.testing)
androidTestImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,83 @@
package com.flagship.abox.manager.automation
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.automation.data.ActivityEventEntity
import com.flagship.abox.manager.automation.data.AutomationDatabase
import com.flagship.abox.manager.automation.data.RoomActivityRepository
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityEventId
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.domain.SceneTriggerId
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AutomationDatabaseTest {
private lateinit var database: AutomationDatabase
private lateinit var activityRepository: RoomActivityRepository
private lateinit var runtimeRepository: RoomAutomationRuntimeRepository
@Before
fun setUp() {
database = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AutomationDatabase::class.java).build()
activityRepository = RoomActivityRepository(database.automationDao())
runtimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
}
@After
fun tearDown() = database.close()
@Test
fun activityEventsAreIsolatedByProfileAndPageByCursor() = runTest {
activityRepository.append(event("profile-a", "a", 10L))
activityRepository.append(event("profile-a", "b", 10L))
activityRepository.append(event("profile-b", "c", 10L))
val first = activityRepository.observeLatest(ProviderProfileId("profile-a"), 1).first()
val firstEvent = (first as DomainResult.Success).value.single()
val next = activityRepository.loadBefore(
ProviderProfileId("profile-a"),
ActivityEventCursor(firstEvent.occurredAtEpochMillis, firstEvent.id),
1,
)
assertEquals("b", firstEvent.id.value)
assertEquals(listOf("a"), (next as DomainResult.Success).value.map { it.id.value })
}
@Test
fun occurrenceClaimIsAtomic() = runTest {
val first = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
val second = runtimeRepository.claimOccurrence(ProviderProfileId("p"), SceneId("s"), SceneTriggerId("t"), "occurrence")
assertTrue((first as DomainResult.Success).value)
assertEquals(false, (second as DomainResult.Success).value)
}
private fun event(profile: String, id: String, time: Long) = ActivityEvent(
id = ActivityEventId(id),
profileId = ProviderProfileId(profile),
occurredAtEpochMillis = time,
kind = ActivityEventKind.SOCKET_COMMAND,
source = ActivitySource.USER,
outcome = ActivityOutcome.SUCCEEDED,
target = ActivityTarget(ActivityTargetType.DEVICE, id, "设备"),
)
}
@@ -0,0 +1,26 @@
package com.flagship.abox.manager.automation.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface AutomationDao {
@Query("SELECT * FROM activity_events WHERE profileId = :profileId ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit")
fun observeLatest(profileId: String, limit: Int): Flow<List<ActivityEventEntity>>
@Query(
"SELECT * FROM activity_events WHERE profileId = :profileId AND " +
"(occurredAtEpochMillis < :beforeTime OR (occurredAtEpochMillis = :beforeTime AND id < :beforeId)) " +
"ORDER BY occurredAtEpochMillis DESC, id DESC LIMIT :limit",
)
suspend fun loadBefore(profileId: String, beforeTime: Long, beforeId: String, limit: Int): List<ActivityEventEntity>
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertEvent(event: ActivityEventEntity): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun claimOccurrence(occurrence: AutomationOccurrenceEntity): Long
}
@@ -0,0 +1,9 @@
package com.flagship.abox.manager.automation.data
import androidx.room.Database
import androidx.room.RoomDatabase
@Database(entities = [ActivityEventEntity::class, AutomationOccurrenceEntity::class], version = 1, exportSchema = false)
abstract class AutomationDatabase : RoomDatabase() {
abstract fun automationDao(): AutomationDao
}
@@ -0,0 +1,33 @@
package com.flagship.abox.manager.automation.data
import androidx.room.Entity
import androidx.room.Index
@Entity(
tableName = "activity_events",
primaryKeys = ["profileId", "id"],
indices = [Index(value = ["profileId", "occurredAtEpochMillis", "id"])],
)
data class ActivityEventEntity(
val profileId: String,
val id: String,
val occurredAtEpochMillis: Long,
val kind: String,
val source: String,
val outcome: String,
val targetType: String,
val targetStableId: String,
val targetDisplayName: String,
val errorCode: String?,
)
@Entity(
tableName = "automation_occurrences",
primaryKeys = ["profileId", "sceneId", "triggerId", "occurrenceKey"],
)
data class AutomationOccurrenceEntity(
val profileId: String,
val sceneId: String,
val triggerId: String,
val occurrenceKey: String,
)
@@ -0,0 +1,70 @@
package com.flagship.abox.manager.automation.data
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityEventId
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RoomActivityRepository(private val dao: AutomationDao) : ActivityRepository {
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> =
dao.observeLatest(profileId.value, limit.coerceAtLeast(0)).map { entities -> DomainResult.Success(entities.map { it.toDomain() }) }
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int): DomainResult<List<ActivityEvent>> = try {
DomainResult.Success(dao.loadBefore(profileId.value, before.occurredAtEpochMillis, before.id.value, limit.coerceAtLeast(0)).map { it.toDomain() })
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
}
override suspend fun append(event: ActivityEvent): DomainResult<Unit> = try {
dao.insertEvent(event.toEntity())
DomainResult.Success(Unit)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Activity storage unavailable"))
}
}
private fun ActivityEventEntity.toDomain() = ActivityEvent(
id = ActivityEventId(id),
profileId = ProviderProfileId(profileId),
occurredAtEpochMillis = occurredAtEpochMillis,
kind = kind.toActivityEventKind(),
source = source.toActivitySource(),
outcome = outcome.toActivityOutcome(),
target = ActivityTarget(targetType.toActivityTargetType(), targetStableId, targetDisplayName),
errorCode = errorCode,
)
private fun String.toActivityEventKind(): ActivityEventKind =
runCatching { ActivityEventKind.valueOf(this) }.getOrDefault(ActivityEventKind.SCENE_ACTION)
private fun String.toActivitySource(): ActivitySource =
runCatching { ActivitySource.valueOf(this) }.getOrDefault(ActivitySource.SYSTEM)
private fun String.toActivityOutcome(): ActivityOutcome =
runCatching { ActivityOutcome.valueOf(this) }.getOrDefault(ActivityOutcome.FAILED)
private fun String.toActivityTargetType(): ActivityTargetType =
runCatching { ActivityTargetType.valueOf(this) }.getOrDefault(ActivityTargetType.SCENE_ACTION)
private fun ActivityEvent.toEntity() = ActivityEventEntity(
profileId = profileId.value,
id = id.value,
occurredAtEpochMillis = occurredAtEpochMillis,
kind = kind.name,
source = source.name,
outcome = outcome.name,
targetType = target.type.name,
targetStableId = target.stableId,
targetDisplayName = target.displayName,
errorCode = errorCode,
)
@@ -0,0 +1,29 @@
package com.flagship.abox.manager.automation.data
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.SceneId
import com.flagship.abox.manager.domain.SceneTriggerId
data class TriggerMatchTransition(val wasMatched: Boolean, val isMatched: Boolean)
interface AutomationRuntimeRepository {
suspend fun claimOccurrence(profileId: ProviderProfileId, sceneId: SceneId, triggerId: SceneTriggerId, occurrenceKey: String): DomainResult<Boolean>
}
class RoomAutomationRuntimeRepository(private val dao: AutomationDao) : AutomationRuntimeRepository {
override suspend fun claimOccurrence(
profileId: ProviderProfileId,
sceneId: SceneId,
triggerId: SceneTriggerId,
occurrenceKey: String,
): DomainResult<Boolean> = try {
val inserted = dao.claimOccurrence(
AutomationOccurrenceEntity(profileId.value, sceneId.value, triggerId.value, occurrenceKey),
)
DomainResult.Success(inserted != -1L)
} catch (error: Exception) {
DomainResult.Failure(DomainError.Unknown(detail = "Automation storage unavailable"))
}
}
@@ -0,0 +1,48 @@
package com.flagship.abox.manager.automation.di
import android.content.Context
import androidx.room.Room
import com.flagship.abox.manager.automation.data.AutomationDatabase
import com.flagship.abox.manager.automation.data.AutomationRuntimeRepository
import com.flagship.abox.manager.automation.data.RoomActivityRepository
import com.flagship.abox.manager.automation.data.RoomAutomationRuntimeRepository
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.automation.notification.AndroidNotificationGateway
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object AutomationModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AutomationDatabase =
Room.databaseBuilder(context, AutomationDatabase::class.java, "abox_automation.db").build()
@Provides
@Singleton
fun provideActivityRepository(database: AutomationDatabase): ActivityRepository = RoomActivityRepository(database.automationDao())
@Provides
@Singleton
fun provideActivityEventWriter(repository: ActivityRepository): ActivityEventWriter =
ActivityEventWriter(repository, CoroutineScope(SupervisorJob() + Dispatchers.IO))
@Provides
@Singleton
fun provideRuntimeRepository(database: AutomationDatabase): AutomationRuntimeRepository = RoomAutomationRuntimeRepository(database.automationDao())
@Provides
@Singleton
fun provideNotificationGateway(@ApplicationContext context: Context): NotificationGateway =
AndroidNotificationGateway(context)
}
@@ -0,0 +1,49 @@
package com.flagship.abox.manager.automation.notification
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.pm.PackageManager
import android.annotation.SuppressLint
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.NotificationGateway
import com.flagship.abox.manager.domain.ProviderProfileId
class AndroidNotificationGateway(private val context: Context) : NotificationGateway {
override suspend fun show(profileId: ProviderProfileId, title: String, message: String): DomainResult<Unit> {
if (android.os.Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
return DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
}
val manager = context.getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "自动化通知", NotificationManager.IMPORTANCE_DEFAULT),
)
return try {
@SuppressLint("MissingPermission")
fun postNotification() {
NotificationManagerCompat.from(context).notify(
profileId.value.hashCode(),
NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(message)
.setAutoCancel(true)
.build(),
)
}
postNotification()
DomainResult.Success(Unit)
} catch (_: SecurityException) {
DomainResult.Failure(DomainError.OperationFailed(detail = "Notification permission is not granted"))
}
}
private companion object { const val CHANNEL_ID = "automation_events" }
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.automation.runtime
import com.flagship.abox.manager.domain.ProviderProfileStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import com.flagship.abox.manager.domain.ProviderProfileId
class AutomationBootstrapper(
private val profileStore: ProviderProfileStore,
private val scheduler: AutomationScheduler,
private val scope: CoroutineScope,
) {
private var previousProfileId: ProviderProfileId? = null
fun start() {
scope.launch {
profileStore.observeActiveProfile().collectLatest { active ->
previousProfileId?.takeIf { it != active?.id }?.let { scheduler.cancel(it) }
previousProfileId = active?.id
scheduler.reconcileActiveProfile()
}
}
}
}
@@ -0,0 +1,10 @@
package com.flagship.abox.manager.automation.runtime
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
class AutomationPollWorker(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result =
if (inputData.getString(WorkManagerAutomationScheduleStore.KEY_PROFILE_ID).isNullOrBlank()) Result.failure() else Result.success()
}
@@ -0,0 +1,53 @@
package com.flagship.abox.manager.automation.runtime
import com.flagship.abox.manager.domain.AutomationType
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.Scene
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.SessionValidity
import kotlinx.coroutines.flow.first
interface AutomationScheduler {
suspend fun reconcileActiveProfile(): DomainResult<Unit>
suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit>
suspend fun cancel(profileId: ProviderProfileId): DomainResult<Unit>
}
interface AutomationScheduleStore {
suspend fun replace(profileId: ProviderProfileId, scenes: List<Scene>): DomainResult<Unit>
suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit>
}
class ProfileAutomationScheduler(
private val profileStore: ProviderProfileStore,
private val sessionGateway: SessionGateway,
private val sceneRepository: SceneRepository,
private val scheduleStore: AutomationScheduleStore,
) : AutomationScheduler {
override suspend fun reconcileActiveProfile(): DomainResult<Unit> {
val active = profileStore.activeProfile() ?: return DomainResult.Success(Unit)
return reconcile(active.id)
}
override suspend fun reconcile(profileId: ProviderProfileId): DomainResult<Unit> {
val active = profileStore.activeProfile()
if (active?.id != profileId) return DomainResult.Failure(DomainError.InvalidRequest(detail = "Provider profile is not active"))
return when (val validity = sessionGateway.validateSession(profileId)) {
is DomainResult.Success -> if (validity.value == SessionValidity.VALID) {
when (val result = sceneRepository.observeScenes(profileId).first()) {
is DomainResult.Failure -> result
is DomainResult.Success -> scheduleStore.replace(profileId, result.value.filter { it.enabled && it.type == AutomationType.AUTOMATIC })
}
} else {
scheduleStore.remove(profileId)
}
is DomainResult.Failure -> scheduleStore.remove(profileId)
}
}
override suspend fun cancel(profileId: ProviderProfileId): DomainResult<Unit> = scheduleStore.remove(profileId)
}
@@ -0,0 +1,32 @@
package com.flagship.abox.manager.automation.runtime
import android.content.Context
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Scene
import java.util.concurrent.TimeUnit
class WorkManagerAutomationScheduleStore(context: Context) : AutomationScheduleStore {
private val workManager = WorkManager.getInstance(context)
override suspend fun replace(profileId: ProviderProfileId, scenes: List<Scene>): DomainResult<Unit> {
if (scenes.isEmpty()) return remove(profileId)
val request = PeriodicWorkRequestBuilder<AutomationPollWorker>(15, TimeUnit.MINUTES)
.setInputData(androidx.work.Data.Builder().putString(KEY_PROFILE_ID, profileId.value).build())
.build()
workManager.enqueueUniquePeriodicWork(workName(profileId), ExistingPeriodicWorkPolicy.UPDATE, request)
return DomainResult.Success(Unit)
}
override suspend fun remove(profileId: ProviderProfileId): DomainResult<Unit> {
workManager.cancelUniqueWork(workName(profileId))
return DomainResult.Success(Unit)
}
private fun workName(profileId: ProviderProfileId) = "automation:${profileId.value}"
companion object { const val KEY_PROFILE_ID = "profile_id" }
}
@@ -0,0 +1,15 @@
package com.flagship.abox.manager.automation.ui.activity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.domain.ProviderProfileId
@Composable
fun ActivityRoute(profileId: ProviderProfileId, modifier: Modifier = Modifier, viewModel: ActivityViewModel = hiltViewModel()) {
val state = viewModel.state.collectAsStateWithLifecycle().value
LaunchedEffect(profileId) { viewModel.initialize(profileId) }
ActivityScreen(state = state, onLoadEarlier = viewModel::loadEarlier, modifier = modifier)
}
@@ -0,0 +1,94 @@
package com.flagship.abox.manager.automation.ui.activity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.automation.R
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable
fun ActivityScreen(
state: ActivityUiState,
onLoadEarlier: () -> Unit,
modifier: Modifier = Modifier,
zoneId: ZoneId = ZoneId.systemDefault(),
) {
Column(modifier = modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.activity_title), style = MaterialTheme.typography.headlineSmall)
if (!state.loading && state.groups.isEmpty()) {
Text(
if (state.error) stringResource(R.string.activity_error) else stringResource(R.string.activity_empty),
modifier = Modifier.testTag("activity_empty"),
)
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.testTag("activity_timeline")) {
state.groups.forEach { group ->
item { Text(group.label, style = MaterialTheme.typography.titleMedium) }
items(group.events, key = { it.id.value }) { event -> ActivityEventRow(event, zoneId) }
}
if (state.hasMore) item { TextButton(onClick = onLoadEarlier) { Text(stringResource(R.string.activity_load_earlier)) } }
}
}
}
}
@Composable
private fun ActivityEventRow(event: ActivityEvent, zoneId: ZoneId) {
val outcome = when (event.outcome) {
ActivityOutcome.SUCCEEDED -> R.string.activity_outcome_success
ActivityOutcome.FAILED -> R.string.activity_outcome_failed
ActivityOutcome.CANCELLED -> R.string.activity_outcome_cancelled
ActivityOutcome.AWAITING_CONFIRMATION -> R.string.activity_outcome_awaiting
}
val kind = when (event.kind) {
ActivityEventKind.SOCKET_COMMAND -> R.string.activity_kind_socket_command
ActivityEventKind.SOCKET_CONFIRMATION -> R.string.activity_kind_socket_confirmation
ActivityEventKind.SENSOR_REFRESH -> R.string.activity_kind_sensor_refresh
ActivityEventKind.INFRARED_SEND -> R.string.activity_kind_infrared_send
ActivityEventKind.INFRARED_LEARN -> R.string.activity_kind_infrared_learn
ActivityEventKind.SCENE_EXECUTION -> R.string.activity_kind_scene_execution
ActivityEventKind.SCENE_ACTION -> R.string.activity_kind_scene_action
else -> R.string.activity_title
}
val source = when (event.source) {
ActivitySource.USER -> R.string.activity_source_user
ActivitySource.AUTOMATION -> R.string.activity_source_automation
ActivitySource.SYSTEM -> R.string.activity_source_system
}
Card(
modifier = Modifier.fillMaxWidth().testTag("activity_event_${event.id.value}"),
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.fillMaxWidth().padding(14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(stringResource(kind), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
Text(event.target.displayName, style = MaterialTheme.typography.bodyLarge)
Text(
"${stringResource(outcome)} · ${stringResource(source)} · ${Instant.ofEpochMilli(event.occurredAtEpochMillis).atZone(zoneId).format(DateTimeFormatter.ofPattern("HH:mm"))}",
style = MaterialTheme.typography.bodyMedium,
)
event.errorCode?.let { Text(stringResource(R.string.activity_error_code, it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) }
}
}
}
@@ -0,0 +1,90 @@
package com.flagship.abox.manager.automation.ui.activity
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import dagger.hilt.android.lifecycle.HiltViewModel
import java.time.Instant
import java.time.ZoneId
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import javax.inject.Inject
data class ActivityGroup(val label: String, val events: List<ActivityEvent>)
data class ActivityUiState(
val profileId: ProviderProfileId? = null,
val events: List<ActivityEvent> = emptyList(),
val groups: List<ActivityGroup> = emptyList(),
val loading: Boolean = true,
val loadingEarlier: Boolean = false,
val hasMore: Boolean = true,
val error: Boolean = false,
)
@HiltViewModel
class ActivityViewModel @Inject constructor(
private val repository: ActivityRepository,
) : ViewModel() {
private val _state = MutableStateFlow(ActivityUiState())
val state: StateFlow<ActivityUiState> = _state.asStateFlow()
private var observation: Job? = null
fun initialize(profileId: ProviderProfileId, zoneId: ZoneId = ZoneId.systemDefault()) {
if (_state.value.profileId == profileId && observation?.isActive == true) return
observation?.cancel()
_state.value = ActivityUiState(profileId = profileId)
observation = viewModelScope.launch {
repository.observeLatest(profileId, 100)
.catch { _state.value = _state.value.copy(loading = false, error = true) }
.collectLatest { result ->
when (result) {
is DomainResult.Success -> publish(result.value, zoneId)
is DomainResult.Failure -> _state.value = _state.value.copy(loading = false, error = true)
}
}
}
}
fun loadEarlier(zoneId: ZoneId = ZoneId.systemDefault()) {
val current = _state.value
val profileId = current.profileId ?: return
val last = current.events.lastOrNull() ?: return
if (current.loadingEarlier) return
viewModelScope.launch {
_state.value = _state.value.copy(loadingEarlier = true)
val result = repository.loadBefore(profileId, ActivityEventCursor(last.occurredAtEpochMillis, last.id), 100)
when (result) {
is DomainResult.Success -> publish((current.events + result.value).distinctBy { it.id }, zoneId)
is DomainResult.Failure -> _state.value = _state.value.copy(error = true)
}
_state.value = _state.value.copy(loadingEarlier = false, hasMore = resultHasMore(result))
}
}
private fun publish(events: List<ActivityEvent>, zoneId: ZoneId) {
val sorted = events.sortedWith(compareByDescending<ActivityEvent> { it.occurredAtEpochMillis }.thenByDescending { it.id.value })
val today = Instant.now().atZone(zoneId).toLocalDate()
val groups = sorted.groupBy {
val date = Instant.ofEpochMilli(it.occurredAtEpochMillis).atZone(zoneId).toLocalDate()
when (date) {
today -> "今天"
today.minusDays(1) -> "昨天"
else -> "更早记录"
}
}.map { ActivityGroup(it.key, it.value) }
_state.value = _state.value.copy(events = sorted, groups = groups, loading = false, error = false)
}
private fun resultHasMore(result: DomainResult<List<ActivityEvent>>): Boolean =
result is DomainResult.Success && result.value.size >= 100
}
@@ -0,0 +1,21 @@
<resources>
<string name="activity_title">活动记录</string>
<string name="activity_empty">暂无活动记录</string>
<string name="activity_error">活动记录加载失败</string>
<string name="activity_load_earlier">加载更早记录</string>
<string name="activity_source_user">手动操作</string>
<string name="activity_source_automation">自动化</string>
<string name="activity_source_system">系统</string>
<string name="activity_outcome_success">成功</string>
<string name="activity_outcome_failed">失败</string>
<string name="activity_outcome_cancelled">已取消</string>
<string name="activity_outcome_awaiting">等待确认</string>
<string name="activity_kind_socket_command">插座控制</string>
<string name="activity_kind_socket_confirmation">插座状态确认</string>
<string name="activity_kind_sensor_refresh">传感器刷新</string>
<string name="activity_kind_infrared_send">红外发送</string>
<string name="activity_kind_infrared_learn">红外学习</string>
<string name="activity_kind_scene_execution">场景执行</string>
<string name="activity_kind_scene_action">场景动作</string>
<string name="activity_error_code">错误码:%1$s</string>
</resources>
@@ -0,0 +1,21 @@
<resources>
<string name="activity_title">Activity</string>
<string name="activity_empty">No activity yet</string>
<string name="activity_error">Activity could not be loaded.</string>
<string name="activity_load_earlier">Load earlier activity</string>
<string name="activity_source_user">Manual</string>
<string name="activity_source_automation">Automation</string>
<string name="activity_source_system">System</string>
<string name="activity_outcome_success">Succeeded</string>
<string name="activity_outcome_failed">Failed</string>
<string name="activity_outcome_cancelled">Cancelled</string>
<string name="activity_outcome_awaiting">Awaiting confirmation</string>
<string name="activity_kind_socket_command">Socket command</string>
<string name="activity_kind_socket_confirmation">Socket confirmation</string>
<string name="activity_kind_sensor_refresh">Sensor refresh</string>
<string name="activity_kind_infrared_send">Infrared signal</string>
<string name="activity_kind_infrared_learn">Infrared learning</string>
<string name="activity_kind_scene_execution">Scene execution</string>
<string name="activity_kind_scene_action">Scene action</string>
<string name="activity_error_code">Error code: %1$s</string>
</resources>
@@ -0,0 +1,113 @@
package com.flagship.abox.manager.automation.ui.activity
import com.flagship.abox.manager.domain.ActivityEvent
import com.flagship.abox.manager.domain.ActivityEventCursor
import com.flagship.abox.manager.domain.ActivityEventId
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivityRepository
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.ProviderProfileId
import java.time.Instant
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.first
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 ActivityViewModelTest {
private val dispatcher = StandardTestDispatcher()
private val scope = TestScope(dispatcher)
private val profile = ProviderProfileId("profile")
private val zone = ZoneId.of("Asia/Singapore")
private lateinit var repository: TestActivityRepository
@Before
fun setUp() {
Dispatchers.setMain(dispatcher)
repository = TestActivityRepository()
}
@After
fun tearDown() = Dispatchers.resetMain()
@Test
fun realEmptyRepositoryShowsEmptyState() = scope.runTest {
val viewModel = ActivityViewModel(repository)
viewModel.initialize(profile, zone)
advanceUntilIdle()
assertTrue(viewModel.state.value.events.isEmpty())
assertTrue(viewModel.state.value.groups.isEmpty())
assertEquals(false, viewModel.state.value.error)
}
@Test
fun eventsGroupByLocalDate() = scope.runTest {
val now = Instant.now().atZone(zone).truncatedTo(ChronoUnit.MINUTES)
repository.latest.value = DomainResult.Success(
listOf(
event("today", now.toInstant().toEpochMilli()),
event("yesterday", now.minusDays(1).toInstant().toEpochMilli()),
event("earlier", now.minusDays(3).toInstant().toEpochMilli()),
),
)
val viewModel = ActivityViewModel(repository)
viewModel.initialize(profile, zone)
advanceUntilIdle()
assertEquals(listOf("今天", "昨天", "更早记录"), viewModel.state.value.groups.map { it.label })
}
@Test
fun loadEarlierRemovesDuplicateEventsByStableId() = scope.runTest {
val newest = event("newest", 30L)
val older = event("older", 20L)
repository.latest.value = DomainResult.Success(listOf(newest))
repository.before = DomainResult.Success(listOf(newest, older))
val viewModel = ActivityViewModel(repository)
viewModel.initialize(profile, zone)
advanceUntilIdle()
viewModel.loadEarlier(zone)
advanceUntilIdle()
assertEquals(listOf("newest", "older"), viewModel.state.value.events.map { it.id.value })
}
private fun event(id: String, time: Long) = ActivityEvent(
id = ActivityEventId(id),
profileId = profile,
occurredAtEpochMillis = time,
kind = ActivityEventKind.SOCKET_COMMAND,
source = ActivitySource.USER,
outcome = ActivityOutcome.SUCCEEDED,
target = ActivityTarget(ActivityTargetType.DEVICE, id, id),
)
private class TestActivityRepository : ActivityRepository {
val latest = MutableStateFlow<DomainResult<List<ActivityEvent>>>(DomainResult.Success(emptyList()))
var before: DomainResult<List<ActivityEvent>> = DomainResult.Success(emptyList())
override fun observeLatest(profileId: ProviderProfileId, limit: Int): Flow<DomainResult<List<ActivityEvent>>> = latest
override suspend fun loadBefore(profileId: ProviderProfileId, before: ActivityEventCursor, limit: Int) = this.before
override suspend fun append(event: ActivityEvent): DomainResult<Unit> = DomainResult.Success(Unit)
}
}
+8 -8
View File
@@ -1,13 +1,16 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.kapt)
alias(libs.plugins.hilt.android)
}
android {
namespace = "com.flagship.abox.manager.infrared"
compileSdk = 34
compileSdk = 37
defaultConfig {
minSdk = 26
@@ -18,18 +21,14 @@ android {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlinOptions {
jvmTarget = "17"
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
kapt {
@@ -58,6 +57,7 @@ dependencies {
implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
@@ -0,0 +1,57 @@
package com.flagship.abox.manager.infrared.hub
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
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.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class InfraredHubScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun rendersHardwareSummaryAndRemoteCollection() {
val profileId = ProviderProfileId("p1")
val remote = VirtualRemote(
id = VirtualRemoteId("remote-1"),
profileId = profileId,
deviceName = DeviceName("ir-1"),
displayName = "电视遥控器",
type = RemoteDeviceType.TV,
transmissionMode = TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 1L,
)
composeRule.setContent {
InfraredHubScreen(
state = InfraredHubUiState(
profileId = profileId,
savedDeviceName = DeviceName("ir-1"),
remotes = listOf(remote),
loading = false,
),
onBack = {},
onNavigateToSettings = {},
onOpenRemote = {},
onDeleteRemote = {},
onCreateRemote = { _, _, _, _, _ -> },
onSessionInvalid = {},
onMessageShown = {},
)
}
composeRule.onNodeWithTag("infrared_hardware_summary").assertIsDisplayed()
composeRule.onNodeWithTag("infrared_remote_collection").assertIsDisplayed()
}
}
@@ -0,0 +1,66 @@
package com.flagship.abox.manager.infrared.panel
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
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.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.TransmissionMode
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RemotePanelScreenTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun acPanelUsesTemperatureStepperSurface() {
composeRule.setContent {
RemotePanelScreen(
state = RemotePanelUiState(remote = remote(PanelLayoutStyle.AC_COMPACT, RemoteDeviceType.AIR_CONDITIONER), loading = false),
onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {},
onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {},
onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {},
onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {},
)
}
composeRule.onNodeWithTag("ac_temperature_stepper").assertIsDisplayed()
composeRule.onNodeWithTag("ac_mode_selector").assertIsDisplayed()
}
@Test
fun tvPanelUsesDirectionalPadAndRockers() {
composeRule.setContent {
RemotePanelScreen(
state = RemotePanelUiState(remote = remote(PanelLayoutStyle.TV_NAV, RemoteDeviceType.TV), loading = false),
onBack = {}, onSessionInvalid = {}, onButtonClick = {}, onToggleLearnMode = {},
onAddCustomButton = {}, onDeleteButton = {}, onToggleAcPower = {}, onAdjustAcTemp = {},
onSetAcMode = {}, onSetFanSpeed = {}, onToggleAcVerticalSwing = {},
onToggleAcHorizontalSwing = {}, onToggleAcEco = {}, onMessageShown = {},
)
}
composeRule.onNodeWithTag("tv_direction_pad").assertIsDisplayed()
composeRule.onNodeWithTag("tv_volume_channel_rockers").assertIsDisplayed()
}
private fun remote(layout: PanelLayoutStyle, type: RemoteDeviceType) = VirtualRemote(
id = VirtualRemoteId("remote-${layout.name}"),
profileId = ProviderProfileId("p1"),
deviceName = DeviceName("ir-1"),
displayName = "测试遥控器",
type = type,
layoutStyle = layout,
transmissionMode = if (layout == PanelLayoutStyle.AC_COMPACT) TransmissionMode.DYNAMIC_SCRATCH else TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = 1L,
updatedAtEpochMillis = 1L,
)
}
@@ -8,6 +8,7 @@ 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.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -3,17 +3,13 @@ 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.onNodeWithText
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
@@ -25,53 +21,32 @@ class InfraredControlScreenTest {
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,
)
fun rendersDeviceConfigurationAndAcceptsDeviceNameInput() {
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
var changedName: String? = null
composeTestRule.setContent {
InfraredControlScreen(
state = state,
onBack = {},
onSessionInvalid = {},
onDeviceNameChanged = {},
onDeviceNameChanged = { changedName = it },
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)
composeTestRule.onNodeWithText("运行环境与连接信息").assertIsDisplayed()
composeTestRule.onNodeWithTag("infrared_device_name_input")
.assertIsDisplayed()
.performTextInput("-updated")
composeTestRule.waitForIdle()
assertEquals("-updated", changedName)
}
}
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
VirtualRemoteEntity::class,
RemoteKeyBindingEntity::class,
],
version = 2,
version = 3,
exportSchema = false,
)
abstract class InfraredDatabase : RoomDatabase() {
@@ -65,6 +65,11 @@ data class VirtualRemoteEntity(
indices = [
Index(value = ["remoteId"]),
Index(value = ["profileId"]),
Index(
value = ["profileId", "hardwareDeviceName", "assignedSlot"],
unique = true,
name = "index_remote_key_bindings_hardware_slot",
),
],
)
data class RemoteKeyBindingEntity(
@@ -72,6 +77,7 @@ data class RemoteKeyBindingEntity(
val id: String,
val remoteId: String,
val profileId: String,
val hardwareDeviceName: String,
val buttonId: String,
val displayName: String,
val assignedSlot: String?,
@@ -0,0 +1,20 @@
package com.flagship.abox.manager.infrared.data.local
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL(
"ALTER TABLE remote_key_bindings ADD COLUMN hardwareDeviceName TEXT NOT NULL DEFAULT ''",
)
db.execSQL(
"UPDATE remote_key_bindings SET hardwareDeviceName = " +
"(SELECT deviceName FROM virtual_remotes WHERE virtual_remotes.id = remote_key_bindings.remoteId)",
)
db.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS index_remote_key_bindings_hardware_slot " +
"ON remote_key_bindings(profileId, hardwareDeviceName, assignedSlot)",
)
}
}
@@ -206,19 +206,36 @@ private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBindi
id = id,
remoteId = VirtualRemoteId(remoteId),
profileId = ProviderProfileId(profileId),
hardwareDeviceName = DeviceName(hardwareDeviceName),
buttonId = buttonId,
displayName = displayName,
assignedSlot = assignedSlot,
rawHexCode = rawHexCode,
status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY),
status = resolvedStatus(),
lastErrorCode = lastErrorCode,
lastErrorDetail = lastErrorDetail,
)
private fun RemoteKeyBindingEntity.resolvedStatus(): InfraredKeyStatus {
val parsed = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.UNLEARNED)
return if (
parsed == InfraredKeyStatus.FAILED &&
lastErrorCode == null &&
lastErrorDetail == null &&
assignedSlot == null &&
rawHexCode == null
) {
InfraredKeyStatus.UNLEARNED
} else {
parsed
}
}
private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity(
id = id,
remoteId = remoteId.value,
profileId = profileId.value,
hardwareDeviceName = hardwareDeviceName.value,
buttonId = buttonId,
displayName = displayName,
assignedSlot = assignedSlot,
@@ -20,6 +20,7 @@ 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 com.flagship.abox.manager.infrared.data.local.MIGRATION_2_3
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -49,7 +50,7 @@ object InfraredFeatureModule {
context,
InfraredDatabase::class.java,
"abox_infrared.db",
).fallbackToDestructiveMigration().build()
).addMigrations(MIGRATION_2_3).build()
}
@Provides
@@ -0,0 +1,414 @@
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.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowLeft
import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Home
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.KeyboardArrowUp
import androidx.compose.material.icons.outlined.Menu
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.material.icons.outlined.VolumeOff
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.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
@Composable
internal fun InfraredCenterControlSurface(
state: InfraredHubUiState,
onButtonClick: (String) -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleAcVerticalSwing: () -> Unit,
onToggleAcHorizontalSwing: () -> Unit,
onToggleAcEco: () -> Unit,
modifier: Modifier = Modifier,
) {
val remote = state.selectedRemote ?: return
when (remote.layoutStyle) {
PanelLayoutStyle.AC_COMPACT -> CenterAcPanel(
state = state,
onTogglePower = onToggleAcPower,
onAdjustTemp = onAdjustAcTemp,
onSetMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleVerticalSwing = onToggleAcVerticalSwing,
onToggleHorizontalSwing = onToggleAcHorizontalSwing,
onToggleEco = onToggleAcEco,
modifier = modifier,
)
PanelLayoutStyle.TV_NAV -> CenterTvPanel(state, onButtonClick, modifier)
PanelLayoutStyle.MEDIA_PLAYER -> CenterMediaPanel(state, onButtonClick, modifier)
PanelLayoutStyle.GRID -> CenterGridPanel(
remote = remote,
bindings = state.bindings,
learningButtonId = state.learningButtonId,
sendingButtonId = state.sendingButtonId,
onButtonClick = onButtonClick,
onAddButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
modifier = modifier,
)
}
}
@Composable
private fun CenterAcPanel(
state: InfraredHubUiState,
onTogglePower: () -> Unit,
onAdjustTemp: (Int) -> Unit,
onSetMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleVerticalSwing: () -> Unit,
onToggleHorizontalSwing: () -> Unit,
onToggleEco: () -> Unit,
modifier: Modifier,
) {
val ac = state.acState
Column(
modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Surface(
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth(),
) {
Box(Modifier.fillMaxWidth().padding(20.dp)) {
IconButton(
onClick = onTogglePower,
modifier = Modifier.align(Alignment.TopEnd).background(
if (ac.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline,
CircleShape,
),
) {
Icon(Icons.Outlined.PowerSettingsNew, "空调电源", tint = Color.White)
}
Row(
Modifier.fillMaxWidth().padding(top = 38.dp),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
StepButton(Icons.Outlined.Remove, "降低温度") { onAdjustTemp(-1) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("${ac.temperature}°", style = MaterialTheme.typography.displayMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.infrared_set_temperature), style = MaterialTheme.typography.labelMedium)
}
StepButton(Icons.Outlined.Add, "升高温度") { onAdjustTemp(1) }
}
}
}
CompactOptionGroup(stringResource(R.string.infrared_running_mode)) {
AcMode.entries.forEach { mode ->
FilterChip(ac.mode == mode, { onSetMode(mode) }, { Text(mode.centerLabel()) })
}
}
CompactOptionGroup(stringResource(R.string.infrared_fan_speed)) {
AcFanSpeed.entries.forEach { speed ->
FilterChip(ac.fanSpeed == speed, { onSetFanSpeed(speed) }, { Text(speed.centerLabel()) })
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
CompactToggle(stringResource(R.string.infrared_vertical_swing), ac.verticalSwing, onToggleVerticalSwing, Modifier.weight(1f))
CompactToggle(stringResource(R.string.infrared_horizontal_swing), ac.horizontalSwing, onToggleHorizontalSwing, Modifier.weight(1f))
CompactToggle(stringResource(R.string.infrared_eco), ac.eco, onToggleEco, Modifier.weight(1f))
}
Text(
stringResource(R.string.infrared_transmission_notice),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
private fun CenterTvPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) {
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteIconKey("TV_POWER", stringResource(R.string.infrared_key_tv_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick)
RemoteIconKey("TV_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick)
RemoteIconKey("TV_SOURCE", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Tv, state, onButtonClick)
}
Card(
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier.fillMaxWidth(),
) {
Box(Modifier.fillMaxWidth().height(260.dp), contentAlignment = Alignment.Center) {
Surface(shape = CircleShape, color = MaterialTheme.colorScheme.surface, modifier = Modifier.size(220.dp)) {
Box(Modifier.size(220.dp), contentAlignment = Alignment.Center) {
DirectionKey("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, stringResource(R.string.infrared_up), Alignment.TopCenter, state, onButtonClick)
DirectionKey("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, stringResource(R.string.infrared_down), Alignment.BottomCenter, state, onButtonClick)
DirectionKey("TV_NAV_LEFT", Icons.AutoMirrored.Outlined.KeyboardArrowLeft, stringResource(R.string.infrared_left), Alignment.CenterStart, state, onButtonClick)
DirectionKey("TV_NAV_RIGHT", Icons.AutoMirrored.Outlined.KeyboardArrowRight, stringResource(R.string.infrared_right), Alignment.CenterEnd, state, onButtonClick)
Button(onClick = { onButtonClick("TV_OK") }, shape = CircleShape, modifier = Modifier.size(72.dp)) { Text("OK") }
}
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
RemoteTextKey("TV_BACK", stringResource(R.string.infrared_key_tv_back), state, onButtonClick, Modifier.weight(1f))
RemoteIconTextKey("TV_HOME", stringResource(R.string.infrared_key_tv_home), Icons.Outlined.Home, state, onButtonClick, Modifier.weight(1f))
RemoteIconTextKey("TV_MENU", stringResource(R.string.infrared_key_tv_menu), Icons.Outlined.Menu, state, onButtonClick, Modifier.weight(1f))
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Rocker(stringResource(R.string.infrared_volume), "TV_VOL_UP", "TV_VOL_DOWN", state, onButtonClick, Modifier.weight(1f))
Rocker(stringResource(R.string.infrared_channel), "TV_CH_UP", "TV_CH_DOWN", state, onButtonClick, Modifier.weight(1f))
}
}
}
@Composable
private fun CenterMediaPanel(state: InfraredHubUiState, onButtonClick: (String) -> Unit, modifier: Modifier) {
Column(modifier.padding(top = 12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
RemoteIconKey("MEDIA_POWER", stringResource(R.string.infrared_key_ac_power), Icons.Outlined.PowerSettingsNew, state, onButtonClick)
RemoteIconKey("MEDIA_MUTE", stringResource(R.string.infrared_key_tv_mute), Icons.Outlined.VolumeOff, state, onButtonClick)
}
Text(stringResource(R.string.infrared_media_panel), style = MaterialTheme.typography.titleMedium, modifier = Modifier.align(Alignment.CenterHorizontally))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf("MEDIA_PREV" to "上一项", "MEDIA_PLAY_PAUSE" to "播放/暂停", "MEDIA_NEXT" to "下一项").forEach { (id, label) ->
RemoteTextKey(id, label, state, onButtonClick, Modifier.weight(1f))
}
}
}
}
@Composable
private fun CenterGridPanel(
remote: VirtualRemote,
bindings: List<RemoteKeyBinding>,
learningButtonId: String?,
sendingButtonId: String?,
onButtonClick: (String) -> Unit,
onAddButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
modifier: Modifier,
) {
var showAddDialog by remember(remote.id) { mutableStateOf(false) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
stringResource(R.string.infrared_remote_keys, remote.displayName),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f),
)
Button(onClick = { showAddDialog = true }, shape = RoundedCornerShape(6.dp)) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(18.dp))
Text(stringResource(R.string.infrared_add_button), modifier = Modifier.padding(start = 6.dp))
}
}
if (bindings.isEmpty()) {
Text(stringResource(R.string.infrared_no_buttons_manage), color = MaterialTheme.colorScheme.onSurfaceVariant)
} else {
LazyVerticalGrid(columns = GridCells.Fixed(2), verticalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
items(bindings, key = { it.id }) { binding ->
Card(
modifier = Modifier.fillMaxWidth().clickable { onButtonClick(binding.buttonId) },
shape = RoundedCornerShape(6.dp),
) {
Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
binding.displayName,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
when {
learningButtonId == binding.buttonId -> stringResource(R.string.infrared_button_learning)
sendingButtonId == binding.buttonId -> stringResource(R.string.infrared_button_sending)
binding.status == InfraredKeyStatus.READY -> stringResource(R.string.infrared_button_ready)
binding.status == InfraredKeyStatus.FAILED -> stringResource(R.string.infrared_button_failed)
binding.status == InfraredKeyStatus.UNLEARNED -> stringResource(R.string.infrared_button_unlearned)
else -> stringResource(R.string.infrared_button_unlearned)
},
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
IconButton(
onClick = { onDeleteButton(binding.buttonId) },
modifier = Modifier.size(48.dp),
) {
Icon(Icons.Outlined.Remove, contentDescription = stringResource(R.string.infrared_delete_button))
}
}
}
}
}
}
}
if (showAddDialog) {
AddGridButtonDialog(
onDismiss = { showAddDialog = false },
onConfirm = { name ->
onAddButton(name)
showAddDialog = false
},
)
}
}
@Composable
private fun AddGridButtonDialog(
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_add_button_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
stringResource(R.string.infrared_add_button_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.infrared_button_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(onClick = { onConfirm(name.trim()) }, enabled = name.isNotBlank()) {
Text(stringResource(R.string.infrared_add_button))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.infrared_dialog_cancel))
}
},
)
}
@Composable private fun StepButton(icon: ImageVector, description: String, onClick: () -> Unit) {
IconButton(onClick = onClick, modifier = Modifier.size(52.dp).background(MaterialTheme.colorScheme.surface, RoundedCornerShape(6.dp))) {
Icon(icon, description)
}
}
@Composable private fun CompactOptionGroup(title: String, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
Card(shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp), content = content)
}
}
}
@Composable private fun CompactToggle(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier) {
Surface(
modifier = modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(6.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
) { Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(horizontal = 8.dp, vertical = 14.dp)) }
}
@Composable private fun RemoteIconKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.size(84.dp).clickable { onClick(id) }) {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(20.dp)) else Icon(icon, label)
Text(label, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(top = 6.dp))
}
}
}
@Composable private fun BoxScope.DirectionKey(id: String, icon: ImageVector, description: String, alignment: Alignment, state: InfraredHubUiState, onClick: (String) -> Unit) {
IconButton(onClick = { onClick(id) }, modifier = Modifier.align(alignment).padding(10.dp)) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Icon(icon, description, Modifier.size(30.dp))
}
}
@Composable private fun RemoteTextKey(id: String, label: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) {
if (state.sendingButtonId == id || state.learningButtonId == id) CircularProgressIndicator(Modifier.size(18.dp)) else Text(label)
}
}
@Composable private fun RemoteIconTextKey(id: String, label: String, icon: ImageVector, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Button(onClick = { onClick(id) }, modifier = modifier.height(52.dp), shape = RoundedCornerShape(6.dp)) {
Icon(icon, label, Modifier.size(18.dp)); Text(label, Modifier.padding(start = 5.dp))
}
}
@Composable private fun Rocker(title: String, upId: String, downId: String, state: InfraredHubUiState, onClick: (String) -> Unit, modifier: Modifier) {
Card(modifier, shape = RoundedCornerShape(6.dp)) {
Column(Modifier.padding(10.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(title, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold)
IconButton({ onClick(upId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Add, "提高$title") }
IconButton({ onClick(downId) }, Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(4.dp))) { Icon(Icons.Outlined.Remove, "降低$title") }
}
}
}
private fun AcMode.centerLabel() = when (this) { AcMode.COOL -> "制冷"; AcMode.HEAT -> "制热"; AcMode.AUTO -> "自动"; AcMode.DRY -> "除湿"; AcMode.FAN -> "送风" }
private fun AcFanSpeed.centerLabel() = when (this) { AcFanSpeed.AUTO -> "自动"; AcFanSpeed.LOW -> "低速"; AcFanSpeed.MEDIUM -> "中速"; AcFanSpeed.HIGH -> "高速" }
@@ -2,6 +2,8 @@ package com.flagship.abox.manager.infrared.hub
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -16,6 +18,10 @@ 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.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
@@ -24,6 +30,8 @@ import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
@@ -40,6 +48,8 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.SwapVert
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -50,7 +60,8 @@ 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.graphics.SolidColor
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -59,6 +70,11 @@ 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.designsystem.ABoxBackLayer
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
@@ -69,6 +85,8 @@ 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
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
@Composable
fun InfraredHubRoute(
@@ -87,6 +105,21 @@ fun InfraredHubRoute(
onOpenRemote = onOpenRemote,
onDeleteRemote = viewModel::deleteRemote,
onCreateRemote = viewModel::createRemote,
onSaveDeviceName = viewModel::saveDeviceName,
onMoveRemote = viewModel::moveRemote,
onUpdateRemote = viewModel::updateRemote,
onSelectRemote = viewModel::selectRemote,
onToggleLearnMode = viewModel::toggleLearnMode,
onButtonClick = viewModel::onButtonClick,
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,
onSessionInvalid = onSessionInvalid,
onMessageShown = viewModel::clearMessage,
)
@@ -101,12 +134,28 @@ internal fun InfraredHubScreen(
onOpenRemote: (String) -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit,
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
onSaveDeviceName: (String) -> Unit = {},
onMoveRemote: (VirtualRemoteId, Int) -> Unit = { _, _ -> },
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit = { _, _, _, _ -> },
onSessionInvalid: () -> Unit,
onMessageShown: () -> Unit,
onSelectRemote: (VirtualRemoteId) -> Unit = {},
onToggleLearnMode: () -> Unit = {},
onButtonClick: (String) -> Unit = {},
onAddCustomButton: (String) -> Unit = {},
onDeleteButton: (String) -> Unit = {},
onToggleAcPower: () -> Unit = {},
onAdjustAcTemp: (Int) -> Unit = {},
onSetAcMode: (AcMode) -> Unit = {},
onSetFanSpeed: (AcFanSpeed) -> Unit = {},
onToggleAcVerticalSwing: () -> Unit = {},
onToggleAcHorizontalSwing: () -> Unit = {},
onToggleAcEco: () -> Unit = {},
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
var showCreateDialog by remember { mutableStateOf(false) }
var showManagement by remember { mutableStateOf(false) }
LaunchedEffect(state.message) {
state.message?.let { msg ->
@@ -114,7 +163,7 @@ internal fun InfraredHubScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -122,7 +171,7 @@ internal fun InfraredHubScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -135,40 +184,19 @@ internal fun InfraredHubScreen(
}
}
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)
}
},
ABoxDeviceDetailScaffold(
title = state.savedDeviceName.value,
subtitle = stringResource(R.string.infrared_center_subtitle),
icon = ABoxIconKey.INFRARED,
onBack = onBack,
action = {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = stringResource(R.string.infrared_manage_action),
onClick = { showManagement = true },
modifier = Modifier.testTag("hub_settings_button"),
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
ABoxSurface(
modifier = Modifier
@@ -178,58 +206,105 @@ internal fun InfraredHubScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.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,
)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(12.dp)) {
ABoxIcon(ABoxIconKey.INFRARED, stringResource(R.string.infrared_hub_no_remotes), modifier = Modifier.size(48.dp))
Text(stringResource(R.string.infrared_hub_no_remotes), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.infrared_no_remotes_hint), color = MaterialTheme.colorScheme.onSurfaceVariant)
Button(onClick = { showManagement = true }) { Text(stringResource(R.string.infrared_open_management)) }
}
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxWidth().weight(1f),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
LazyRow(
modifier = Modifier.fillMaxWidth().testTag("infrared_remote_collection"),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
items(state.remotes, key = { it.id.value }) { remote ->
VirtualRemoteCard(
remote = remote,
onClick = { onOpenRemote(remote.id.value) },
onDelete = { onDeleteRemote(remote.id) },
FilterChip(
selected = state.selectedRemoteId == remote.id,
onClick = { onSelectRemote(remote.id) },
label = { Text(remote.displayName) },
leadingIcon = {
ABoxIcon(
key = remoteIcon(remote.type),
contentDescription = remote.displayName,
modifier = Modifier.size(18.dp),
)
},
)
}
}
state.selectedRemote?.let { remote ->
Card(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Column {
Text(remote.displayName, fontWeight = FontWeight.Bold)
Text(
text = "${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (remote.type == RemoteDeviceType.CUSTOM) {
FilterChip(
selected = state.isLearnMode,
onClick = onToggleLearnMode,
label = { Text(if (state.isLearnMode) stringResource(R.string.infrared_recording) else stringResource(R.string.infrared_learn_mode)) },
)
}
}
}
InfraredCenterControlSurface(
state = state,
onButtonClick = onButtonClick,
onAddCustomButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
onToggleAcPower = onToggleAcPower,
onAdjustAcTemp = onAdjustAcTemp,
onSetAcMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleAcVerticalSwing = onToggleAcVerticalSwing,
onToggleAcHorizontalSwing = onToggleAcHorizontalSwing,
onToggleAcEco = onToggleAcEco,
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
}
}
}
}
if (showManagement) {
ABoxBackLayer(onBack = { showManagement = false }) {
ABoxDeviceDetailScaffold(
title = state.savedDeviceName.value,
subtitle = stringResource(R.string.infrared_settings_subtitle),
icon = ABoxIconKey.INFRARED,
onBack = { showManagement = false },
) { innerPadding ->
ABoxSurface(
modifier = Modifier.fillMaxSize().padding(innerPadding),
) {
InfraredManagementScreen(
state = state,
onAddRemote = { showCreateDialog = true },
onDeleteRemote = onDeleteRemote,
onSaveDeviceName = onSaveDeviceName,
onMoveRemote = onMoveRemote,
onUpdateRemote = onUpdateRemote,
)
}
}
}
@@ -244,6 +319,226 @@ internal fun InfraredHubScreen(
},
)
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.fillMaxSize().padding(bottom = 16.dp),
)
}
@Composable
private fun StableSingleLineField(
value: String,
onValueChange: (String) -> Unit,
label: String,
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
shape = RoundedCornerShape(6.dp),
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline),
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
BasicTextField(
value = value,
onValueChange = onValueChange,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
modifier = Modifier.fillMaxWidth(),
)
}
}
}
@Composable
private fun InfraredManagementScreen(
state: InfraredHubUiState,
onAddRemote: () -> Unit,
onDeleteRemote: (VirtualRemoteId) -> Unit,
onSaveDeviceName: (String) -> Unit,
onMoveRemote: (VirtualRemoteId, Int) -> Unit,
onUpdateRemote: (VirtualRemoteId, String, PanelLayoutStyle, TransmissionMode) -> Unit,
) {
var deviceName by remember(state.savedDeviceName.value) { mutableStateOf(state.savedDeviceName.value) }
var reorderRemoteId by remember { mutableStateOf<VirtualRemoteId?>(null) }
var editingRemote by remember { mutableStateOf<VirtualRemote?>(null) }
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(stringResource(R.string.infrared_hardware_slots_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
InfraredHardwareSummary(
deviceName = state.savedDeviceName.value,
remoteCount = state.remotes.size,
quota = state.slotQuota,
)
}
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(stringResource(R.string.infrared_hub_remotes_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.infrared_remote_count, state.remotes.size),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Button(onClick = onAddRemote) { Text(stringResource(R.string.infrared_add_remote)) }
}
state.remotes.forEach { remote ->
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) {
Row(Modifier.fillMaxWidth().padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) {
ABoxIcon(
remoteIcon(remote.type),
remote.displayName,
modifier = Modifier.padding(9.dp).size(24.dp),
)
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(remote.displayName, fontWeight = FontWeight.Bold)
Text(
"${remote.brandModel ?: stringResource(R.string.infrared_custom)} · ${transmissionLabel(remote.transmissionMode)} · ${layoutLabel(remote.layoutStyle)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box {
IconButton(onClick = { reorderRemoteId = remote.id }) {
Icon(Icons.Outlined.SwapVert, contentDescription = stringResource(R.string.infrared_move_remote))
}
DropdownMenu(
expanded = reorderRemoteId == remote.id,
onDismissRequest = { reorderRemoteId = null },
) {
val index = state.remotes.indexOf(remote)
DropdownMenuItem(
text = { Text(stringResource(R.string.infrared_move_up)) },
enabled = index > 0,
onClick = { reorderRemoteId = null; onMoveRemote(remote.id, -1) },
)
DropdownMenuItem(
text = { Text(stringResource(R.string.infrared_move_down)) },
enabled = index < state.remotes.lastIndex,
onClick = { reorderRemoteId = null; onMoveRemote(remote.id, 1) },
)
}
}
ABoxIconButton(ABoxIconKey.EDIT, stringResource(R.string.infrared_edit_remote), { editingRemote = remote })
}
}
}
Card(Modifier.fillMaxWidth(), shape = RoundedCornerShape(6.dp)) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(stringResource(R.string.infrared_gateway_name_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.infrared_gateway_name_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
StableSingleLineField(
value = deviceName,
onValueChange = { deviceName = it },
label = stringResource(R.string.infrared_device_name_label),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { onSaveDeviceName(deviceName.trim()) },
enabled = deviceName.isNotBlank() && deviceName != state.savedDeviceName.value,
modifier = Modifier.align(Alignment.End),
) { Text(stringResource(R.string.infrared_save_name)) }
}
}
Spacer(Modifier.height(24.dp))
}
editingRemote?.let { remote ->
EditRemoteDialog(
remote = remote,
onDismiss = { editingRemote = null },
onSave = { name, layout, mode ->
editingRemote = null
onUpdateRemote(remote.id, name, layout, mode)
},
onDelete = {
editingRemote = null
onDeleteRemote(remote.id)
},
)
}
}
@Composable
private fun EditRemoteDialog(
remote: VirtualRemote,
onDismiss: () -> Unit,
onSave: (String, PanelLayoutStyle, TransmissionMode) -> Unit,
onDelete: () -> Unit,
) {
var name by remember(remote.id) { mutableStateOf(remote.displayName) }
var layout by remember(remote.id) { mutableStateOf(remote.layoutStyle) }
var mode by remember(remote.id) { mutableStateOf(remote.transmissionMode) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.infrared_edit_remote)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(value = name, onValueChange = { name = it }, label = { Text(stringResource(R.string.infrared_remote_name)) }, singleLine = true)
Text(stringResource(R.string.infrared_layout), fontWeight = FontWeight.Bold)
PanelLayoutStyle.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { layout = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = layout == option, onClick = { layout = option })
Text(layoutLabel(option))
}
}
Text(stringResource(R.string.infrared_transmission_mode), fontWeight = FontWeight.Bold)
TransmissionMode.entries.forEach { option ->
Row(Modifier.fillMaxWidth().clickable { mode = option }, verticalAlignment = Alignment.CenterVertically) {
RadioButton(selected = mode == option, onClick = { mode = option })
Text(transmissionLabel(option))
}
}
TextButton(onClick = onDelete) { Text(stringResource(R.string.infrared_delete_remote), color = MaterialTheme.colorScheme.error) }
}
},
confirmButton = { Button(onClick = { onSave(name.trim(), layout, mode) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.infrared_device_save_changes)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.infrared_dialog_cancel)) } },
)
}
private fun remoteIcon(type: RemoteDeviceType): ABoxIconKey = when (type) {
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
@Composable
private fun layoutLabel(layout: PanelLayoutStyle): String = when (layout) {
PanelLayoutStyle.TV_NAV -> stringResource(R.string.infrared_layout_tv)
PanelLayoutStyle.AC_COMPACT -> stringResource(R.string.infrared_layout_ac)
PanelLayoutStyle.MEDIA_PLAYER -> stringResource(R.string.infrared_layout_media)
PanelLayoutStyle.GRID -> stringResource(R.string.infrared_layout_grid)
}
@Composable
private fun transmissionLabel(mode: TransmissionMode): String = when (mode) {
TransmissionMode.STATIC_SLOTS -> stringResource(R.string.infrared_static_slots)
TransmissionMode.DYNAMIC_SCRATCH -> stringResource(R.string.infrared_dynamic_codes)
}
@Composable
@@ -271,15 +566,18 @@ private fun VirtualRemoteCard(
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)
RemoteDeviceType.TV -> ABoxIconKey.DEVICES
RemoteDeviceType.AIR_CONDITIONER -> ABoxIconKey.TEMPERATURE
RemoteDeviceType.FAN -> ABoxIconKey.REFRESH
RemoteDeviceType.CUSTOM -> ABoxIconKey.INFRARED
}
ABoxIcon(icon, contentDescription = remote.displayName, modifier = Modifier.size(30.dp))
ABoxIconButton(
key = ABoxIconKey.CLOSE,
contentDescription = "删除${remote.displayName}",
onClick = onDelete,
modifier = Modifier.testTag("delete_remote_${remote.id.value}"),
)
}
Text(
@@ -288,11 +586,11 @@ private fun VirtualRemoteCard(
)
val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) {
"❄️ 状态机动态写码"
"状态机动态写码"
} else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
"🚀 静态独占槽位"
"静态独占槽位"
} else {
"📦 动态写码"
"动态写码"
}
Text(
@@ -305,9 +603,13 @@ private fun VirtualRemoteCard(
}
@Composable
private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
private fun InfraredHardwareSummary(
deviceName: String,
remoteCount: Int,
quota: SlotQuotaSummary,
) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag("infrared_hardware_summary"),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
@@ -323,13 +625,28 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Surface(
shape = RoundedCornerShape(14.dp),
color = MaterialTheme.colorScheme.primaryContainer,
) {
ABoxIcon(
key = ABoxIconKey.INFRARED,
contentDescription = "红外硬件",
modifier = Modifier.padding(10.dp).size(26.dp),
)
}
Column {
Text(deviceName, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(
text = "红外控制器 · $remoteCount 个虚拟遥控器",
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
text = "硬件槽位配额 (总计 100 槽位)",
fontWeight = FontWeight.Bold,
fontSize = 14.sp,
)
Text(
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用",
text = "${quota.usedCount}/${quota.totalCapacity}",
fontWeight = FontWeight.Bold,
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
fontSize = 13.sp,
@@ -345,7 +662,7 @@ private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
)
Text(
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个",
text = "静态槽位 ${quota.assignedStaticSlots.size} 个 · 动态缓冲槽位 100 · 剩余 ${quota.remainingStaticCount} 个",
fontSize = 11.sp,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -430,7 +747,7 @@ private fun CreateVirtualRemoteDialog(
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("遥控器名称(如:客厅电视、我的遥控器)") },
label = { Text("遥控器名称(如:电视、我的遥控器)") },
singleLine = true,
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
)
@@ -443,10 +760,10 @@ private fun CreateVirtualRemoteDialog(
) {
OutlinedTextField(
value = when (selectedCustomLayout) {
PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)"
PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)"
PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)"
PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局"
PanelLayoutStyle.TV_NAV -> "电视导航布局(内置方向盘、音量、频道)"
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放器布局(播放、快进、切歌)"
PanelLayoutStyle.GRID -> "自由网格布局(可自由增删按键)"
PanelLayoutStyle.AC_COMPACT -> "空调布局"
},
onValueChange = {},
readOnly = true,
@@ -459,21 +776,21 @@ private fun CreateVirtualRemoteDialog(
onDismissRequest = { layoutDropdownExpanded = false },
) {
DropdownMenuItem(
text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") },
text = { Text("电视导航布局(内置方向盘、音量、频道)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.TV_NAV
layoutDropdownExpanded = false
},
)
DropdownMenuItem(
text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") },
text = { Text("媒体播放器布局(播放、快进、切歌)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
layoutDropdownExpanded = false
},
)
DropdownMenuItem(
text = { Text("🎛️ 自由网格布局 (可自由增删按键)") },
text = { Text("自由网格布局(可自由增删按键)") },
onClick = {
selectedCustomLayout = PanelLayoutStyle.GRID
layoutDropdownExpanded = false
@@ -538,7 +855,7 @@ private fun CreateVirtualRemoteDialog(
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
)
Column {
Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium)
Text("静态独占槽位模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -552,7 +869,7 @@ private fun CreateVirtualRemoteDialog(
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
)
Column {
Text("📦 动态写码模式", fontWeight = FontWeight.Medium)
Text("动态写码模式", fontWeight = FontWeight.Medium)
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@@ -563,7 +880,7 @@ private fun CreateVirtualRemoteDialog(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。",
text = "空调多维状态自动采用状态机和动态缓冲写码,避免状态冲突并节省槽位。",
fontSize = 12.sp,
modifier = Modifier.padding(10.dp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
@@ -4,7 +4,10 @@ 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.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.VirtualRemote
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
import com.flagship.abox.manager.infrared.remote.UiText
sealed interface HubUiMessage {
@@ -19,6 +22,13 @@ data class InfraredHubUiState(
val providerDisplayName: String? = null,
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
val remotes: List<VirtualRemote> = emptyList(),
val selectedRemoteId: VirtualRemoteId? = null,
val selectedRemote: VirtualRemote? = null,
val bindings: List<RemoteKeyBinding> = emptyList(),
val acState: AcStateSnapshot = AcStateSnapshot(),
val isLearnMode: Boolean = false,
val learningButtonId: String? = null,
val sendingButtonId: String? = null,
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
val loading: Boolean = true,
val message: HubUiMessage? = null,
@@ -9,9 +9,23 @@ 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.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
import com.flagship.abox.manager.domain.infrared.TransmissionMode
@@ -30,7 +44,10 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -43,12 +60,26 @@ class InfraredHubViewModel @Inject constructor(
private val virtualRemoteRepository: VirtualRemoteRepository,
private val createVirtualRemote: CreateVirtualRemote,
private val deleteVirtualRemote: DeleteVirtualRemote,
private val sendVirtualRemoteAction: SendVirtualRemoteAction,
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
private val addVirtualRemoteKey: AddVirtualRemoteKey,
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
private val activityWriter: ActivityEventWriter,
) : ViewModel() {
private val rawProfileId: String? = savedStateHandle["profileId"]
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
private val messageFlow = MutableStateFlow<HubUiMessage?>(null)
private val selectedRemoteIdFlow = MutableStateFlow<VirtualRemoteId?>(null)
private val isLearnModeFlow = MutableStateFlow(false)
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
private var acDebounceJob: Job? = null
private val routeDeviceName: DeviceName? = savedStateHandle.get<String>("deviceName")
?.takeIf { it.isNotBlank() }
?.let(::DeviceName)
private data class HubSnapshot(
val providerProfile: ProviderProfile? = null,
@@ -68,7 +99,12 @@ class InfraredHubViewModel @Inject constructor(
when (devResult) {
is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error))
is DomainResult.Success -> {
val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME
val deviceName = routeDeviceName ?: devResult.value ?: return@flatMapLatest flowOf(
HubSnapshot(
providerProfile = active,
configError = DomainError.InvalidRequest(detail = "No infrared device configured"),
),
)
combine(
virtualRemoteRepository.observeRemotes(profileId, deviceName),
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
@@ -88,10 +124,51 @@ class InfraredHubViewModel @Inject constructor(
}
}
private data class PanelSnapshot(
val selectedId: VirtualRemoteId?,
val remote: VirtualRemote?,
val bindings: List<RemoteKeyBinding>,
)
private val panelSnapshotFlow = combine(hubSnapshotFlow, selectedRemoteIdFlow) { hub, selectedId ->
val resolvedId = selectedId?.takeIf { id -> hub.remotes.any { it.id == id } } ?: hub.remotes.firstOrNull()?.id
resolvedId to hub
}.flatMapLatest { (selectedId, hub) ->
if (selectedId == null) {
flowOf(PanelSnapshot(null, null, emptyList()))
} else {
virtualRemoteRepository.observeKeyBindings(selectedId).map { result ->
PanelSnapshot(
selectedId,
hub.remotes.firstOrNull { it.id == selectedId },
if (result is DomainResult.Success) result.value else emptyList(),
)
}
}
}
private data class CenterActions(
val learnMode: Boolean,
val learningButtonId: String?,
val sendingButtonId: String?,
val localAcState: AcStateSnapshot?,
)
private val centerActionsFlow = combine(
isLearnModeFlow,
learningButtonIdFlow,
sendingButtonIdFlow,
localAcStateFlow,
) { learnMode, learningId, sendingId, localAc ->
CenterActions(learnMode, learningId, sendingId, localAc)
}
val state: StateFlow<InfraredHubUiState> = combine(
hubSnapshotFlow,
panelSnapshotFlow,
centerActionsFlow,
messageFlow,
) { snapshot, msg ->
) { snapshot, panel, actions, msg ->
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid
@@ -111,6 +188,13 @@ class InfraredHubViewModel @Inject constructor(
providerDisplayName = snapshot.providerProfile?.displayName,
savedDeviceName = snapshot.savedDevice,
remotes = snapshot.remotes,
selectedRemoteId = panel.selectedId,
selectedRemote = panel.remote,
bindings = panel.bindings,
acState = actions.localAcState ?: panel.remote?.acState ?: AcStateSnapshot(),
isLearnMode = actions.learnMode,
learningButtonId = actions.learningButtonId,
sendingButtonId = actions.sendingButtonId,
slotQuota = snapshot.slotQuota,
loading = false,
message = resolvedMessage,
@@ -160,6 +244,155 @@ class InfraredHubViewModel @Inject constructor(
}
}
fun selectRemote(remoteId: VirtualRemoteId) {
selectedRemoteIdFlow.value = remoteId
localAcStateFlow.value = null
isLearnModeFlow.value = false
}
fun moveRemote(remoteId: VirtualRemoteId, offset: Int) {
val remotes = state.value.remotes
val from = remotes.indexOfFirst { it.id == remoteId }
val to = from + offset
if (from !in remotes.indices || to !in remotes.indices) return
val current = remotes[from]
val target = remotes[to]
viewModelScope.launch {
val first = virtualRemoteRepository.updateRemote(
current.copy(createdAtEpochMillis = target.createdAtEpochMillis),
)
val second = virtualRemoteRepository.updateRemote(
target.copy(createdAtEpochMillis = current.createdAtEpochMillis),
)
if (first is DomainResult.Failure) handleDomainError(first.error)
else if (second is DomainResult.Failure) handleDomainError(second.error)
}
}
fun updateRemote(
remoteId: VirtualRemoteId,
displayName: String,
layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle,
transmissionMode: TransmissionMode,
) {
val remote = state.value.remotes.firstOrNull { it.id == remoteId } ?: return
val trimmed = displayName.trim()
if (trimmed.isBlank()) return
viewModelScope.launch {
when (val result = virtualRemoteRepository.updateRemote(
remote.copy(
displayName = trimmed,
layoutStyle = layoutStyle,
transmissionMode = transmissionMode,
updatedAtEpochMillis = System.currentTimeMillis(),
),
)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器配置已保存"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun toggleLearnMode() {
if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return
isLearnModeFlow.update { !it }
}
fun onButtonClick(buttonId: String) {
if (isLearnModeFlow.value) learnButton(buttonId) else sendButton(buttonId)
}
private fun sendButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
if (sendingButtonIdFlow.value != null || learningButtonIdFlow.value != null) return
sendingButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val result = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.SUCCEEDED)
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态"))
}
is DomainResult.Failure -> {
recordRemoteActivity(ActivityEventKind.INFRARED_SEND, ActivityOutcome.FAILED, result.error.externalCode)
handleDomainError(result.error)
}
}
} finally {
sendingButtonIdFlow.value = null
}
}
}
private fun learnButton(buttonId: String) {
if (state.value.selectedRemote?.type != RemoteDeviceType.CUSTOM) return
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
if (learningButtonIdFlow.value != null) return
learningButtonIdFlow.value = buttonId
viewModelScope.launch {
try {
when (val result = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.SUCCEEDED)
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键信号已学习并保存"))
}
is DomainResult.Failure -> {
recordRemoteActivity(ActivityEventKind.INFRARED_LEARN, ActivityOutcome.FAILED, result.error.externalCode)
handleDomainError(result.error)
}
}
} finally {
learningButtonIdFlow.value = null
}
}
}
fun addCustomButton(displayName: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
viewModelScope.launch {
when (val result = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已添加按键"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun deleteButton(buttonId: String) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
viewModelScope.launch {
when (val result = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("按键已删除"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun toggleAcPower() = updateAc(state.value.acState.copy(power = !state.value.acState.power))
fun adjustAcTemperature(delta: Int) = updateAc(state.value.acState.copy(temperature = (state.value.acState.temperature + delta).coerceIn(16, 30)))
fun setAcMode(mode: AcMode) = updateAc(state.value.acState.copy(mode = mode))
fun setAcFanSpeed(speed: AcFanSpeed) = updateAc(state.value.acState.copy(fanSpeed = speed))
fun toggleAcVerticalSwing() = updateAc(state.value.acState.copy(verticalSwing = !state.value.acState.verticalSwing))
fun toggleAcHorizontalSwing() = updateAc(state.value.acState.copy(horizontalSwing = !state.value.acState.horizontalSwing))
fun toggleAcEco() = updateAc(state.value.acState.copy(eco = !state.value.acState.eco))
private fun updateAc(newState: AcStateSnapshot) {
val currentProfile = profileId ?: return
val currentRemoteId = state.value.selectedRemoteId ?: return
localAcStateFlow.value = newState
acDebounceJob?.cancel()
acDebounceJob = viewModelScope.launch {
delay(300L)
when (val result = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) {
is DomainResult.Success -> messageFlow.value = HubUiMessage.Info(UiText.DynamicString("红外信号已发射,未确认设备实际状态"))
is DomainResult.Failure -> handleDomainError(result.error)
}
}
}
fun saveDeviceName(newName: String) {
val currentProfile = profileId ?: return
val trimmed = newName.trim()
@@ -181,6 +414,19 @@ class InfraredHubViewModel @Inject constructor(
messageFlow.value = null
}
private fun recordRemoteActivity(kind: ActivityEventKind, outcome: ActivityOutcome, errorCode: String? = null) {
val currentProfile = profileId ?: return
val remote = state.value.selectedRemote ?: return
activityWriter.record(
profileId = currentProfile,
kind = kind,
source = ActivitySource.USER,
outcome = outcome,
target = ActivityTarget(ActivityTargetType.REMOTE, remote.id.value, remote.displayName),
errorCode = errorCode,
)
}
private fun handleDomainError(error: DomainError) {
when (error) {
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid
@@ -16,8 +16,10 @@ 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.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -41,6 +43,16 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.KeyboardArrowDown
import androidx.compose.material.icons.outlined.KeyboardArrowLeft
import androidx.compose.material.icons.outlined.KeyboardArrowRight
import androidx.compose.material.icons.outlined.KeyboardArrowUp
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Remove
import androidx.compose.material.icons.outlined.VolumeOff
import androidx.compose.material.icons.outlined.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -51,7 +63,7 @@ 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.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -60,6 +72,9 @@ 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.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
import com.flagship.abox.manager.domain.infrared.AcMode
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
@@ -118,7 +133,7 @@ internal fun RemotePanelScreen(
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
var showAddKeyDialog by remember { mutableStateOf(false) }
@@ -128,7 +143,7 @@ internal fun RemotePanelScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -136,7 +151,7 @@ internal fun RemotePanelScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -175,17 +190,14 @@ internal fun RemotePanelScreen(
}
},
navigationIcon = {
TextButton(onClick = onBack) {
Text(stringResource(R.string.infrared_back))
}
ABoxIconButton(ABoxIconKey.BACK, "返回", onBack)
},
actions = {
// 非空调面板显示学习模式开关
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
if (state.remote?.type == com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) {
FilterChip(
selected = state.isLearnMode,
onClick = onToggleLearnMode,
label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") },
label = { Text(if (state.isLearnMode) "录制中" else "学习模式") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFFFF5252),
selectedLabelColor = Color.White,
@@ -222,7 +234,7 @@ internal fun RemotePanelScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text("🔴", fontSize = 16.sp)
ABoxIcon(ABoxIconKey.PENDING, "录制中", modifier = Modifier.size(20.dp))
Text(
text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码",
color = Color(0xFFC62828),
@@ -233,52 +245,20 @@ internal fun RemotePanelScreen(
}
}
// 面板内容区
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 },
)
}
}
}
UnifiedRemoteControlSurface(
state = state,
onButtonClick = onButtonClick,
onAddCustomButton = onAddCustomButton,
onDeleteButton = onDeleteButton,
onToggleAcPower = onToggleAcPower,
onAdjustAcTemp = onAdjustAcTemp,
onSetAcMode = onSetAcMode,
onSetFanSpeed = onSetFanSpeed,
onToggleAcVerticalSwing = onToggleAcVerticalSwing,
onToggleAcHorizontalSwing = onToggleAcHorizontalSwing,
onToggleAcEco = onToggleAcEco,
modifier = Modifier.fillMaxSize().weight(1f),
)
}
}
}
@@ -309,12 +289,13 @@ private fun AcControlPanel(
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag("ac_temperature_stepper"),
colors = CardDefaults.cardColors(
containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
),
@@ -332,7 +313,7 @@ private fun AcControlPanel(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = if (acState.power) "● 运行中" else "○ 已关机",
text = if (acState.power) "运行中" else "已关机",
color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Bold,
)
@@ -344,25 +325,13 @@ private fun AcControlPanel(
.background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline)
.testTag("ac_power_toggle"),
) {
Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Icon(Icons.Outlined.PowerSettingsNew, contentDescription = "电源", tint = Color.White)
}
}
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),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
Button(
@@ -370,31 +339,46 @@ private fun AcControlPanel(
modifier = Modifier.size(56.dp).testTag("ac_temp_down"),
shape = CircleShape,
) {
Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Icon(Icons.Outlined.Remove, contentDescription = "降低温度")
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${acState.temperature}°",
fontSize = 56.sp,
fontWeight = FontWeight.Black,
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
)
Text("设定温度", style = MaterialTheme.typography.labelMedium)
}
Button(
onClick = { onAdjustTemp(1) },
modifier = Modifier.size(56.dp).testTag("ac_temp_up"),
shape = CircleShape,
) {
Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold)
Icon(Icons.Outlined.Add, contentDescription = "升高温度")
}
}
Text(
text = "本地选择:${acState.mode.name} · ${acState.fanSpeed.name}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
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),
)
Card(modifier = Modifier.fillMaxWidth().testTag("ac_mode_selector")) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("运行模式", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
AcMode.entries.forEach { mode ->
FilterChip(
selected = acState.mode == mode,
onClick = { onSetMode(mode) },
label = { Text(modeLabel(mode)) },
modifier = Modifier.weight(1f),
)
}
}
}
}
@@ -407,7 +391,7 @@ private fun AcControlPanel(
FilterChip(
selected = acState.fanSpeed == speed,
onClick = { onSetFanSpeed(speed) },
label = { Text(speed.name) },
label = { Text(fanSpeedLabel(speed)) },
modifier = Modifier.weight(1f),
)
}
@@ -420,19 +404,19 @@ private fun AcControlPanel(
FilterChip(
selected = acState.verticalSwing,
onClick = onToggleVSwing,
label = { Text("↕ 上下扫风") },
label = { Text("上下扫风") },
modifier = Modifier.weight(1f),
)
FilterChip(
selected = acState.horizontalSwing,
onClick = onToggleHSwing,
label = { Text("↔ 左右扫风") },
label = { Text("左右扫风") },
modifier = Modifier.weight(1f),
)
FilterChip(
selected = acState.eco,
onClick = onToggleEco,
label = { Text("🌱 节能 ECO") },
label = { Text("节能") },
modifier = Modifier.weight(1f),
)
}
@@ -452,6 +436,7 @@ private fun TvControlPanel(
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
@@ -462,9 +447,9 @@ private fun TvControlPanel(
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)
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))
@@ -472,7 +457,7 @@ private fun TvControlPanel(
// 中部五向方向导航盘
Card(
shape = CircleShape,
modifier = Modifier.size(220.dp),
modifier = Modifier.size(220.dp).testTag("tv_direction_pad"),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
@@ -481,7 +466,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_UP") },
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"),
) {
ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_UP", Icons.Outlined.KeyboardArrowUp, "向上", learningButtonId, sendingButtonId)
}
// 下
@@ -489,7 +474,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_DOWN") },
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
) {
ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_DOWN", Icons.Outlined.KeyboardArrowDown, "向下", learningButtonId, sendingButtonId)
}
// 左
@@ -497,7 +482,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_LEFT") },
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
) {
ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_LEFT", Icons.Outlined.KeyboardArrowLeft, "向左", learningButtonId, sendingButtonId)
}
// 右
@@ -505,7 +490,7 @@ private fun TvControlPanel(
onClick = { onButtonClick("TV_NAV_RIGHT") },
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
) {
ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId)
DirectionIndicator("TV_NAV_RIGHT", Icons.Outlined.KeyboardArrowRight, "向右", learningButtonId, sendingButtonId)
}
// OK 居中大按钮
@@ -524,14 +509,14 @@ private fun TvControlPanel(
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)
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(),
modifier = Modifier.fillMaxWidth().testTag("tv_volume_channel_rockers"),
horizontalArrangement = Arrangement.SpaceEvenly,
) {
Column(
@@ -539,11 +524,11 @@ private fun TvControlPanel(
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_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Add, contentDescription = "提高音量")
}
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId)
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Remove, contentDescription = "降低音量")
}
}
@@ -552,17 +537,118 @@ private fun TvControlPanel(
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_UP") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Add, contentDescription = "频道上调")
}
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId)
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.width(120.dp), shape = RoundedCornerShape(12.dp)) {
Icon(Icons.Outlined.Remove, contentDescription = "频道下调")
}
}
}
}
}
@Composable
fun UnifiedRemoteControlSurface(
state: RemotePanelUiState,
onButtonClick: (String) -> Unit,
onAddCustomButton: (String) -> Unit,
onDeleteButton: (String) -> Unit,
onToggleAcPower: () -> Unit,
onAdjustAcTemp: (Int) -> Unit,
onSetAcMode: (AcMode) -> Unit,
onSetFanSpeed: (AcFanSpeed) -> Unit,
onToggleAcVerticalSwing: () -> Unit,
onToggleAcHorizontalSwing: () -> Unit,
onToggleAcEco: () -> Unit,
modifier: Modifier = Modifier,
) {
var showAddKeyDialog by remember { mutableStateOf(false) }
val remote = state.remote
Box(modifier = modifier) {
if (remote == null) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else {
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 = {
showAddKeyDialog = false
onAddCustomButton(it)
},
)
}
}
@Composable
private fun DirectionIndicator(
buttonId: String,
icon: androidx.compose.ui.graphics.vector.ImageVector,
description: String,
learningButtonId: String?,
sendingButtonId: String?,
) {
when (buttonId) {
learningButtonId -> Text("录制中", fontSize = 11.sp, fontWeight = FontWeight.Bold)
sendingButtonId -> CircularProgressIndicator(modifier = Modifier.size(18.dp))
else -> Icon(icon, contentDescription = description, modifier = Modifier.size(30.dp))
}
}
private fun modeLabel(mode: AcMode): String = when (mode) {
AcMode.COOL -> "制冷"
AcMode.HEAT -> "制热"
AcMode.AUTO -> "自动"
AcMode.DRY -> "除湿"
AcMode.FAN -> "送风"
}
private fun fanSpeedLabel(speed: AcFanSpeed): String = when (speed) {
AcFanSpeed.AUTO -> "自动"
AcFanSpeed.LOW -> "低速"
AcFanSpeed.MEDIUM -> "中速"
AcFanSpeed.HIGH -> "高速"
}
@Composable
private fun MediaPlayerControlPanel(
bindings: List<RemoteKeyBinding>,
@@ -582,8 +668,8 @@ private fun MediaPlayerControlPanel(
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)
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))
@@ -641,13 +727,13 @@ private fun GenericGridControlPanel(
) {
Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) {
Text("➕ 添加按键")
Text("添加按键")
}
}
if (bindings.isEmpty()) {
Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text("暂无自定义按键,请使用上方添加操作新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyVerticalGrid(
@@ -696,9 +782,10 @@ private fun GridButtonCard(
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 -> "⚪ 待录制 (点击录码)"
isLearning -> "正在录制信号..."
binding.status == InfraredKeyStatus.READY -> "已就绪(槽位 ${binding.assignedSlot ?: "动态"})"
binding.status == InfraredKeyStatus.FAILED -> "学习失败(请重试)"
else -> "未学习(点击录码)"
}
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
@@ -711,7 +798,7 @@ private fun GridButtonCard(
onClick = onDelete,
modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"),
) {
Text("🗑️", fontSize = 15.sp)
Text("删除", fontSize = 12.sp)
}
}
}
@@ -755,7 +842,7 @@ private fun ButtonIndicatorText(
sendingButtonId: String?,
) {
if (learningButtonId == buttonId) {
Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
Text("录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
} else if (sendingButtonId == buttonId) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
} else {
@@ -24,6 +24,8 @@ 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.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
@@ -59,6 +61,7 @@ class RemotePanelViewModel @Inject constructor(
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
private var acDebounceJob: Job? = null
private var learningJob: Job? = null
private val remoteFlow = if (profileId == null || remoteId == null) {
flowOf(null)
@@ -126,8 +129,10 @@ class RemotePanelViewModel @Inject constructor(
)
fun toggleLearnMode() {
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
val newMode = !isLearnModeFlow.value
isLearnModeFlow.value = newMode
if (!newMode) cancelLearning()
if (newMode) {
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled))
}
@@ -162,12 +167,13 @@ class RemotePanelViewModel @Inject constructor(
}
fun learnButton(buttonId: String) {
if (state.value.remote?.type != com.flagship.abox.manager.domain.infrared.RemoteDeviceType.CUSTOM) return
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
if (state.value.learningButtonId != null) return
learningButtonIdFlow.value = buttonId
viewModelScope.launch {
learningJob = viewModelScope.launch {
try {
when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
is DomainResult.Success -> {
@@ -175,12 +181,39 @@ class RemotePanelViewModel @Inject constructor(
}
is DomainResult.Failure -> handleDomainError(res.error)
}
} catch (cancelled: kotlinx.coroutines.CancellationException) {
val binding = state.value.bindings.firstOrNull { it.buttonId == buttonId }
if (binding != null) {
withContext(NonCancellable) {
virtualRemoteRepository.updateKeyBinding(
binding.copy(
status = com.flagship.abox.manager.domain.infrared.InfraredKeyStatus.FAILED,
lastErrorCode = null,
lastErrorDetail = "学习已取消,需要重新学习",
),
)
}
}
throw cancelled
} finally {
learningButtonIdFlow.value = null
}
}
}
fun cancelLearning() {
learningJob?.cancel()
learningJob = null
learningButtonIdFlow.value = null
isLearnModeFlow.value = false
}
override fun onCleared() {
cancelLearning()
acDebounceJob?.cancel()
super.onCleared()
}
fun addCustomButton(displayName: String) {
val currentProfile = profileId ?: return
val currentRemoteId = remoteId ?: return
@@ -204,7 +204,8 @@ class InfraredRemoteViewModel @Inject constructor(
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))
InfraredKeyStatus.UNLEARNED, InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING ->
learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
null -> learningCoordinator.learn(
profileId = currentProfile,
deviceName = currentDevice,
@@ -26,24 +26,24 @@ data class RemoteButtonSpec(
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 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 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 -")
@@ -35,7 +35,7 @@ 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.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -45,6 +45,8 @@ 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.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.infrared.R
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
@@ -87,7 +89,7 @@ internal fun InfraredRemoteScreen(
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
LaunchedEffect(state.message) {
state.message?.let { msg ->
@@ -95,7 +97,7 @@ internal fun InfraredRemoteScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -103,7 +105,7 @@ internal fun InfraredRemoteScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -158,12 +160,12 @@ internal fun InfraredRemoteScreen(
}
// 按键管理 / 设置入口按钮
IconButton(
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = "打开红外设置",
onClick = onOpenSettings,
modifier = Modifier.testTag("remote_settings_button"),
) {
Text("⚙️", fontSize = 20.sp)
}
)
},
)
},
@@ -33,7 +33,7 @@ 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.LocalResources
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -76,7 +76,7 @@ internal fun InfraredControlScreen(
onMessageShown: () -> Unit,
) {
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val resources = LocalResources.current
val scrollState = rememberScrollState()
LaunchedEffect(state.message) {
@@ -85,7 +85,7 @@ internal fun InfraredControlScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -93,7 +93,7 @@ internal fun InfraredControlScreen(
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())
is UiText.StringResource -> resources.getString(t.resId, *t.args.toTypedArray())
}
snackbarHostState.showSnackbar(text)
onMessageShown()
@@ -40,6 +40,9 @@ class InfraredControlViewModel @Inject constructor(
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
private val routeDeviceName: DeviceName? = savedStateHandle.get<String>("deviceName")
?.takeIf { it.isNotBlank() }
?.let(::DeviceName)
private data class ServerState(
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
@@ -65,7 +68,12 @@ class InfraredControlViewModel @Inject constructor(
),
)
is DomainResult.Success -> {
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
val deviceName = routeDeviceName ?: deviceResult.value ?: return@flatMapLatest flowOf(
ServerState(
providerProfile = active,
configError = DomainError.InvalidRequest(detail = "No infrared device configured"),
),
)
combine(
virtualRemoteRepository.observeRemotes(profileId, deviceName),
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
@@ -35,12 +35,12 @@
<string name="infrared_remote_title">红外遥控器</string>
<string name="infrared_remote_recording">录制中</string>
<string name="infrared_remote_learning_mode">学习模式</string>
<string name="infrared_remote_learning_banner">🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号</string>
<string name="infrared_remote_scene_ac">❄️ 空调面板</string>
<string name="infrared_remote_scene_tv">📺 电视面板</string>
<string name="infrared_remote_learning_banner">学习模式已开启:点击任意按键开始录制实体遥控器信号</string>
<string name="infrared_remote_scene_ac">空调面板</string>
<string name="infrared_remote_scene_tv">电视面板</string>
<string name="infrared_remote_settings">设置</string>
<string name="infrared_msg_learning_mode_enabled">🔴 学习模式已开启:点击任意按键开始录制</string>
<string name="infrared_msg_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>
@@ -51,8 +51,8 @@
<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_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>
@@ -73,9 +73,9 @@
<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_key_tv_menu">电视-菜单</string>
<string name="infrared_key_tv_home">主页</string>
<string name="infrared_key_tv_back">返回</string>
<string name="infrared_key_tv_menu">菜单</string>
<string name="infrared_control_title">红外设备与环境配置</string>
<string name="infrared_device_config_section">ABSDK 设备名称配置</string>
<string name="infrared_device_config_desc">ABSDK 依靠此设备名称(默认为 红外控制器)寻址具体的硬件转发器,请确保名称与主机端保持一致。</string>
@@ -98,10 +98,10 @@
<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_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_mode_static">静态独占(%1$s 槽位)</string>
<string name="infrared_hub_mode_dynamic">动态缓冲(共用槽位 %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>
@@ -110,8 +110,83 @@
<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_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>
<string name="infrared_hub_device_type_custom">自定义面板</string>
<string name="infrared_set_temperature">设定温度</string>
<string name="infrared_running_mode">运行模式</string>
<string name="infrared_fan_speed">风速档位</string>
<string name="infrared_cooling">制冷</string>
<string name="infrared_heating">制热</string>
<string name="infrared_auto">自动</string>
<string name="infrared_drying">除湿</string>
<string name="infrared_fan">送风</string>
<string name="infrared_low">低速</string>
<string name="infrared_medium">中速</string>
<string name="infrared_high">高速</string>
<string name="infrared_vertical_swing">上下扫风</string>
<string name="infrared_horizontal_swing">左右扫风</string>
<string name="infrared_eco">节能</string>
<string name="infrared_transmission_notice">状态变更会生成完整红外帧并发送;信号发射不代表设备状态已确认。</string>
<string name="infrared_up">向上</string>
<string name="infrared_down">向下</string>
<string name="infrared_left">向左</string>
<string name="infrared_right">向右</string>
<string name="infrared_volume">音量</string>
<string name="infrared_channel">频道</string>
<string name="infrared_raise_volume">提高音量</string>
<string name="infrared_lower_volume">降低音量</string>
<string name="infrared_raise_channel">频道上调</string>
<string name="infrared_lower_channel">频道下调</string>
<string name="infrared_media_panel">媒体控制面板</string>
<string name="infrared_remote_keys">%1$s 个按键</string>
<string name="infrared_no_buttons_manage">暂无按键,请在管理面板中添加。</string>
<string name="infrared_add_button">添加按键</string>
<string name="infrared_add_button_title">添加自定义按键</string>
<string name="infrared_add_button_description">先设置按键名称,录制红外信号时再分配硬件槽位。</string>
<string name="infrared_button_name">按键名称</string>
<string name="infrared_button_available">可用</string>
<string name="infrared_button_learning">正在学习</string>
<string name="infrared_button_sending">正在发送</string>
<string name="infrared_button_ready">可发送</string>
<string name="infrared_button_failed">学习失败</string>
<string name="infrared_button_unlearned">待学习</string>
<string name="infrared_delete_button">删除按键</string>
<string name="infrared_center_subtitle">红外控制器 · 遥控中心</string>
<string name="infrared_settings_subtitle">红外设备设置</string>
<string name="infrared_manage_description">遥控器、槽位与网关设备名称</string>
<string name="infrared_open_management">打开管理</string>
<string name="infrared_no_remotes_hint">使用管理操作添加遥控器</string>
<string name="infrared_manage_action">管理红外设备与遥控器</string>
<string name="infrared_custom">自定义</string>
<string name="infrared_static_slots">静态槽位</string>
<string name="infrared_dynamic_codes">动态写码</string>
<string name="infrared_layout_tv">电视导航面板</string>
<string name="infrared_layout_ac">空调状态面板</string>
<string name="infrared_layout_media">媒体控制面板</string>
<string name="infrared_layout_grid">自定义按键面板</string>
<string name="infrared_learn_mode">学习模式</string>
<string name="infrared_recording">录制中</string>
<string name="infrared_hardware_summary">红外控制器 · %1$d 个虚拟遥控器</string>
<string name="infrared_slot_summary">静态槽位 %1$d 个 · 动态缓冲槽位 100 · 剩余 %2$d 个</string>
<string name="infrared_remote_name">遥控器名称</string>
<string name="infrared_layout_tv_desc">电视导航布局,包含方向、音量和频道控制</string>
<string name="infrared_layout_media_desc">媒体播放器布局,包含播放控制</string>
<string name="infrared_layout_grid_desc">自由网格布局,可增删按键</string>
<string name="infrared_mode_static_title">静态独占槽位模式</string>
<string name="infrared_mode_dynamic_title">动态写码模式</string>
<string name="infrared_hardware_slots_title">硬件槽位</string>
<string name="infrared_remote_count">%1$d 个遥控器</string>
<string name="infrared_add_remote">添加遥控器</string>
<string name="infrared_move_remote">调整遥控器顺序</string>
<string name="infrared_move_up">上移</string>
<string name="infrared_move_down">下移</string>
<string name="infrared_edit_remote">编辑遥控器</string>
<string name="infrared_gateway_name_title">网关设备名称</string>
<string name="infrared_gateway_name_desc">用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。</string>
<string name="infrared_save_name">保存设备名称</string>
<string name="infrared_layout">面板布局</string>
<string name="infrared_transmission_mode">传输模式</string>
<string name="infrared_delete_remote">删除遥控器</string>
</resources>
@@ -36,12 +36,12 @@
<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_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_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>
@@ -52,8 +52,8 @@
<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_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>
@@ -100,8 +100,8 @@
<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_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>
@@ -110,8 +110,83 @@
<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_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>
<string name="infrared_hub_device_type_custom">Custom Panel</string>
<string name="infrared_set_temperature">Set temperature</string>
<string name="infrared_running_mode">Operating mode</string>
<string name="infrared_fan_speed">Fan speed</string>
<string name="infrared_cooling">Cool</string>
<string name="infrared_heating">Heat</string>
<string name="infrared_auto">Auto</string>
<string name="infrared_drying">Dry</string>
<string name="infrared_fan">Fan</string>
<string name="infrared_low">Low</string>
<string name="infrared_medium">Medium</string>
<string name="infrared_high">High</string>
<string name="infrared_vertical_swing">Vertical swing</string>
<string name="infrared_horizontal_swing">Horizontal swing</string>
<string name="infrared_eco">Eco</string>
<string name="infrared_transmission_notice">State changes generate a complete infrared frame and send it. Transmission does not confirm the appliance state.</string>
<string name="infrared_up">Up</string>
<string name="infrared_down">Down</string>
<string name="infrared_left">Left</string>
<string name="infrared_right">Right</string>
<string name="infrared_volume">Volume</string>
<string name="infrared_channel">Channel</string>
<string name="infrared_raise_volume">Raise volume</string>
<string name="infrared_lower_volume">Lower volume</string>
<string name="infrared_raise_channel">Raise channel</string>
<string name="infrared_lower_channel">Lower channel</string>
<string name="infrared_media_panel">Media control panel</string>
<string name="infrared_remote_keys">%1$s buttons</string>
<string name="infrared_no_buttons_manage">No buttons. Add one in management.</string>
<string name="infrared_add_button">Add button</string>
<string name="infrared_add_button_title">Add custom button</string>
<string name="infrared_add_button_description">Name the button now. Its hardware slot will be assigned when you record the infrared signal.</string>
<string name="infrared_button_name">Button name</string>
<string name="infrared_button_available">Available</string>
<string name="infrared_button_learning">Learning</string>
<string name="infrared_button_sending">Sending</string>
<string name="infrared_button_ready">Ready to send</string>
<string name="infrared_button_failed">Learning failed</string>
<string name="infrared_button_unlearned">Not learned</string>
<string name="infrared_delete_button">Delete button</string>
<string name="infrared_center_subtitle">Infrared controller · Remote center</string>
<string name="infrared_settings_subtitle">Infrared device settings</string>
<string name="infrared_manage_description">Remotes, slots, and gateway device name</string>
<string name="infrared_open_management">Open management</string>
<string name="infrared_no_remotes_hint">Use the management action to add a remote</string>
<string name="infrared_manage_action">Manage infrared devices and remotes</string>
<string name="infrared_custom">Custom</string>
<string name="infrared_static_slots">Static slots</string>
<string name="infrared_dynamic_codes">Dynamic codes</string>
<string name="infrared_layout_tv">TV navigation panel</string>
<string name="infrared_layout_ac">Air conditioner panel</string>
<string name="infrared_layout_media">Media control panel</string>
<string name="infrared_layout_grid">Custom button panel</string>
<string name="infrared_learn_mode">Learn mode</string>
<string name="infrared_recording">Recording</string>
<string name="infrared_hardware_summary">Infrared controller · %1$d virtual remotes</string>
<string name="infrared_slot_summary">Static slots: %1$d · Dynamic buffer: slot 100 · Free: %2$d</string>
<string name="infrared_remote_name">Remote name</string>
<string name="infrared_layout_tv_desc">TV navigation layout with direction, volume, and channel controls</string>
<string name="infrared_layout_media_desc">Media player layout with playback controls</string>
<string name="infrared_layout_grid_desc">Custom grid layout with editable buttons</string>
<string name="infrared_mode_static_title">Static slot mode</string>
<string name="infrared_mode_dynamic_title">Dynamic code mode</string>
<string name="infrared_hardware_slots_title">Hardware slots</string>
<string name="infrared_remote_count">%1$d remotes</string>
<string name="infrared_add_remote">Add remote</string>
<string name="infrared_move_remote">Reorder remote</string>
<string name="infrared_move_up">Move up</string>
<string name="infrared_move_down">Move down</string>
<string name="infrared_edit_remote">Edit remote</string>
<string name="infrared_gateway_name_title">Gateway device name</string>
<string name="infrared_gateway_name_desc">Used to match the infrared controller in the gateway. It must exactly match the gateway name.</string>
<string name="infrared_save_name">Save device name</string>
<string name="infrared_layout">Panel layout</string>
<string name="infrared_transmission_mode">Transmission mode</string>
<string name="infrared_delete_remote">Delete remote</string>
</resources>
@@ -226,4 +226,29 @@ class RemotePanelViewModelTest {
job.cancel()
}
@Test
fun cancellingLearningModeClearsActiveLearningState() = 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.toggleLearnMode()
runCurrent()
vm.cancelLearning()
runCurrent()
assertEquals(false, vm.state.value.isLearnMode)
assertEquals(null, vm.state.value.learningButtonId)
job.cancel()
}
}