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:
@@ -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)
|
||||
}
|
||||
+83
@@ -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, "设备"),
|
||||
)
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+9
@@ -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
|
||||
}
|
||||
+33
@@ -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,
|
||||
)
|
||||
+70
@@ -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,
|
||||
)
|
||||
+29
@@ -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"))
|
||||
}
|
||||
}
|
||||
+48
@@ -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)
|
||||
}
|
||||
+49
@@ -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" }
|
||||
}
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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()
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
+32
@@ -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" }
|
||||
}
|
||||
+15
@@ -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)
|
||||
}
|
||||
+94
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+90
@@ -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>
|
||||
+113
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+57
@@ -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()
|
||||
}
|
||||
}
|
||||
+66
@@ -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,
|
||||
)
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
+10
-35
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
|
||||
VirtualRemoteEntity::class,
|
||||
RemoteKeyBindingEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
version = 3,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class InfraredDatabase : RoomDatabase() {
|
||||
|
||||
+6
@@ -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?,
|
||||
|
||||
+20
@@ -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)",
|
||||
)
|
||||
}
|
||||
}
|
||||
+18
-1
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+414
@@ -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 -> "高速" }
|
||||
+423
-106
@@ -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,
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+248
-2
@@ -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
|
||||
|
||||
+208
-121
@@ -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 {
|
||||
|
||||
+34
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+13
-13
@@ -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 -")
|
||||
|
||||
+10
-8
@@ -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)
|
||||
}
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
|
||||
+4
-4
@@ -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()
|
||||
|
||||
+9
-1
@@ -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>
|
||||
|
||||
+25
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user