Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
525b97c7c5 | ||
|
|
363532e449 |
-65
@@ -17,12 +17,10 @@ import org.junit.Assert.assertEquals
|
|||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.reflect.KClass
|
|
||||||
|
|
||||||
class AbsdkDeviceBridgeTest {
|
class AbsdkDeviceBridgeTest {
|
||||||
private val profile = ProviderProfileId("abox")
|
private val profile = ProviderProfileId("abox")
|
||||||
private val socket = DeviceName("socket-1")
|
private val socket = DeviceName("socket-1")
|
||||||
private val irDevice = DeviceName("ir-room")
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun socketControlReturnsConfirmedStatus() = runTest {
|
fun socketControlReturnsConfirmedStatus() = runTest {
|
||||||
@@ -85,69 +83,6 @@ class AbsdkDeviceBridgeTest {
|
|||||||
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
|
assertTrue((offline as DomainResult.Failure).error is DomainError.DeviceOffline)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredSendForwardsDeviceAndStableKey() = runTest {
|
|
||||||
val client = ScriptedAbsdkClient()
|
|
||||||
val bridge = authenticatedBridge(client)
|
|
||||||
assertEquals(DomainResult.Success(Unit), bridge.sendKey(profile, irDevice, "app_ir_power"))
|
|
||||||
assertEquals(listOf("login:demo", "ir-send:ir-room:app_ir_power"), client.calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredLearnForwardsDeviceAndStableKey() = runTest {
|
|
||||||
val client = ScriptedAbsdkClient()
|
|
||||||
val bridge = authenticatedBridge(client)
|
|
||||||
assertEquals(DomainResult.Success(Unit), bridge.learnKey(profile, irDevice, "app_ir_power"))
|
|
||||||
assertEquals(listOf("login:demo", "ir-learn:ir-room:app_ir_power"), client.calls)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredErrorCodesAreMappedCorrectlyForSendAndLearn() = runTest {
|
|
||||||
val errorMappings = mapOf<String, KClass<out DomainError>>(
|
|
||||||
"20201" to DomainError.InvalidRequest::class,
|
|
||||||
"20202" to DomainError.InvalidRequest::class,
|
|
||||||
"20203" to DomainError.DeviceNotFound::class,
|
|
||||||
"20204" to DomainError.OperationFailed::class,
|
|
||||||
"20205" to DomainError.OperationFailed::class,
|
|
||||||
"20206" to DomainError.DeviceOffline::class,
|
|
||||||
)
|
|
||||||
|
|
||||||
for ((code, expectedClass) in errorMappings) {
|
|
||||||
val client = ScriptedAbsdkClient().apply {
|
|
||||||
sendResponse = ScriptedAbsdkClient.failure(code, "message for $code")
|
|
||||||
learnResponse = ScriptedAbsdkClient.failure(code, "message for $code")
|
|
||||||
}
|
|
||||||
val bridge = authenticatedBridge(client)
|
|
||||||
|
|
||||||
val sendResult = bridge.sendKey(profile, irDevice, "key1")
|
|
||||||
assertTrue("Expected send error class $expectedClass for code $code", sendResult is DomainResult.Failure)
|
|
||||||
assertEquals(expectedClass, (sendResult as DomainResult.Failure).error::class)
|
|
||||||
assertEquals(code, sendResult.error.externalCode)
|
|
||||||
|
|
||||||
val learnResult = bridge.learnKey(profile, irDevice, "key1")
|
|
||||||
assertTrue("Expected learn error class $expectedClass for code $code", learnResult is DomainResult.Failure)
|
|
||||||
assertEquals(expectedClass, (learnResult as DomainResult.Failure).error::class)
|
|
||||||
assertEquals(code, learnResult.error.externalCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredNullResponseReturnsUnknownDomainError() = runTest {
|
|
||||||
val client = ScriptedAbsdkClient().apply {
|
|
||||||
sendResponse = null
|
|
||||||
learnResponse = null
|
|
||||||
}
|
|
||||||
val bridge = authenticatedBridge(client)
|
|
||||||
|
|
||||||
val sendResult = bridge.sendKey(profile, irDevice, "key1")
|
|
||||||
assertTrue(sendResult is DomainResult.Failure)
|
|
||||||
assertTrue((sendResult as DomainResult.Failure).error is DomainError.Unknown)
|
|
||||||
|
|
||||||
val learnResult = bridge.learnKey(profile, irDevice, "key1")
|
|
||||||
assertTrue(learnResult is DomainResult.Failure)
|
|
||||||
assertTrue((learnResult as DomainResult.Failure).error is DomainError.Unknown)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
|
fun infraredCallsAndPartialDownloadResultsAreMapped() = runTest {
|
||||||
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
|
val codeOne = InfraredCode(DeviceName("ir-1"), "power", "code-1")
|
||||||
|
|||||||
-34
@@ -48,38 +48,4 @@ class AbsdkSerializationTest {
|
|||||||
assertEquals(1, client.maxConcurrentCalls.get())
|
assertEquals(1, client.maxConcurrentCalls.get())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun concurrentInfraredAndOtherDeviceCallsDoNotOverlap() = runBlocking {
|
|
||||||
withTimeout(5_000) {
|
|
||||||
val profile = ProviderProfileId("abox")
|
|
||||||
val client = ScriptedAbsdkClient().apply {
|
|
||||||
socketStatusResponses = ArrayDeque(
|
|
||||||
listOf(ScriptedAbsdkClient.success(mapOf("status" to "1"))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val bridge = AbsdkBridge(client, Dispatchers.IO)
|
|
||||||
bridge.login(profile, LoginCredentials(Username("demo"), Password("secret")))
|
|
||||||
|
|
||||||
client.onCall = {
|
|
||||||
if (client.calls.last().startsWith("ir-learn") || client.calls.last().startsWith("temperature")) {
|
|
||||||
Thread.sleep(75)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val irCall = async {
|
|
||||||
bridge.learnKey(profile, DeviceName("ir-1"), "app_ir_power")
|
|
||||||
}
|
|
||||||
val tempCall = async {
|
|
||||||
bridge.getReading(profile, DeviceName("th-1"))
|
|
||||||
}
|
|
||||||
|
|
||||||
delay(10)
|
|
||||||
|
|
||||||
irCall.await()
|
|
||||||
tempCall.await()
|
|
||||||
|
|
||||||
assertEquals(1, client.maxConcurrentCalls.get())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ dependencies {
|
|||||||
implementation(project(":domain"))
|
implementation(project(":domain"))
|
||||||
implementation(project(":android:core:designsystem"))
|
implementation(project(":android:core:designsystem"))
|
||||||
implementation(project(":android:core:session"))
|
implementation(project(":android:core:session"))
|
||||||
implementation(project(":android:feature:infrared"))
|
|
||||||
implementation(project(":absdk-bridge"))
|
implementation(project(":absdk-bridge"))
|
||||||
implementation(project(":runtime:simulation"))
|
implementation(project(":runtime:simulation"))
|
||||||
debugImplementation(project(":testing:fakes"))
|
debugImplementation(project(":testing:fakes"))
|
||||||
|
|||||||
@@ -11,20 +11,13 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavType
|
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
|
||||||
import com.flagship.abox.manager.domain.Username
|
|
||||||
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
|
|
||||||
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
|
|
||||||
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
|
|
||||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
||||||
import com.flagship.abox.manager.ui.login.LoginRoute
|
import com.flagship.abox.manager.ui.login.LoginRoute
|
||||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||||
@@ -37,6 +30,8 @@ import com.flagship.abox.manager.ui.session.AppSessionViewModel
|
|||||||
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
import com.flagship.abox.manager.ui.session.SessionActionsViewModel
|
||||||
import com.flagship.abox.manager.ui.shell.MainAppShell
|
import com.flagship.abox.manager.ui.shell.MainAppShell
|
||||||
|
|
||||||
|
private const val AUTHENTICATED_HOME_ROUTE = "authenticated/home"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ABoxManagerApp(
|
fun ABoxManagerApp(
|
||||||
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
appSessionViewModel: AppSessionViewModel = hiltViewModel(),
|
||||||
@@ -72,108 +67,47 @@ fun ABoxManagerApp(
|
|||||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||||
viewModel = loginViewModel,
|
viewModel = loginViewModel,
|
||||||
)
|
)
|
||||||
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
|
is AppSessionUiState.Authenticated -> {
|
||||||
username = state.username,
|
val navController = rememberNavController()
|
||||||
profile = state.profile,
|
NavHost(
|
||||||
actionInProgress = actionState.inProgress,
|
navController = navController,
|
||||||
errorMessage = actionState.errorMessage,
|
startDestination = AUTHENTICATED_HOME_ROUTE,
|
||||||
onLogout = sessionActionsViewModel::logout,
|
) {
|
||||||
)
|
composable(AUTHENTICATED_HOME_ROUTE) {
|
||||||
|
MainAppShell(
|
||||||
|
userName = state.username.value,
|
||||||
|
providerName = state.profile.displayName,
|
||||||
|
onLogout = sessionActionsViewModel::logout,
|
||||||
|
actionInProgress = actionState.inProgress,
|
||||||
|
errorMessage = actionState.errorMessage,
|
||||||
|
devicesSlot = {
|
||||||
|
DeviceListScreen(
|
||||||
|
onSocketClick = {
|
||||||
|
navController.navigateToSocket(
|
||||||
|
profileId = state.profile.id,
|
||||||
|
deviceName = DeviceName("Zigbee插座控制器"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onTemperatureHumidityClick = {
|
||||||
|
navController.navigateToTemperatureHumidity(
|
||||||
|
profileId = state.profile.id,
|
||||||
|
deviceName = DeviceName("温湿度传感器"),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
deviceDetailGraph(
|
||||||
|
onBackClick = navController::popBackStack,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AuthenticatedNavHost(
|
|
||||||
username: Username,
|
|
||||||
profile: ProviderProfile,
|
|
||||||
actionInProgress: Boolean,
|
|
||||||
errorMessage: String?,
|
|
||||||
onLogout: () -> Unit,
|
|
||||||
) {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = "home",
|
|
||||||
) {
|
|
||||||
composable("home") {
|
|
||||||
MainAppShell(
|
|
||||||
userName = username.value,
|
|
||||||
providerName = profile.displayName,
|
|
||||||
onLogout = onLogout,
|
|
||||||
actionInProgress = actionInProgress,
|
|
||||||
errorMessage = errorMessage,
|
|
||||||
devicesSlot = {
|
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
|
||||||
DeviceListScreen(
|
|
||||||
onSocketClick = {
|
|
||||||
navController.navigateToSocket(
|
|
||||||
profileId = profile.id,
|
|
||||||
deviceName = DeviceName("Zigbee插座控制器"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onTemperatureHumidityClick = {
|
|
||||||
navController.navigateToTemperatureHumidity(
|
|
||||||
profileId = profile.id,
|
|
||||||
deviceName = DeviceName("温湿度传感器"),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onInfraredClick = {
|
|
||||||
navController.navigate("infrared_hub/$encodedProfileId")
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
deviceDetailGraph(
|
|
||||||
onBackClick = navController::popBackStack,
|
|
||||||
)
|
|
||||||
composable(
|
|
||||||
route = "infrared_hub/{profileId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
val encodedProfileId = android.net.Uri.encode(profile.id.value)
|
|
||||||
InfraredHubRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onOpenRemote = { remoteId ->
|
|
||||||
val encodedRemoteId = android.net.Uri.encode(remoteId)
|
|
||||||
navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId")
|
|
||||||
},
|
|
||||||
onOpenSettings = {
|
|
||||||
navController.navigate("infrared_settings/$encodedProfileId")
|
|
||||||
},
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = "infrared_panel/{profileId}/{remoteId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
navArgument("remoteId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
RemotePanelRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
route = "infrared_settings/{profileId}",
|
|
||||||
arguments = listOf(
|
|
||||||
navArgument("profileId") { type = NavType.StringType },
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
InfraredControlRoute(
|
|
||||||
onBack = { navController.popBackStack() },
|
|
||||||
onSessionInvalid = onLogout,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun AppSessionUiState.screenKey(): String = when (this) {
|
private fun AppSessionUiState.screenKey(): String = when (this) {
|
||||||
AppSessionUiState.Loading -> "loading"
|
AppSessionUiState.Loading -> "loading"
|
||||||
AppSessionUiState.ProviderSelection -> "providers"
|
AppSessionUiState.ProviderSelection -> "providers"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<string name="action_switch_provider">切换提供方</string>
|
<string name="action_switch_provider">切换提供方</string>
|
||||||
<string name="authenticated_title">已连接到 %1$s</string>
|
<string name="authenticated_title">已连接到 %1$s</string>
|
||||||
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
|
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
|
||||||
|
|
||||||
<!-- 认证后壳层 -->
|
<!-- 认证后壳层 -->
|
||||||
<string name="tab_devices">设备</string>
|
<string name="tab_devices">设备</string>
|
||||||
<string name="tab_scenes">情景</string>
|
<string name="tab_scenes">情景</string>
|
||||||
@@ -51,8 +52,6 @@
|
|||||||
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
|
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
|
||||||
<string name="device_infrared_title">红外控制器</string>
|
<string name="device_infrared_title">红外控制器</string>
|
||||||
<string name="device_infrared_description">进入红外控制器相关功能。</string>
|
<string name="device_infrared_description">进入红外控制器相关功能。</string>
|
||||||
<string name="infrared_control_action">红外控制</string>
|
|
||||||
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
|
|
||||||
<!-- Socket strings -->
|
<!-- Socket strings -->
|
||||||
<string name="socket_title">智能插座</string>
|
<string name="socket_title">智能插座</string>
|
||||||
<string name="socket_status_label">电源状态</string>
|
<string name="socket_status_label">电源状态</string>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<string name="action_switch_provider">Switch provider</string>
|
<string name="action_switch_provider">Switch provider</string>
|
||||||
<string name="authenticated_title">Connected to %1$s</string>
|
<string name="authenticated_title">Connected to %1$s</string>
|
||||||
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
|
<string name="authenticated_description">The authenticated route is ready. Device navigation will be added by its module owner.</string>
|
||||||
|
|
||||||
<!-- Authenticated shell -->
|
<!-- Authenticated shell -->
|
||||||
<string name="tab_devices">Devices</string>
|
<string name="tab_devices">Devices</string>
|
||||||
<string name="tab_scenes">Scenes</string>
|
<string name="tab_scenes">Scenes</string>
|
||||||
@@ -51,8 +52,6 @@
|
|||||||
<string name="device_temperature_humidity_description">View temperature and humidity status.</string>
|
<string name="device_temperature_humidity_description">View temperature and humidity status.</string>
|
||||||
<string name="device_infrared_title">Infrared Controller</string>
|
<string name="device_infrared_title">Infrared Controller</string>
|
||||||
<string name="device_infrared_description">Access infrared controller features.</string>
|
<string name="device_infrared_description">Access infrared controller features.</string>
|
||||||
<string name="infrared_control_action">Infrared Control</string>
|
|
||||||
<string name="infrared_session_invalid_message">ABox session is not authenticated. Please log in first.</string>
|
|
||||||
<!-- Socket strings -->
|
<!-- Socket strings -->
|
||||||
<string name="socket_title">Smart Socket</string>
|
<string name="socket_title">Smart Socket</string>
|
||||||
<string name="socket_status_label">Power Status</string>
|
<string name="socket_status_label">Power Status</string>
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
plugins {
|
|
||||||
alias(libs.plugins.android.library)
|
|
||||||
alias(libs.plugins.kotlin.android)
|
|
||||||
alias(libs.plugins.kotlin.kapt)
|
|
||||||
alias(libs.plugins.hilt.android)
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "com.flagship.abox.manager.infrared"
|
|
||||||
compileSdk = 34
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
minSdk = 26
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
compose = true
|
|
||||||
}
|
|
||||||
|
|
||||||
composeOptions {
|
|
||||||
kotlinCompilerExtensionVersion = libs.versions.composeCompiler.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = "17"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
kapt {
|
|
||||||
correctErrorTypes = true
|
|
||||||
arguments {
|
|
||||||
arg("room.schemaLocation", "$projectDir/schemas")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(project(":domain"))
|
|
||||||
implementation(project(":android:core:designsystem"))
|
|
||||||
|
|
||||||
implementation(libs.room.runtime)
|
|
||||||
implementation(libs.room.ktx)
|
|
||||||
kapt(libs.room.compiler)
|
|
||||||
|
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
|
||||||
implementation(libs.androidx.navigation.compose)
|
|
||||||
implementation(libs.androidx.hilt.navigation.compose)
|
|
||||||
implementation(libs.hilt.android)
|
|
||||||
kapt(libs.hilt.compiler)
|
|
||||||
|
|
||||||
implementation(platform(libs.compose.bom))
|
|
||||||
implementation(libs.compose.ui)
|
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
|
||||||
implementation(libs.compose.material3)
|
|
||||||
|
|
||||||
testImplementation(libs.junit)
|
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
|
||||||
testImplementation(project(":testing:fakes"))
|
|
||||||
|
|
||||||
androidTestImplementation(platform(libs.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
|
||||||
androidTestImplementation(libs.espresso.core)
|
|
||||||
androidTestImplementation(libs.compose.ui.test.junit4)
|
|
||||||
androidTestImplementation(libs.room.testing)
|
|
||||||
androidTestImplementation(libs.kotlinx.coroutines.test)
|
|
||||||
|
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
|
||||||
debugImplementation(libs.compose.ui.test.manifest)
|
|
||||||
}
|
|
||||||
-136
@@ -1,136 +0,0 @@
|
|||||||
{
|
|
||||||
"formatVersion": 1,
|
|
||||||
"database": {
|
|
||||||
"version": 1,
|
|
||||||
"identityHash": "be673e12eb4c5275aaa6ef6fc189a861",
|
|
||||||
"entities": [
|
|
||||||
{
|
|
||||||
"tableName": "infrared_keys",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `sdkKey` TEXT NOT NULL, `status` TEXT NOT NULL, `lastErrorCode` TEXT, `lastErrorDetail` TEXT, `createdAtEpochMillis` INTEGER NOT NULL, `updatedAtEpochMillis` INTEGER NOT NULL, PRIMARY KEY(`id`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "id",
|
|
||||||
"columnName": "id",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "profileId",
|
|
||||||
"columnName": "profileId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "deviceName",
|
|
||||||
"columnName": "deviceName",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "displayName",
|
|
||||||
"columnName": "displayName",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "sdkKey",
|
|
||||||
"columnName": "sdkKey",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "status",
|
|
||||||
"columnName": "status",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastErrorCode",
|
|
||||||
"columnName": "lastErrorCode",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "lastErrorDetail",
|
|
||||||
"columnName": "lastErrorDetail",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "createdAtEpochMillis",
|
|
||||||
"columnName": "createdAtEpochMillis",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "updatedAtEpochMillis",
|
|
||||||
"columnName": "updatedAtEpochMillis",
|
|
||||||
"affinity": "INTEGER",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"id"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [
|
|
||||||
{
|
|
||||||
"name": "index_infrared_keys_profileId_deviceName",
|
|
||||||
"unique": false,
|
|
||||||
"columnNames": [
|
|
||||||
"profileId",
|
|
||||||
"deviceName"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName` ON `${TABLE_NAME}` (`profileId`, `deviceName`)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "index_infrared_keys_profileId_deviceName_sdkKey",
|
|
||||||
"unique": true,
|
|
||||||
"columnNames": [
|
|
||||||
"profileId",
|
|
||||||
"deviceName",
|
|
||||||
"sdkKey"
|
|
||||||
],
|
|
||||||
"orders": [],
|
|
||||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_infrared_keys_profileId_deviceName_sdkKey` ON `${TABLE_NAME}` (`profileId`, `deviceName`, `sdkKey`)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"foreignKeys": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"tableName": "infrared_device_configuration",
|
|
||||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`profileId` TEXT NOT NULL, `deviceName` TEXT NOT NULL, PRIMARY KEY(`profileId`))",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"fieldPath": "profileId",
|
|
||||||
"columnName": "profileId",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldPath": "deviceName",
|
|
||||||
"columnName": "deviceName",
|
|
||||||
"affinity": "TEXT",
|
|
||||||
"notNull": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"primaryKey": {
|
|
||||||
"autoGenerate": false,
|
|
||||||
"columnNames": [
|
|
||||||
"profileId"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"indices": [],
|
|
||||||
"foreignKeys": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"views": [],
|
|
||||||
"setupQueries": [
|
|
||||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
||||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'be673e12eb4c5275aaa6ef6fc189a861')"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-194
@@ -1,194 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.data.local
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.Room
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class RoomInfraredKeyRepositoryTest {
|
|
||||||
|
|
||||||
private lateinit var database: InfraredDatabase
|
|
||||||
private lateinit var repository: RoomInfraredKeyRepository
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
|
||||||
database = Room.inMemoryDatabaseBuilder(context, InfraredDatabase::class.java)
|
|
||||||
.allowMainThreadQueries()
|
|
||||||
.build()
|
|
||||||
repository = RoomInfraredKeyRepository(database.infraredDao())
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun teardown() {
|
|
||||||
database.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun observesOnlyRequestedProfileAndDevice() = runTest {
|
|
||||||
repository.insert(testKey(profile = "one", device = "ir-1", id = "a", sdkKey = "key_a"))
|
|
||||||
repository.insert(testKey(profile = "one", device = "ir-2", id = "b", sdkKey = "key_b"))
|
|
||||||
repository.insert(testKey(profile = "two", device = "ir-1", id = "c", sdkKey = "key_c"))
|
|
||||||
|
|
||||||
val result = repository.observeKeys(
|
|
||||||
ProviderProfileId("one"),
|
|
||||||
DeviceName("ir-1"),
|
|
||||||
).first() as DomainResult.Success
|
|
||||||
|
|
||||||
assertEquals(listOf(InfraredKeyId("a")), result.value.map { it.id })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsDuplicateSdkBinding() = runTest {
|
|
||||||
val first = repository.insert(testKey(id = "a", sdkKey = "app_ir_power"))
|
|
||||||
assertTrue(first is DomainResult.Success)
|
|
||||||
|
|
||||||
val duplicate = repository.insert(testKey(id = "b", sdkKey = "app_ir_power"))
|
|
||||||
assertTrue(duplicate is DomainResult.Failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun transitionsStatusCorrectly() = runTest {
|
|
||||||
val key = testKey(id = "a", status = InfraredKeyStatus.LEARNING)
|
|
||||||
repository.insert(key)
|
|
||||||
|
|
||||||
val transitionResult = repository.transitionStatus(
|
|
||||||
ref = InfraredKeyRef(key.profileId, key.id),
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = 2_000L,
|
|
||||||
)
|
|
||||||
assertTrue(transitionResult is DomainResult.Success)
|
|
||||||
|
|
||||||
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, fetched.status)
|
|
||||||
assertEquals(2_000L, fetched.updatedAtEpochMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renamesKeyWithoutChangingSdkKey() = runTest {
|
|
||||||
val key = testKey(id = "a", displayName = "Old Name", sdkKey = "app_ir_power")
|
|
||||||
repository.insert(key)
|
|
||||||
|
|
||||||
val renameResult = repository.rename(
|
|
||||||
ref = InfraredKeyRef(key.profileId, key.id),
|
|
||||||
displayName = "New Name",
|
|
||||||
updatedAtEpochMillis = 3_000L,
|
|
||||||
)
|
|
||||||
assertTrue(renameResult is DomainResult.Success)
|
|
||||||
|
|
||||||
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
|
|
||||||
assertEquals("New Name", fetched.displayName)
|
|
||||||
assertEquals("app_ir_power", fetched.sdkKey)
|
|
||||||
assertEquals(3_000L, fetched.updatedAtEpochMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteRejectsLearningKeyAndRemovesReadyKey() = runTest {
|
|
||||||
val learningKey = testKey(id = "learning", status = InfraredKeyStatus.LEARNING)
|
|
||||||
val readyKey = testKey(id = "ready", sdkKey = "ready_sdk_key", status = InfraredKeyStatus.READY)
|
|
||||||
repository.insert(learningKey)
|
|
||||||
repository.insert(readyKey)
|
|
||||||
|
|
||||||
val deleteLearning = repository.deleteIfNotLearning(InfraredKeyRef(learningKey.profileId, learningKey.id))
|
|
||||||
assertTrue(deleteLearning is DomainResult.Failure)
|
|
||||||
|
|
||||||
val deleteReady = repository.deleteIfNotLearning(InfraredKeyRef(readyKey.profileId, readyKey.id))
|
|
||||||
assertTrue(deleteReady is DomainResult.Success)
|
|
||||||
|
|
||||||
val fetchedReady = (repository.getKey(InfraredKeyRef(readyKey.profileId, readyKey.id)) as DomainResult.Success).value
|
|
||||||
assertNull(fetchedReady)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failsInterruptedLearningKeys() = runTest {
|
|
||||||
repository.insert(testKey(id = "k1", sdkKey = "k1", status = InfraredKeyStatus.LEARNING))
|
|
||||||
repository.insert(testKey(id = "k2", sdkKey = "k2", status = InfraredKeyStatus.LEARNING))
|
|
||||||
repository.insert(testKey(id = "k3", sdkKey = "k3", status = InfraredKeyStatus.READY))
|
|
||||||
|
|
||||||
val countResult = repository.failInterruptedLearning(ProviderProfileId("profile1"), 5_000L) as DomainResult.Success
|
|
||||||
assertEquals(2, countResult.value)
|
|
||||||
|
|
||||||
val k1 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, k1.status)
|
|
||||||
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, k1.lastErrorDetail)
|
|
||||||
assertNull(k1.lastErrorCode)
|
|
||||||
assertEquals(5_000L, k1.updatedAtEpochMillis)
|
|
||||||
|
|
||||||
val k3 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k3"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, k3.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
|
|
||||||
repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L))
|
|
||||||
repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L))
|
|
||||||
|
|
||||||
val countResult = repository.failInterruptedLearning(
|
|
||||||
profileId = ProviderProfileId("profile1"),
|
|
||||||
cutoffEpochMillis = 150L,
|
|
||||||
updatedAtEpochMillis = 150L,
|
|
||||||
) as DomainResult.Success
|
|
||||||
assertEquals(1, countResult.value)
|
|
||||||
|
|
||||||
val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, oldK.status)
|
|
||||||
|
|
||||||
val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, newK.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun persistsAndObservesDeviceName() = runTest {
|
|
||||||
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
|
|
||||||
assertNull(initial.value)
|
|
||||||
|
|
||||||
val setResult = repository.setDeviceName(ProviderProfileId("profile1"), DeviceName("ir_main"))
|
|
||||||
assertTrue(setResult is DomainResult.Success)
|
|
||||||
|
|
||||||
val observed = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
|
|
||||||
assertEquals(DeviceName("ir_main"), observed.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun testKey(
|
|
||||||
profile: String = "profile1",
|
|
||||||
device: String = "ir-1",
|
|
||||||
id: String = "key-1",
|
|
||||||
displayName: String = "Power",
|
|
||||||
sdkKey: String = "app_ir_key1",
|
|
||||||
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
|
|
||||||
): SavedInfraredKey {
|
|
||||||
return SavedInfraredKey(
|
|
||||||
id = InfraredKeyId(id),
|
|
||||||
profileId = ProviderProfileId(profile),
|
|
||||||
deviceName = DeviceName(device),
|
|
||||||
displayName = displayName,
|
|
||||||
sdkKey = sdkKey,
|
|
||||||
status = status,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 1_000L,
|
|
||||||
updatedAtEpochMillis = 1_000L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-49
@@ -1,49 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class InfraredRemoteScreenTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rendersAcPanelAndTvPanelCorrectly() {
|
|
||||||
val state = InfraredRemoteUiState(
|
|
||||||
selectedScene = RemoteScene.AIR_CONDITIONER,
|
|
||||||
savedDeviceName = DeviceName("ir-1"),
|
|
||||||
)
|
|
||||||
var clickedSpec: RemoteButtonSpec? = null
|
|
||||||
var settingsClicked = false
|
|
||||||
|
|
||||||
composeTestRule.setContent {
|
|
||||||
InfraredRemoteScreen(
|
|
||||||
state = state,
|
|
||||||
onBack = {},
|
|
||||||
onOpenSettings = { settingsClicked = true },
|
|
||||||
onSessionInvalid = {},
|
|
||||||
onSceneSelected = {},
|
|
||||||
onToggleLearningMode = {},
|
|
||||||
onButtonClick = { clickedSpec = it },
|
|
||||||
onMessageShown = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
|
|
||||||
assertEquals("AC_POWER", clickedSpec?.id)
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("remote_settings_button").assertIsDisplayed().performClick()
|
|
||||||
assertTrue(settingsClicked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-77
@@ -1,77 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.ui
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.test.performTextInput
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class InfraredControlScreenTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rendersControlScreenAndTriggersKeyOperations() {
|
|
||||||
val testKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = ProviderProfileId("profile1"),
|
|
||||||
deviceName = DeviceName("ir-1"),
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
val state = InfraredControlUiState(
|
|
||||||
profileId = ProviderProfileId("profile1"),
|
|
||||||
providerType = ProviderType.LOCAL_EXPERIENCE,
|
|
||||||
savedDeviceName = DeviceName("ir-1"),
|
|
||||||
keys = listOf(testKey),
|
|
||||||
loading = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
var sendClicked = false
|
|
||||||
var deleteClicked = false
|
|
||||||
|
|
||||||
composeTestRule.setContent {
|
|
||||||
InfraredControlScreen(
|
|
||||||
state = state,
|
|
||||||
onBack = {},
|
|
||||||
onSessionInvalid = {},
|
|
||||||
onDeviceNameChanged = {},
|
|
||||||
onSaveDeviceName = {},
|
|
||||||
onLearn = { _, _ -> },
|
|
||||||
onRetry = {},
|
|
||||||
onSend = { sendClicked = true },
|
|
||||||
onRename = { _, _ -> },
|
|
||||||
onDelete = { deleteClicked = true },
|
|
||||||
onMessageShown = {},
|
|
||||||
nextSlot = 2,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed()
|
|
||||||
composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick()
|
|
||||||
assertTrue(sendClicked)
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick()
|
|
||||||
composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick()
|
|
||||||
assertTrue(deleteClicked)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
</manifest>
|
|
||||||
-72
@@ -1,72 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.application
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Deferred
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
|
|
||||||
class InfraredLearningCoordinator(
|
|
||||||
private val learnInfraredKey: LearnInfraredKey,
|
|
||||||
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
|
|
||||||
private val relearnInfraredKey: RelearnInfraredKey,
|
|
||||||
private val keyRepository: InfraredKeyRepository,
|
|
||||||
private val externalScope: CoroutineScope,
|
|
||||||
) {
|
|
||||||
private val mutex = Mutex()
|
|
||||||
private var cleanupJob: Job? = null
|
|
||||||
|
|
||||||
fun learn(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
displayName: String,
|
|
||||||
sdkKey: String? = null,
|
|
||||||
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
|
|
||||||
mutex.withLock {
|
|
||||||
cleanupJob?.join()
|
|
||||||
}
|
|
||||||
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
|
||||||
mutex.withLock {
|
|
||||||
cleanupJob?.join()
|
|
||||||
}
|
|
||||||
retryInfraredKeyLearning(ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
|
||||||
mutex.withLock {
|
|
||||||
cleanupJob?.join()
|
|
||||||
}
|
|
||||||
relearnInfraredKey(ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun failInterruptedLearning(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
cutoffEpochMillis: Long = System.currentTimeMillis(),
|
|
||||||
updatedAtEpochMillis: Long = cutoffEpochMillis,
|
|
||||||
): Job {
|
|
||||||
val job = externalScope.launch(NonCancellable) {
|
|
||||||
keyRepository.failInterruptedLearning(
|
|
||||||
profileId = profileId,
|
|
||||||
cutoffEpochMillis = cutoffEpochMillis,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
cleanupJob = job
|
|
||||||
return job
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-222
@@ -1,222 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.data.local
|
|
||||||
|
|
||||||
import androidx.room.Dao
|
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
@Dao
|
|
||||||
interface InfraredDao {
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM infrared_keys
|
|
||||||
WHERE profileId = :profileId AND deviceName = :deviceName
|
|
||||||
ORDER BY createdAtEpochMillis ASC
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
fun observeKeys(profileId: String, deviceName: String): Flow<List<InfraredKeyEntity>>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM infrared_keys
|
|
||||||
WHERE profileId = :profileId AND id = :id
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun getKey(profileId: String, id: String): InfraredKeyEntity?
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
|
||||||
suspend fun insertKey(entity: InfraredKeyEntity)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
UPDATE infrared_keys
|
|
||||||
SET status = :targetStatus,
|
|
||||||
lastErrorCode = :lastErrorCode,
|
|
||||||
lastErrorDetail = :lastErrorDetail,
|
|
||||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
|
||||||
WHERE profileId = :profileId
|
|
||||||
AND id = :id
|
|
||||||
AND status IN (:expectedStatuses)
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun transitionStatus(
|
|
||||||
profileId: String,
|
|
||||||
id: String,
|
|
||||||
expectedStatuses: List<String>,
|
|
||||||
targetStatus: String,
|
|
||||||
lastErrorCode: String?,
|
|
||||||
lastErrorDetail: String?,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
UPDATE infrared_keys
|
|
||||||
SET displayName = :displayName,
|
|
||||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
|
||||||
WHERE profileId = :profileId AND id = :id
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun renameKey(
|
|
||||||
profileId: String,
|
|
||||||
id: String,
|
|
||||||
displayName: String,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
DELETE FROM infrared_keys
|
|
||||||
WHERE profileId = :profileId AND id = :id AND status != 'LEARNING'
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun deleteKeyIfNotLearning(profileId: String, id: String): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
UPDATE infrared_keys
|
|
||||||
SET status = :targetStatus,
|
|
||||||
lastErrorCode = NULL,
|
|
||||||
lastErrorDetail = :errorDetail,
|
|
||||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
|
||||||
WHERE profileId = :profileId
|
|
||||||
AND status = 'LEARNING'
|
|
||||||
AND createdAtEpochMillis <= :cutoffEpochMillis
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun failInterruptedLearning(
|
|
||||||
profileId: String,
|
|
||||||
targetStatus: String,
|
|
||||||
errorDetail: String,
|
|
||||||
cutoffEpochMillis: Long,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM infrared_device_configuration
|
|
||||||
WHERE profileId = :profileId
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
fun observeDeviceName(profileId: String): Flow<InfraredDeviceConfigurationEntity?>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity)
|
|
||||||
|
|
||||||
// Virtual Remotes
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM virtual_remotes
|
|
||||||
WHERE profileId = :profileId AND deviceName = :deviceName
|
|
||||||
ORDER BY createdAtEpochMillis DESC
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
fun observeVirtualRemotes(profileId: String, deviceName: String): Flow<List<VirtualRemoteEntity>>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM virtual_remotes
|
|
||||||
WHERE profileId = :profileId AND id = :id
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
fun observeVirtualRemote(profileId: String, id: String): Flow<VirtualRemoteEntity?>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM virtual_remotes
|
|
||||||
WHERE profileId = :profileId AND id = :id
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun getVirtualRemote(profileId: String, id: String): VirtualRemoteEntity?
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertVirtualRemote(entity: VirtualRemoteEntity)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
DELETE FROM virtual_remotes
|
|
||||||
WHERE profileId = :profileId AND id = :id
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun deleteVirtualRemote(profileId: String, id: String): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
UPDATE virtual_remotes
|
|
||||||
SET acPower = :power,
|
|
||||||
acMode = :mode,
|
|
||||||
acTemperature = :temperature,
|
|
||||||
acFanSpeed = :fanSpeed,
|
|
||||||
acVerticalSwing = :verticalSwing,
|
|
||||||
acHorizontalSwing = :horizontalSwing,
|
|
||||||
acEco = :eco,
|
|
||||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
|
||||||
WHERE profileId = :profileId AND id = :id
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun updateAcState(
|
|
||||||
profileId: String,
|
|
||||||
id: String,
|
|
||||||
power: Boolean?,
|
|
||||||
mode: String?,
|
|
||||||
temperature: Int?,
|
|
||||||
fanSpeed: String?,
|
|
||||||
verticalSwing: Boolean?,
|
|
||||||
horizontalSwing: Boolean?,
|
|
||||||
eco: Boolean?,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): Int
|
|
||||||
|
|
||||||
// Remote Key Bindings
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM remote_key_bindings
|
|
||||||
WHERE remoteId = :remoteId
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
fun observeKeyBindings(remoteId: String): Flow<List<RemoteKeyBindingEntity>>
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT * FROM remote_key_bindings
|
|
||||||
WHERE remoteId = :remoteId
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun getKeyBindings(remoteId: String): List<RemoteKeyBindingEntity>
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertKeyBindings(entities: List<RemoteKeyBindingEntity>)
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertKeyBinding(entity: RemoteKeyBindingEntity)
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
DELETE FROM remote_key_bindings
|
|
||||||
WHERE remoteId = :remoteId
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
DELETE FROM remote_key_bindings
|
|
||||||
WHERE remoteId = :remoteId AND buttonId = :buttonId
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
suspend fun deleteKeyBinding(remoteId: String, buttonId: String): Int
|
|
||||||
|
|
||||||
@Query(
|
|
||||||
"""
|
|
||||||
SELECT b.assignedSlot FROM remote_key_bindings b
|
|
||||||
INNER JOIN virtual_remotes r ON b.remoteId = r.id
|
|
||||||
WHERE r.profileId = :profileId AND r.deviceName = :deviceName AND b.assignedSlot IS NOT NULL
|
|
||||||
""",
|
|
||||||
)
|
|
||||||
fun observeAssignedStaticSlots(profileId: String, deviceName: String): Flow<List<String>>
|
|
||||||
}
|
|
||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.data.local
|
|
||||||
|
|
||||||
import androidx.room.Database
|
|
||||||
import androidx.room.RoomDatabase
|
|
||||||
|
|
||||||
@Database(
|
|
||||||
entities = [
|
|
||||||
InfraredKeyEntity::class,
|
|
||||||
InfraredDeviceConfigurationEntity::class,
|
|
||||||
VirtualRemoteEntity::class,
|
|
||||||
RemoteKeyBindingEntity::class,
|
|
||||||
],
|
|
||||||
version = 2,
|
|
||||||
exportSchema = false,
|
|
||||||
)
|
|
||||||
abstract class InfraredDatabase : RoomDatabase() {
|
|
||||||
abstract fun infraredDao(): InfraredDao
|
|
||||||
}
|
|
||||||
-83
@@ -1,83 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.data.local
|
|
||||||
|
|
||||||
import androidx.room.Entity
|
|
||||||
import androidx.room.Index
|
|
||||||
import androidx.room.PrimaryKey
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "infrared_keys",
|
|
||||||
indices = [
|
|
||||||
Index(value = ["profileId", "deviceName"]),
|
|
||||||
Index(value = ["profileId", "deviceName", "sdkKey"], unique = true),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
data class InfraredKeyEntity(
|
|
||||||
@PrimaryKey
|
|
||||||
val id: String,
|
|
||||||
val profileId: String,
|
|
||||||
val deviceName: String,
|
|
||||||
val displayName: String,
|
|
||||||
val sdkKey: String,
|
|
||||||
val status: String,
|
|
||||||
val lastErrorCode: String?,
|
|
||||||
val lastErrorDetail: String?,
|
|
||||||
val createdAtEpochMillis: Long,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(tableName = "infrared_device_configuration")
|
|
||||||
data class InfraredDeviceConfigurationEntity(
|
|
||||||
@PrimaryKey
|
|
||||||
val profileId: String,
|
|
||||||
val deviceName: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "virtual_remotes",
|
|
||||||
indices = [
|
|
||||||
Index(value = ["profileId", "deviceName"]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
data class VirtualRemoteEntity(
|
|
||||||
@PrimaryKey
|
|
||||||
val id: String,
|
|
||||||
val profileId: String,
|
|
||||||
val deviceName: String,
|
|
||||||
val displayName: String,
|
|
||||||
val type: String,
|
|
||||||
val brandModel: String?,
|
|
||||||
val layoutStyle: String?,
|
|
||||||
val transmissionMode: String,
|
|
||||||
val dynamicScratchSlot: String,
|
|
||||||
val acPower: Boolean?,
|
|
||||||
val acMode: String?,
|
|
||||||
val acTemperature: Int?,
|
|
||||||
val acFanSpeed: String?,
|
|
||||||
val acVerticalSwing: Boolean?,
|
|
||||||
val acHorizontalSwing: Boolean?,
|
|
||||||
val acEco: Boolean?,
|
|
||||||
val createdAtEpochMillis: Long,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Entity(
|
|
||||||
tableName = "remote_key_bindings",
|
|
||||||
indices = [
|
|
||||||
Index(value = ["remoteId"]),
|
|
||||||
Index(value = ["profileId"]),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
data class RemoteKeyBindingEntity(
|
|
||||||
@PrimaryKey
|
|
||||||
val id: String,
|
|
||||||
val remoteId: String,
|
|
||||||
val profileId: String,
|
|
||||||
val buttonId: String,
|
|
||||||
val displayName: String,
|
|
||||||
val assignedSlot: String?,
|
|
||||||
val rawHexCode: String?,
|
|
||||||
val status: String,
|
|
||||||
val lastErrorCode: String?,
|
|
||||||
val lastErrorDetail: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
-37
@@ -1,37 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.data.local
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
|
|
||||||
fun InfraredKeyEntity.toDomain(): SavedInfraredKey {
|
|
||||||
return SavedInfraredKey(
|
|
||||||
id = InfraredKeyId(id),
|
|
||||||
profileId = ProviderProfileId(profileId),
|
|
||||||
deviceName = DeviceName(deviceName),
|
|
||||||
displayName = displayName,
|
|
||||||
sdkKey = sdkKey,
|
|
||||||
status = InfraredKeyStatus.valueOf(status),
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
createdAtEpochMillis = createdAtEpochMillis,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun SavedInfraredKey.toEntity(): InfraredKeyEntity {
|
|
||||||
return InfraredKeyEntity(
|
|
||||||
id = id.value,
|
|
||||||
profileId = profileId.value,
|
|
||||||
deviceName = deviceName.value,
|
|
||||||
displayName = displayName,
|
|
||||||
sdkKey = sdkKey,
|
|
||||||
status = status.name,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
createdAtEpochMillis = createdAtEpochMillis,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-201
@@ -1,201 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.data.local
|
|
||||||
|
|
||||||
import android.database.sqlite.SQLiteConstraintException
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.catch
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
class RoomInfraredKeyRepository(
|
|
||||||
private val dao: InfraredDao,
|
|
||||||
) : InfraredKeyRepository, InfraredDeviceConfigurationRepository {
|
|
||||||
|
|
||||||
override fun observeKeys(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<SavedInfraredKey>>> {
|
|
||||||
return dao.observeKeys(profileId.value, deviceName.value)
|
|
||||||
.map<List<InfraredKeyEntity>, DomainResult<List<SavedInfraredKey>>> { entities ->
|
|
||||||
DomainResult.Success(entities.map { it.toDomain() })
|
|
||||||
}
|
|
||||||
.catch { throwable ->
|
|
||||||
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
|
||||||
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>> {
|
|
||||||
return dao.observeDeviceName(profileId.value)
|
|
||||||
.map<InfraredDeviceConfigurationEntity?, DomainResult<DeviceName?>> { entity ->
|
|
||||||
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
|
|
||||||
}
|
|
||||||
.catch { throwable ->
|
|
||||||
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
|
||||||
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
|
|
||||||
return try {
|
|
||||||
val entity = dao.getKey(ref.profileId.value, ref.keyId.value)
|
|
||||||
DomainResult.Success(entity?.toDomain())
|
|
||||||
} catch (throwable: Throwable) {
|
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
|
|
||||||
return try {
|
|
||||||
dao.insertKey(key.toEntity())
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} catch (throwable: Throwable) {
|
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun transitionStatus(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
expectedStatuses: Set<InfraredKeyStatus>,
|
|
||||||
targetStatus: InfraredKeyStatus,
|
|
||||||
lastErrorCode: String?,
|
|
||||||
lastErrorDetail: String?,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
return try {
|
|
||||||
val updated = dao.transitionStatus(
|
|
||||||
profileId = ref.profileId.value,
|
|
||||||
id = ref.keyId.value,
|
|
||||||
expectedStatuses = expectedStatuses.map { it.name },
|
|
||||||
targetStatus = targetStatus.name,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
if (updated > 0) {
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} else {
|
|
||||||
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
|
|
||||||
if (existing == null) {
|
|
||||||
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
|
|
||||||
} else {
|
|
||||||
DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(
|
|
||||||
detail = "Cannot transition key from ${existing.status} to ${targetStatus.name}",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (throwable: Throwable) {
|
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun rename(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
displayName: String,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val trimmed = displayName.trim()
|
|
||||||
if (trimmed.isBlank()) {
|
|
||||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Display name cannot be blank"))
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
val updated = dao.renameKey(
|
|
||||||
profileId = ref.profileId.value,
|
|
||||||
id = ref.keyId.value,
|
|
||||||
displayName = trimmed,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
if (updated > 0) {
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} else {
|
|
||||||
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
|
|
||||||
}
|
|
||||||
} catch (throwable: Throwable) {
|
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
|
|
||||||
return try {
|
|
||||||
val deleted = dao.deleteKeyIfNotLearning(ref.profileId.value, ref.keyId.value)
|
|
||||||
if (deleted > 0) {
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} else {
|
|
||||||
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
|
|
||||||
if (existing == null) {
|
|
||||||
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
|
|
||||||
} else {
|
|
||||||
DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Cannot delete key while learning is in progress"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (throwable: Throwable) {
|
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun failInterruptedLearning(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
cutoffEpochMillis: Long,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Int> {
|
|
||||||
return try {
|
|
||||||
val count = dao.failInterruptedLearning(
|
|
||||||
profileId = profileId.value,
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED.name,
|
|
||||||
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
|
||||||
cutoffEpochMillis = cutoffEpochMillis,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
DomainResult.Success(count)
|
|
||||||
} catch (throwable: Throwable) {
|
|
||||||
if (throwable is CancellationException) {
|
|
||||||
throw throwable
|
|
||||||
}
|
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun setDeviceName(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val trimmed = deviceName.value.trim()
|
|
||||||
if (trimmed.isBlank()) {
|
|
||||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name cannot be blank"))
|
|
||||||
}
|
|
||||||
return try {
|
|
||||||
dao.setDeviceName(
|
|
||||||
InfraredDeviceConfigurationEntity(
|
|
||||||
profileId = profileId.value,
|
|
||||||
deviceName = trimmed,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} catch (throwable: Throwable) {
|
|
||||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
|
|
||||||
if (throwable is kotlinx.coroutines.CancellationException) {
|
|
||||||
throw throwable
|
|
||||||
}
|
|
||||||
return when (throwable) {
|
|
||||||
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
|
|
||||||
else -> DomainError.Unknown(detail = "Database operation failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-229
@@ -1,229 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.data.local
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class RoomVirtualRemoteRepository @Inject constructor(
|
|
||||||
private val dao: InfraredDao,
|
|
||||||
) : VirtualRemoteRepository {
|
|
||||||
|
|
||||||
override fun observeRemotes(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<VirtualRemote>>> =
|
|
||||||
dao.observeVirtualRemotes(profileId.value, deviceName.value).map { entities ->
|
|
||||||
DomainResult.Success(entities.map { it.toDomain() })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<VirtualRemote?>> =
|
|
||||||
dao.observeVirtualRemote(profileId.value, remoteId.value).map { entity ->
|
|
||||||
DomainResult.Success(entity?.toDomain())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<List<RemoteKeyBinding>>> =
|
|
||||||
dao.observeKeyBindings(remoteId.value).map { entities ->
|
|
||||||
DomainResult.Success(entities.map { it.toDomain() })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeSlotQuota(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<SlotQuotaSummary>> =
|
|
||||||
dao.observeAssignedStaticSlots(profileId.value, deviceName.value).map { slots ->
|
|
||||||
val slotInts = slots.mapNotNull { it.toIntOrNull() }.toSet()
|
|
||||||
DomainResult.Success(
|
|
||||||
SlotQuotaSummary(
|
|
||||||
totalCapacity = 100,
|
|
||||||
dynamicScratchSlot = 100,
|
|
||||||
assignedStaticSlots = slotInts,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<VirtualRemote?> = try {
|
|
||||||
val entity = dao.getVirtualRemote(profileId.value, remoteId.value)
|
|
||||||
DomainResult.Success(entity?.toDomain())
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<List<RemoteKeyBinding>> = try {
|
|
||||||
val list = dao.getKeyBindings(remoteId.value)
|
|
||||||
DomainResult.Success(list.map { it.toDomain() })
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun createRemote(
|
|
||||||
remote: VirtualRemote,
|
|
||||||
initialBindings: List<RemoteKeyBinding>,
|
|
||||||
): DomainResult<VirtualRemoteId> = try {
|
|
||||||
dao.insertVirtualRemote(remote.toEntity())
|
|
||||||
dao.insertKeyBindings(initialBindings.map { it.toEntity() })
|
|
||||||
DomainResult.Success(remote.id)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> = try {
|
|
||||||
dao.insertVirtualRemote(remote.toEntity())
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<Unit> = try {
|
|
||||||
dao.deleteVirtualRemote(profileId.value, remoteId.value)
|
|
||||||
dao.deleteKeyBindingsByRemoteId(remoteId.value)
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> = try {
|
|
||||||
dao.insertKeyBinding(binding.toEntity())
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteKeyBinding(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
buttonId: String,
|
|
||||||
): DomainResult<Unit> = try {
|
|
||||||
dao.deleteKeyBinding(remoteId.value, buttonId)
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateAcState(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
state: AcStateSnapshot,
|
|
||||||
): DomainResult<Unit> = try {
|
|
||||||
dao.updateAcState(
|
|
||||||
profileId = profileId.value,
|
|
||||||
id = remoteId.value,
|
|
||||||
power = state.power,
|
|
||||||
mode = state.mode.name,
|
|
||||||
temperature = state.temperature,
|
|
||||||
fanSpeed = state.fanSpeed.name,
|
|
||||||
verticalSwing = state.verticalSwing,
|
|
||||||
horizontalSwing = state.horizontalSwing,
|
|
||||||
eco = state.eco,
|
|
||||||
updatedAtEpochMillis = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun VirtualRemoteEntity.toDomain(): VirtualRemote {
|
|
||||||
val ac = if (type == RemoteDeviceType.AIR_CONDITIONER.name) {
|
|
||||||
AcStateSnapshot(
|
|
||||||
power = acPower ?: true,
|
|
||||||
mode = acMode?.let { runCatching { AcMode.valueOf(it) }.getOrNull() } ?: AcMode.COOL,
|
|
||||||
temperature = acTemperature ?: 26,
|
|
||||||
fanSpeed = acFanSpeed?.let { runCatching { AcFanSpeed.valueOf(it) }.getOrNull() } ?: AcFanSpeed.AUTO,
|
|
||||||
verticalSwing = acVerticalSwing ?: true,
|
|
||||||
horizontalSwing = acHorizontalSwing ?: false,
|
|
||||||
eco = acEco ?: false,
|
|
||||||
)
|
|
||||||
} else null
|
|
||||||
|
|
||||||
val devType = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM)
|
|
||||||
val parsedLayout = layoutStyle?.let { runCatching { com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.valueOf(it) }.getOrNull() }
|
|
||||||
?: VirtualRemote.defaultLayoutFor(devType)
|
|
||||||
|
|
||||||
return VirtualRemote(
|
|
||||||
id = VirtualRemoteId(id),
|
|
||||||
profileId = ProviderProfileId(profileId),
|
|
||||||
deviceName = DeviceName(deviceName),
|
|
||||||
displayName = displayName,
|
|
||||||
type = devType,
|
|
||||||
brandModel = brandModel,
|
|
||||||
layoutStyle = parsedLayout,
|
|
||||||
transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH),
|
|
||||||
dynamicScratchSlot = dynamicScratchSlot,
|
|
||||||
acState = ac,
|
|
||||||
createdAtEpochMillis = createdAtEpochMillis,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity(
|
|
||||||
id = id.value,
|
|
||||||
profileId = profileId.value,
|
|
||||||
deviceName = deviceName.value,
|
|
||||||
displayName = displayName,
|
|
||||||
type = type.name,
|
|
||||||
brandModel = brandModel,
|
|
||||||
layoutStyle = layoutStyle.name,
|
|
||||||
transmissionMode = transmissionMode.name,
|
|
||||||
dynamicScratchSlot = dynamicScratchSlot,
|
|
||||||
acPower = acState?.power,
|
|
||||||
acMode = acState?.mode?.name,
|
|
||||||
acTemperature = acState?.temperature,
|
|
||||||
acFanSpeed = acState?.fanSpeed?.name,
|
|
||||||
acVerticalSwing = acState?.verticalSwing,
|
|
||||||
acHorizontalSwing = acState?.horizontalSwing,
|
|
||||||
acEco = acState?.eco,
|
|
||||||
createdAtEpochMillis = createdAtEpochMillis,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBinding(
|
|
||||||
id = id,
|
|
||||||
remoteId = VirtualRemoteId(remoteId),
|
|
||||||
profileId = ProviderProfileId(profileId),
|
|
||||||
buttonId = buttonId,
|
|
||||||
displayName = displayName,
|
|
||||||
assignedSlot = assignedSlot,
|
|
||||||
rawHexCode = rawHexCode,
|
|
||||||
status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY),
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity(
|
|
||||||
id = id,
|
|
||||||
remoteId = remoteId.value,
|
|
||||||
profileId = profileId.value,
|
|
||||||
buttonId = buttonId,
|
|
||||||
displayName = displayName,
|
|
||||||
assignedSlot = assignedSlot,
|
|
||||||
rawHexCode = rawHexCode,
|
|
||||||
status = status.name,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
)
|
|
||||||
-229
@@ -1,229 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.di
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.room.Room
|
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
|
||||||
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyIdGenerator
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
|
||||||
import com.flagship.abox.manager.infrared.data.local.InfraredDao
|
|
||||||
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
|
|
||||||
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Qualifier
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Qualifier
|
|
||||||
@Retention(AnnotationRetention.BINARY)
|
|
||||||
annotation class InfraredApplicationScope
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
object InfraredFeatureModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredDatabase(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
): InfraredDatabase {
|
|
||||||
return Room.databaseBuilder(
|
|
||||||
context,
|
|
||||||
InfraredDatabase::class.java,
|
|
||||||
"abox_infrared.db",
|
|
||||||
).fallbackToDestructiveMigration().build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredDao(database: InfraredDatabase): InfraredDao = database.infraredDao()
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideRoomInfraredKeyRepository(dao: InfraredDao): RoomInfraredKeyRepository =
|
|
||||||
RoomInfraredKeyRepository(dao)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredKeyRepository(
|
|
||||||
repository: RoomInfraredKeyRepository,
|
|
||||||
): InfraredKeyRepository = repository
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredDeviceConfigurationRepository(
|
|
||||||
repository: RoomInfraredKeyRepository,
|
|
||||||
): InfraredDeviceConfigurationRepository = repository
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
|
|
||||||
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
fun provideEpochMillisClock(): EpochMillisClock =
|
|
||||||
EpochMillisClock { System.currentTimeMillis() }
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredKeyFactory(
|
|
||||||
idGenerator: InfraredKeyIdGenerator,
|
|
||||||
clock: EpochMillisClock,
|
|
||||||
): InfraredKeyFactory = InfraredKeyFactory(idGenerator, clock)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
@InfraredApplicationScope
|
|
||||||
fun provideInfraredApplicationScope(): CoroutineScope =
|
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideLearnInfraredKey(
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
gateway: InfraredGateway,
|
|
||||||
factory: InfraredKeyFactory,
|
|
||||||
clock: EpochMillisClock,
|
|
||||||
): LearnInfraredKey = LearnInfraredKey(repository, gateway, factory, clock)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideRetryInfraredKeyLearning(
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
gateway: InfraredGateway,
|
|
||||||
clock: EpochMillisClock,
|
|
||||||
): RetryInfraredKeyLearning = RetryInfraredKeyLearning(repository, gateway, clock)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideSendInfraredKey(
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
gateway: InfraredGateway,
|
|
||||||
): SendInfraredKey = SendInfraredKey(repository, gateway)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideRenameInfraredKey(
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
clock: EpochMillisClock,
|
|
||||||
): RenameInfraredKey = RenameInfraredKey(repository, clock)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDeleteInfraredKey(
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
): DeleteInfraredKey = DeleteInfraredKey(repository)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideRecoverInterruptedInfraredLearning(
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
clock: EpochMillisClock,
|
|
||||||
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideRelearnInfraredKey(
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
gateway: InfraredGateway,
|
|
||||||
clock: EpochMillisClock,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey =
|
|
||||||
com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideInfraredLearningCoordinator(
|
|
||||||
learnInfraredKey: LearnInfraredKey,
|
|
||||||
retryInfraredKeyLearning: RetryInfraredKeyLearning,
|
|
||||||
relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey,
|
|
||||||
repository: InfraredKeyRepository,
|
|
||||||
@InfraredApplicationScope scope: CoroutineScope,
|
|
||||||
): InfraredLearningCoordinator = InfraredLearningCoordinator(
|
|
||||||
learnInfraredKey = learnInfraredKey,
|
|
||||||
retryInfraredKeyLearning = retryInfraredKeyLearning,
|
|
||||||
relearnInfraredKey = relearnInfraredKey,
|
|
||||||
keyRepository = repository,
|
|
||||||
externalScope = scope,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideRoomVirtualRemoteRepository(dao: InfraredDao): RoomVirtualRemoteRepository =
|
|
||||||
RoomVirtualRemoteRepository(dao)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideVirtualRemoteRepository(
|
|
||||||
repository: RoomVirtualRemoteRepository,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository = repository
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideCreateVirtualRemote(
|
|
||||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
|
||||||
gateway: InfraredGateway,
|
|
||||||
clock: EpochMillisClock,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.CreateVirtualRemote =
|
|
||||||
com.flagship.abox.manager.domain.infrared.CreateVirtualRemote(repository, gateway, clock)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideSendVirtualRemoteAction(
|
|
||||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
|
||||||
gateway: InfraredGateway,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction =
|
|
||||||
com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDeleteVirtualRemote(
|
|
||||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote =
|
|
||||||
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideLearnVirtualRemoteKey(
|
|
||||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
|
||||||
gateway: InfraredGateway,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey =
|
|
||||||
com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey(repository, gateway)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideAddVirtualRemoteKey(
|
|
||||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey =
|
|
||||||
com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey(repository)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideDeleteVirtualRemoteKey(
|
|
||||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey =
|
|
||||||
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey(repository)
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun provideUpdateVirtualRemoteLayout(
|
|
||||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
|
||||||
): com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout =
|
|
||||||
com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout(repository)
|
|
||||||
}
|
|
||||||
-593
@@ -1,593 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.hub
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
|
||||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.RadioButton
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
|
||||||
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InfraredHubRoute(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onOpenRemote: (String) -> Unit,
|
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
viewModel: InfraredHubViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
InfraredHubScreen(
|
|
||||||
state = state,
|
|
||||||
onBack = onBack,
|
|
||||||
onNavigateToSettings = onOpenSettings,
|
|
||||||
onOpenRemote = onOpenRemote,
|
|
||||||
onDeleteRemote = viewModel::deleteRemote,
|
|
||||||
onCreateRemote = viewModel::createRemote,
|
|
||||||
onSessionInvalid = onSessionInvalid,
|
|
||||||
onMessageShown = viewModel::clearMessage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
internal fun InfraredHubScreen(
|
|
||||||
state: InfraredHubUiState,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onNavigateToSettings: () -> Unit,
|
|
||||||
onOpenRemote: (String) -> Unit,
|
|
||||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
|
||||||
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
onMessageShown: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val context = LocalContext.current
|
|
||||||
var showCreateDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
|
||||||
state.message?.let { msg ->
|
|
||||||
when (msg) {
|
|
||||||
is HubUiMessage.Info -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is HubUiMessage.Error -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is HubUiMessage.SessionInvalid -> {
|
|
||||||
onSessionInvalid()
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_hub_title),
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
state.providerDisplayName?.let {
|
|
||||||
Text(
|
|
||||||
text = it,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
TextButton(onClick = onBack) {
|
|
||||||
Text(stringResource(R.string.infrared_back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
IconButton(
|
|
||||||
onClick = onNavigateToSettings,
|
|
||||||
modifier = Modifier.testTag("hub_settings_button"),
|
|
||||||
) {
|
|
||||||
Text("⚙️", fontSize = 20.sp)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
) { innerPadding ->
|
|
||||||
ABoxSurface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// 1. 硬件槽位配额看板 (100 槽位预算)
|
|
||||||
QuotaSummaryCard(quota = state.slotQuota)
|
|
||||||
|
|
||||||
// 2. 遥控器列表标题与新建按钮
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_hub_my_remotes),
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { showCreateDialog = true },
|
|
||||||
modifier = Modifier.testTag("hub_add_remote_button"),
|
|
||||||
) {
|
|
||||||
Text("➕ " + stringResource(R.string.infrared_hub_add_remote))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 遥控器网格卡片
|
|
||||||
if (state.remotes.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_hub_no_remotes),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Fixed(2),
|
|
||||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
items(state.remotes, key = { it.id.value }) { remote ->
|
|
||||||
VirtualRemoteCard(
|
|
||||||
remote = remote,
|
|
||||||
onClick = { onOpenRemote(remote.id.value) },
|
|
||||||
onDelete = { onDeleteRemote(remote.id) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showCreateDialog) {
|
|
||||||
CreateVirtualRemoteDialog(
|
|
||||||
onDismiss = { showCreateDialog = false },
|
|
||||||
onConfirm = { name, type, brandModel, transMode, layoutStyle ->
|
|
||||||
onCreateRemote(name, type, brandModel, transMode, layoutStyle)
|
|
||||||
showCreateDialog = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun VirtualRemoteCard(
|
|
||||||
remote: VirtualRemote,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.testTag("remote_card_${remote.id.value}"),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(14.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
val icon = when (remote.type) {
|
|
||||||
RemoteDeviceType.TV -> "📺"
|
|
||||||
RemoteDeviceType.AIR_CONDITIONER -> "❄️"
|
|
||||||
RemoteDeviceType.FAN -> "🌀"
|
|
||||||
RemoteDeviceType.CUSTOM -> "🎛️"
|
|
||||||
}
|
|
||||||
Text(icon, fontSize = 28.sp)
|
|
||||||
IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) {
|
|
||||||
Text("🗑️", fontSize = 16.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = remote.displayName,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
|
|
||||||
val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) {
|
|
||||||
"❄️ 状态机动态写码"
|
|
||||||
} else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
|
|
||||||
"🚀 静态独占槽位"
|
|
||||||
} else {
|
|
||||||
"📦 动态写码"
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${remote.layoutStyle.name} · $modeText",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "硬件槽位配额 (总计 100 槽位)",
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用",
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp)),
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个",
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
private fun CreateVirtualRemoteDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit,
|
|
||||||
) {
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) }
|
|
||||||
var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) }
|
|
||||||
var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) }
|
|
||||||
var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) }
|
|
||||||
|
|
||||||
var typeDropdownExpanded by remember { mutableStateOf(false) }
|
|
||||||
var brandDropdownExpanded by remember { mutableStateOf(false) }
|
|
||||||
var layoutDropdownExpanded by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }
|
|
||||||
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) },
|
|
||||||
text = {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
// 1. 设备类型选择
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = typeDropdownExpanded,
|
|
||||||
onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded },
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = when (selectedType) {
|
|
||||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
|
||||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
|
||||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
|
||||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
|
||||||
},
|
|
||||||
onValueChange = {},
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text(stringResource(R.string.infrared_hub_device_type_label)) },
|
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) },
|
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = typeDropdownExpanded,
|
|
||||||
onDismissRequest = { typeDropdownExpanded = false },
|
|
||||||
) {
|
|
||||||
RemoteDeviceType.entries.forEach { type ->
|
|
||||||
val label = when (type) {
|
|
||||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
|
||||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
|
||||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
|
||||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
|
||||||
}
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(label) },
|
|
||||||
onClick = {
|
|
||||||
selectedType = type
|
|
||||||
val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type }
|
|
||||||
if (matched != null) selectedBrand = matched
|
|
||||||
if (type == RemoteDeviceType.AIR_CONDITIONER) {
|
|
||||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH
|
|
||||||
} else if (type == RemoteDeviceType.CUSTOM) {
|
|
||||||
transmissionMode = TransmissionMode.STATIC_SLOTS
|
|
||||||
}
|
|
||||||
typeDropdownExpanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 遥控器名称输入
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("遥控器名称(如:客厅电视、我的遥控器)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 3. 自定义遥控器时:选择初始布局模板
|
|
||||||
if (selectedType == RemoteDeviceType.CUSTOM) {
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = layoutDropdownExpanded,
|
|
||||||
onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded },
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = when (selectedCustomLayout) {
|
|
||||||
PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)"
|
|
||||||
PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)"
|
|
||||||
PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)"
|
|
||||||
PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局"
|
|
||||||
},
|
|
||||||
onValueChange = {},
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text("选择初始布局模板") },
|
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) },
|
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = layoutDropdownExpanded,
|
|
||||||
onDismissRequest = { layoutDropdownExpanded = false },
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") },
|
|
||||||
onClick = {
|
|
||||||
selectedCustomLayout = PanelLayoutStyle.TV_NAV
|
|
||||||
layoutDropdownExpanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") },
|
|
||||||
onClick = {
|
|
||||||
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
|
|
||||||
layoutDropdownExpanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text("🎛️ 自由网格布局 (可自由增删按键)") },
|
|
||||||
onClick = {
|
|
||||||
selectedCustomLayout = PanelLayoutStyle.GRID
|
|
||||||
layoutDropdownExpanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
modifier = Modifier.padding(10.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (filteredPresets.isNotEmpty()) {
|
|
||||||
// 码库预设设备:选择品牌型号
|
|
||||||
ExposedDropdownMenuBox(
|
|
||||||
expanded = brandDropdownExpanded,
|
|
||||||
onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded },
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = selectedBrand.displayName,
|
|
||||||
onValueChange = {},
|
|
||||||
readOnly = true,
|
|
||||||
label = { Text(stringResource(R.string.infrared_hub_brand_label)) },
|
|
||||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) },
|
|
||||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
|
||||||
)
|
|
||||||
ExposedDropdownMenu(
|
|
||||||
expanded = brandDropdownExpanded,
|
|
||||||
onDismissRequest = { brandDropdownExpanded = false },
|
|
||||||
) {
|
|
||||||
filteredPresets.forEach { preset ->
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = { Text(preset.displayName) },
|
|
||||||
onClick = {
|
|
||||||
selectedBrand = preset
|
|
||||||
brandDropdownExpanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 传输模式选择(仅对非空调、非纯自定义设备可选)
|
|
||||||
if (selectedType != RemoteDeviceType.AIR_CONDITIONER && selectedType != RemoteDeviceType.CUSTOM) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
|
||||||
Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = transmissionMode == TransmissionMode.STATIC_SLOTS,
|
|
||||||
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium)
|
|
||||||
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
|
||||||
) {
|
|
||||||
RadioButton(
|
|
||||||
selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
|
||||||
)
|
|
||||||
Column {
|
|
||||||
Text("📦 动态写码模式", fontWeight = FontWeight.Medium)
|
|
||||||
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
modifier = Modifier.padding(10.dp),
|
|
||||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
val finalName = if (name.isNotBlank()) name.trim() else if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName
|
|
||||||
val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null
|
|
||||||
onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout)
|
|
||||||
},
|
|
||||||
modifier = Modifier.testTag("create_remote_confirm_button"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_dialog_confirm))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) {
|
|
||||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.hub
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
|
|
||||||
sealed interface HubUiMessage {
|
|
||||||
data class Info(val text: UiText) : HubUiMessage
|
|
||||||
data class Error(val text: UiText) : HubUiMessage
|
|
||||||
data object SessionInvalid : HubUiMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
data class InfraredHubUiState(
|
|
||||||
val profileId: ProviderProfileId? = null,
|
|
||||||
val providerType: ProviderType? = null,
|
|
||||||
val providerDisplayName: String? = null,
|
|
||||||
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
|
|
||||||
val remotes: List<VirtualRemote> = emptyList(),
|
|
||||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
|
||||||
val loading: Boolean = true,
|
|
||||||
val message: HubUiMessage? = null,
|
|
||||||
)
|
|
||||||
-200
@@ -1,200 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.hub
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
|
||||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class InfraredHubViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val providerProfileStore: ProviderProfileStore,
|
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
|
||||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
|
||||||
private val createVirtualRemote: CreateVirtualRemote,
|
|
||||||
private val deleteVirtualRemote: DeleteVirtualRemote,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
|
||||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
|
||||||
|
|
||||||
private val messageFlow = MutableStateFlow<HubUiMessage?>(null)
|
|
||||||
|
|
||||||
private data class HubSnapshot(
|
|
||||||
val providerProfile: ProviderProfile? = null,
|
|
||||||
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
|
||||||
val remotes: List<VirtualRemote> = emptyList(),
|
|
||||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
|
||||||
val configError: DomainError? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val hubSnapshotFlow: Flow<HubSnapshot> =
|
|
||||||
if (profileId == null) {
|
|
||||||
flowOf(HubSnapshot())
|
|
||||||
} else {
|
|
||||||
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
|
|
||||||
val active = profiles.find { it.id == profileId }
|
|
||||||
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { devResult ->
|
|
||||||
when (devResult) {
|
|
||||||
is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error))
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME
|
|
||||||
combine(
|
|
||||||
virtualRemoteRepository.observeRemotes(profileId, deviceName),
|
|
||||||
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
|
|
||||||
) { remotesResult, quotaResult ->
|
|
||||||
val remotes = if (remotesResult is DomainResult.Success) remotesResult.value else emptyList()
|
|
||||||
val quota = if (quotaResult is DomainResult.Success) quotaResult.value else SlotQuotaSummary()
|
|
||||||
HubSnapshot(
|
|
||||||
providerProfile = active,
|
|
||||||
savedDevice = deviceName,
|
|
||||||
remotes = remotes,
|
|
||||||
slotQuota = quota,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val state: StateFlow<InfraredHubUiState> = combine(
|
|
||||||
hubSnapshotFlow,
|
|
||||||
messageFlow,
|
|
||||||
) { snapshot, msg ->
|
|
||||||
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid
|
|
||||||
is DomainError.DeviceOffline -> HubUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
|
||||||
)
|
|
||||||
else -> HubUiMessage.Error(
|
|
||||||
error.detail?.let { UiText.DynamicString(it) }
|
|
||||||
?: UiText.StringResource(R.string.infrared_msg_load_failed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
InfraredHubUiState(
|
|
||||||
profileId = profileId,
|
|
||||||
providerType = snapshot.providerProfile?.type,
|
|
||||||
providerDisplayName = snapshot.providerProfile?.displayName,
|
|
||||||
savedDeviceName = snapshot.savedDevice,
|
|
||||||
remotes = snapshot.remotes,
|
|
||||||
slotQuota = snapshot.slotQuota,
|
|
||||||
loading = false,
|
|
||||||
message = resolvedMessage,
|
|
||||||
)
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
|
||||||
initialValue = InfraredHubUiState(profileId = profileId, loading = true),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun createRemote(
|
|
||||||
name: String,
|
|
||||||
type: RemoteDeviceType,
|
|
||||||
brandModel: String? = null,
|
|
||||||
transmissionMode: TransmissionMode,
|
|
||||||
layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle? = null,
|
|
||||||
) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentDevice = state.value.savedDeviceName
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val res = createVirtualRemote(
|
|
||||||
profileId = currentProfile,
|
|
||||||
deviceName = currentDevice,
|
|
||||||
displayName = name,
|
|
||||||
type = type,
|
|
||||||
brandModel = brandModel,
|
|
||||||
transmissionMode = transmissionMode,
|
|
||||||
layoutStyle = layoutStyle,
|
|
||||||
)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已成功创建遥控器"))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteRemote(remoteId: VirtualRemoteId) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val res = deleteVirtualRemote(currentProfile, remoteId)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器已删除"))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveDeviceName(newName: String) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val trimmed = newName.trim()
|
|
||||||
if (trimmed.isBlank()) {
|
|
||||||
messageFlow.value = HubUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val res = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = HubUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearMessage() {
|
|
||||||
messageFlow.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDomainError(error: DomainError) {
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid
|
|
||||||
is DomainError.DeviceOffline -> messageFlow.value = HubUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
|
||||||
)
|
|
||||||
else -> messageFlow.value = HubUiMessage.Error(
|
|
||||||
error.detail?.let { UiText.DynamicString(it) }
|
|
||||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-801
@@ -1,801 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.panel
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RemotePanelRoute(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
viewModel: RemotePanelViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
RemotePanelScreen(
|
|
||||||
state = state,
|
|
||||||
onBack = onBack,
|
|
||||||
onSessionInvalid = onSessionInvalid,
|
|
||||||
onButtonClick = viewModel::onButtonClick,
|
|
||||||
onToggleLearnMode = viewModel::toggleLearnMode,
|
|
||||||
onAddCustomButton = viewModel::addCustomButton,
|
|
||||||
onDeleteButton = viewModel::deleteButton,
|
|
||||||
onToggleAcPower = viewModel::toggleAcPower,
|
|
||||||
onAdjustAcTemp = viewModel::adjustAcTemperature,
|
|
||||||
onSetAcMode = viewModel::setAcMode,
|
|
||||||
onSetFanSpeed = viewModel::setAcFanSpeed,
|
|
||||||
onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing,
|
|
||||||
onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing,
|
|
||||||
onToggleAcEco = viewModel::toggleAcEco,
|
|
||||||
onMessageShown = viewModel::clearMessage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
internal fun RemotePanelScreen(
|
|
||||||
state: RemotePanelUiState,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
onButtonClick: (String) -> Unit,
|
|
||||||
onToggleLearnMode: () -> Unit,
|
|
||||||
onAddCustomButton: (String) -> Unit,
|
|
||||||
onDeleteButton: (String) -> Unit,
|
|
||||||
onToggleAcPower: () -> Unit,
|
|
||||||
onAdjustAcTemp: (Int) -> Unit,
|
|
||||||
onSetAcMode: (AcMode) -> Unit,
|
|
||||||
onSetFanSpeed: (AcFanSpeed) -> Unit,
|
|
||||||
onToggleAcVerticalSwing: () -> Unit,
|
|
||||||
onToggleAcHorizontalSwing: () -> Unit,
|
|
||||||
onToggleAcEco: () -> Unit,
|
|
||||||
onMessageShown: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
var showAddKeyDialog by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
|
||||||
state.message?.let { msg ->
|
|
||||||
when (msg) {
|
|
||||||
is PanelUiMessage.Info -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is PanelUiMessage.Error -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is PanelUiMessage.SessionInvalid -> {
|
|
||||||
onSessionInvalid()
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title),
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
fontSize = 18.sp,
|
|
||||||
)
|
|
||||||
state.remote?.let {
|
|
||||||
val layoutLabel = when (it.layoutStyle) {
|
|
||||||
PanelLayoutStyle.TV_NAV -> "电视导航布局"
|
|
||||||
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放布局"
|
|
||||||
PanelLayoutStyle.GRID -> "自由网格布局"
|
|
||||||
PanelLayoutStyle.AC_COMPACT -> "空调全状态布局"
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = layoutLabel,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
TextButton(onClick = onBack) {
|
|
||||||
Text(stringResource(R.string.infrared_back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
// 非空调面板显示学习模式开关
|
|
||||||
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
|
|
||||||
FilterChip(
|
|
||||||
selected = state.isLearnMode,
|
|
||||||
onClick = onToggleLearnMode,
|
|
||||||
label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") },
|
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
|
||||||
selectedContainerColor = Color(0xFFFF5252),
|
|
||||||
selectedLabelColor = Color.White,
|
|
||||||
),
|
|
||||||
modifier = Modifier.testTag("learn_mode_toggle"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
) { innerPadding ->
|
|
||||||
ABoxSurface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
val remote = state.remote
|
|
||||||
if (remote == null) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
|
||||||
// 学习模式指示横幅
|
|
||||||
if (state.isLearnMode) {
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE)),
|
|
||||||
shape = RoundedCornerShape(0.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text("🔴", fontSize = 16.sp)
|
|
||||||
Text(
|
|
||||||
text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码",
|
|
||||||
color = Color(0xFFC62828),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 面板内容区
|
|
||||||
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
|
|
||||||
when (remote.layoutStyle) {
|
|
||||||
PanelLayoutStyle.AC_COMPACT -> {
|
|
||||||
AcControlPanel(
|
|
||||||
acState = state.acState,
|
|
||||||
onTogglePower = onToggleAcPower,
|
|
||||||
onAdjustTemp = onAdjustAcTemp,
|
|
||||||
onSetMode = onSetAcMode,
|
|
||||||
onSetFanSpeed = onSetFanSpeed,
|
|
||||||
onToggleVSwing = onToggleAcVerticalSwing,
|
|
||||||
onToggleHSwing = onToggleAcHorizontalSwing,
|
|
||||||
onToggleEco = onToggleAcEco,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PanelLayoutStyle.TV_NAV -> {
|
|
||||||
TvControlPanel(
|
|
||||||
bindings = state.bindings,
|
|
||||||
isLearnMode = state.isLearnMode,
|
|
||||||
learningButtonId = state.learningButtonId,
|
|
||||||
sendingButtonId = state.sendingButtonId,
|
|
||||||
onButtonClick = onButtonClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PanelLayoutStyle.MEDIA_PLAYER -> {
|
|
||||||
MediaPlayerControlPanel(
|
|
||||||
bindings = state.bindings,
|
|
||||||
isLearnMode = state.isLearnMode,
|
|
||||||
learningButtonId = state.learningButtonId,
|
|
||||||
sendingButtonId = state.sendingButtonId,
|
|
||||||
onButtonClick = onButtonClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PanelLayoutStyle.GRID -> {
|
|
||||||
GenericGridControlPanel(
|
|
||||||
bindings = state.bindings,
|
|
||||||
isLearnMode = state.isLearnMode,
|
|
||||||
learningButtonId = state.learningButtonId,
|
|
||||||
sendingButtonId = state.sendingButtonId,
|
|
||||||
onButtonClick = onButtonClick,
|
|
||||||
onDeleteButton = onDeleteButton,
|
|
||||||
onAddButton = { showAddKeyDialog = true },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (showAddKeyDialog) {
|
|
||||||
AddCustomKeyDialog(
|
|
||||||
onDismiss = { showAddKeyDialog = false },
|
|
||||||
onConfirm = { name ->
|
|
||||||
onAddCustomButton(name)
|
|
||||||
showAddKeyDialog = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AcControlPanel(
|
|
||||||
acState: AcStateSnapshot,
|
|
||||||
onTogglePower: () -> Unit,
|
|
||||||
onAdjustTemp: (Int) -> Unit,
|
|
||||||
onSetMode: (AcMode) -> Unit,
|
|
||||||
onSetFanSpeed: (AcFanSpeed) -> Unit,
|
|
||||||
onToggleVSwing: () -> Unit,
|
|
||||||
onToggleHSwing: () -> Unit,
|
|
||||||
onToggleEco: () -> Unit,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (acState.power) "● 运行中" else "○ 已关机",
|
|
||||||
color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
IconButton(
|
|
||||||
onClick = onTogglePower,
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline)
|
|
||||||
.testTag("ac_power_toggle"),
|
|
||||||
) {
|
|
||||||
Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "${acState.temperature}°C",
|
|
||||||
fontSize = 64.sp,
|
|
||||||
fontWeight = FontWeight.Black,
|
|
||||||
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Button(
|
|
||||||
onClick = { onAdjustTemp(-1) },
|
|
||||||
modifier = Modifier.size(56.dp).testTag("ac_temp_down"),
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
onClick = { onAdjustTemp(1) },
|
|
||||||
modifier = Modifier.size(56.dp).testTag("ac_temp_up"),
|
|
||||||
shape = CircleShape,
|
|
||||||
) {
|
|
||||||
Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
AcMode.entries.forEach { mode ->
|
|
||||||
FilterChip(
|
|
||||||
selected = acState.mode == mode,
|
|
||||||
onClick = { onSetMode(mode) },
|
|
||||||
label = { Text(mode.name) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
AcFanSpeed.entries.forEach { speed ->
|
|
||||||
FilterChip(
|
|
||||||
selected = acState.fanSpeed == speed,
|
|
||||||
onClick = { onSetFanSpeed(speed) },
|
|
||||||
label = { Text(speed.name) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
FilterChip(
|
|
||||||
selected = acState.verticalSwing,
|
|
||||||
onClick = onToggleVSwing,
|
|
||||||
label = { Text("↕ 上下扫风") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = acState.horizontalSwing,
|
|
||||||
onClick = onToggleHSwing,
|
|
||||||
label = { Text("↔ 左右扫风") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = acState.eco,
|
|
||||||
onClick = onToggleEco,
|
|
||||||
label = { Text("🌱 节能 ECO") },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TvControlPanel(
|
|
||||||
bindings: List<RemoteKeyBinding>,
|
|
||||||
isLearnMode: Boolean,
|
|
||||||
learningButtonId: String?,
|
|
||||||
sendingButtonId: String?,
|
|
||||||
onButtonClick: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// 顶部控制行 (电源、静音、信号源)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
|
|
||||||
// 中部五向方向导航盘
|
|
||||||
Card(
|
|
||||||
shape = CircleShape,
|
|
||||||
modifier = Modifier.size(220.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
// 上
|
|
||||||
IconButton(
|
|
||||||
onClick = { onButtonClick("TV_NAV_UP") },
|
|
||||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"),
|
|
||||||
) {
|
|
||||||
ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下
|
|
||||||
IconButton(
|
|
||||||
onClick = { onButtonClick("TV_NAV_DOWN") },
|
|
||||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
|
|
||||||
) {
|
|
||||||
ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 左
|
|
||||||
IconButton(
|
|
||||||
onClick = { onButtonClick("TV_NAV_LEFT") },
|
|
||||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
|
|
||||||
) {
|
|
||||||
ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 右
|
|
||||||
IconButton(
|
|
||||||
onClick = { onButtonClick("TV_NAV_RIGHT") },
|
|
||||||
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
|
|
||||||
) {
|
|
||||||
ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// OK 居中大按钮
|
|
||||||
Button(
|
|
||||||
onClick = { onButtonClick("TV_OK") },
|
|
||||||
shape = CircleShape,
|
|
||||||
modifier = Modifier.size(76.dp).testTag("tv_ok"),
|
|
||||||
) {
|
|
||||||
ButtonIndicatorText("TV_OK", "OK", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 辅助按键行 (返回、主页、菜单)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceAround,
|
|
||||||
) {
|
|
||||||
InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 底部音量与频道调节柱
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
|
||||||
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
|
||||||
ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
|
||||||
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
) {
|
|
||||||
Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
|
||||||
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
|
||||||
ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
|
||||||
ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun MediaPlayerControlPanel(
|
|
||||||
bindings: List<RemoteKeyBinding>,
|
|
||||||
isLearnMode: Boolean,
|
|
||||||
learningButtonId: String?,
|
|
||||||
sendingButtonId: String?,
|
|
||||||
onButtonClick: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
|
|
||||||
// 播放控制中心大圆盘
|
|
||||||
Card(
|
|
||||||
shape = CircleShape,
|
|
||||||
modifier = Modifier.size(240.dp),
|
|
||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
// 快进 >>
|
|
||||||
IconButton(onClick = { onButtonClick("MEDIA_FF") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) {
|
|
||||||
ButtonIndicatorText("MEDIA_FF", "⏩", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
// 快退 <<
|
|
||||||
IconButton(onClick = { onButtonClick("MEDIA_REWIND") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) {
|
|
||||||
ButtonIndicatorText("MEDIA_REWIND", "⏪", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
// 上一曲
|
|
||||||
IconButton(onClick = { onButtonClick("MEDIA_PREV") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp)) {
|
|
||||||
ButtonIndicatorText("MEDIA_PREV", "⏮", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
// 下一曲
|
|
||||||
IconButton(onClick = { onButtonClick("MEDIA_NEXT") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp)) {
|
|
||||||
ButtonIndicatorText("MEDIA_NEXT", "⏭", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
// 播放/暂停
|
|
||||||
Button(onClick = { onButtonClick("MEDIA_PLAY_PAUSE") }, shape = CircleShape, modifier = Modifier.size(80.dp)) {
|
|
||||||
ButtonIndicatorText("MEDIA_PLAY_PAUSE", "⏯", learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun GenericGridControlPanel(
|
|
||||||
bindings: List<RemoteKeyBinding>,
|
|
||||||
isLearnMode: Boolean,
|
|
||||||
learningButtonId: String?,
|
|
||||||
sendingButtonId: String?,
|
|
||||||
onButtonClick: (String) -> Unit,
|
|
||||||
onDeleteButton: (String) -> Unit,
|
|
||||||
onAddButton: () -> Unit,
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
|
||||||
Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) {
|
|
||||||
Text("➕ 添加按键")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bindings.isEmpty()) {
|
|
||||||
Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
|
|
||||||
Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
LazyVerticalGrid(
|
|
||||||
columns = GridCells.Fixed(2),
|
|
||||||
modifier = Modifier.fillMaxSize().weight(1f),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
items(bindings, key = { it.id }) { binding ->
|
|
||||||
GridButtonCard(
|
|
||||||
binding = binding,
|
|
||||||
isLearnMode = isLearnMode,
|
|
||||||
isLearning = learningButtonId == binding.buttonId,
|
|
||||||
isSending = sendingButtonId == binding.buttonId,
|
|
||||||
onClick = { onButtonClick(binding.buttonId) },
|
|
||||||
onDelete = { onDeleteButton(binding.buttonId) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun GridButtonCard(
|
|
||||||
binding: RemoteKeyBinding,
|
|
||||||
isLearnMode: Boolean,
|
|
||||||
isLearning: Boolean,
|
|
||||||
isSending: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
onDelete: () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(76.dp)
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.testTag("generic_btn_${binding.buttonId}"),
|
|
||||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
|
||||||
val statusText = when {
|
|
||||||
isLearning -> "🟡 正在录制信号..."
|
|
||||||
binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})"
|
|
||||||
else -> "⚪ 待录制 (点击录码)"
|
|
||||||
}
|
|
||||||
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSending || isLearning) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
|
||||||
} else {
|
|
||||||
// 始终提供删除操作按钮
|
|
||||||
IconButton(
|
|
||||||
onClick = onDelete,
|
|
||||||
modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"),
|
|
||||||
) {
|
|
||||||
Text("🗑️", fontSize = 15.sp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun InteractiveRemoteButton(
|
|
||||||
buttonId: String,
|
|
||||||
label: String,
|
|
||||||
containerColor: Color,
|
|
||||||
binding: RemoteKeyBinding?,
|
|
||||||
isLearnMode: Boolean,
|
|
||||||
learningButtonId: String?,
|
|
||||||
sendingButtonId: String?,
|
|
||||||
onButtonClick: (String) -> Unit,
|
|
||||||
) {
|
|
||||||
val isUnlearned = binding != null && binding.status != InfraredKeyStatus.READY
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = { onButtonClick(buttonId) },
|
|
||||||
colors = ButtonDefaults.buttonColors(
|
|
||||||
containerColor = when {
|
|
||||||
learningButtonId == buttonId -> Color(0xFFFF9800)
|
|
||||||
isUnlearned -> MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
else -> containerColor
|
|
||||||
},
|
|
||||||
contentColor = if (isUnlearned) MaterialTheme.colorScheme.onSurfaceVariant else Color.White,
|
|
||||||
),
|
|
||||||
modifier = Modifier.testTag("tv_btn_$buttonId"),
|
|
||||||
) {
|
|
||||||
ButtonIndicatorText(buttonId, label, learningButtonId, sendingButtonId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ButtonIndicatorText(
|
|
||||||
buttonId: String,
|
|
||||||
defaultLabel: String,
|
|
||||||
learningButtonId: String?,
|
|
||||||
sendingButtonId: String?,
|
|
||||||
) {
|
|
||||||
if (learningButtonId == buttonId) {
|
|
||||||
Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
|
||||||
} else if (sendingButtonId == buttonId) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
|
|
||||||
} else {
|
|
||||||
Text(defaultLabel, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AddCustomKeyDialog(
|
|
||||||
onDismiss: () -> Unit,
|
|
||||||
onConfirm: (name: String) -> Unit,
|
|
||||||
) {
|
|
||||||
var name by remember { mutableStateOf("") }
|
|
||||||
AlertDialog(
|
|
||||||
onDismissRequest = onDismiss,
|
|
||||||
title = { Text("添加自定义按键") },
|
|
||||||
text = {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
Text("输入按键显示名称,创建后点击按键或开启【学习模式】即可录制实体按键:", fontSize = 13.sp)
|
|
||||||
OutlinedTextField(
|
|
||||||
value = name,
|
|
||||||
onValueChange = { name = it },
|
|
||||||
label = { Text("按键名称(如:静音、低音增强)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
confirmButton = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
|
||||||
if (name.isNotBlank()) onConfirm(name.trim())
|
|
||||||
},
|
|
||||||
modifier = Modifier.testTag("add_key_confirm_button"),
|
|
||||||
) {
|
|
||||||
Text("确认添加")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dismissButton = {
|
|
||||||
TextButton(onClick = onDismiss) { Text("取消") }
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.panel
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
|
|
||||||
sealed interface PanelUiMessage {
|
|
||||||
data class Info(val text: UiText) : PanelUiMessage
|
|
||||||
data class Error(val text: UiText) : PanelUiMessage
|
|
||||||
data object SessionInvalid : PanelUiMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RemotePanelUiState(
|
|
||||||
val profileId: ProviderProfileId? = null,
|
|
||||||
val remote: VirtualRemote? = null,
|
|
||||||
val bindings: List<RemoteKeyBinding> = emptyList(),
|
|
||||||
val acState: AcStateSnapshot = AcStateSnapshot(),
|
|
||||||
val isLearnMode: Boolean = false,
|
|
||||||
val learningButtonId: String? = null,
|
|
||||||
val sendingButtonId: String? = null,
|
|
||||||
val loading: Boolean = true,
|
|
||||||
val message: PanelUiMessage? = null,
|
|
||||||
)
|
|
||||||
-297
@@ -1,297 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.panel
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
|
||||||
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class RemotePanelViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
|
||||||
private val sendVirtualRemoteAction: SendVirtualRemoteAction,
|
|
||||||
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
|
|
||||||
private val addVirtualRemoteKey: AddVirtualRemoteKey,
|
|
||||||
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
|
|
||||||
private val updateVirtualRemoteLayout: UpdateVirtualRemoteLayout,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
|
||||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
|
||||||
|
|
||||||
private val rawRemoteId: String? = savedStateHandle["remoteId"]
|
|
||||||
val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) }
|
|
||||||
|
|
||||||
private val isLearnModeFlow = MutableStateFlow(false)
|
|
||||||
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
|
|
||||||
private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
|
|
||||||
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
|
|
||||||
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
|
|
||||||
private var acDebounceJob: Job? = null
|
|
||||||
|
|
||||||
private val remoteFlow = if (profileId == null || remoteId == null) {
|
|
||||||
flowOf(null)
|
|
||||||
} else {
|
|
||||||
virtualRemoteRepository.observeRemote(profileId, remoteId).flatMapLatest { res ->
|
|
||||||
val remote = if (res is DomainResult.Success) res.value else null
|
|
||||||
flowOf(remote)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val bindingsFlow = if (remoteId == null) {
|
|
||||||
flowOf(emptyList())
|
|
||||||
} else {
|
|
||||||
virtualRemoteRepository.observeKeyBindings(remoteId).flatMapLatest { res ->
|
|
||||||
val list = if (res is DomainResult.Success) res.value else emptyList()
|
|
||||||
flowOf(list)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class RemoteSnapshot(
|
|
||||||
val remote: VirtualRemote?,
|
|
||||||
val bindings: List<RemoteKeyBinding>,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val snapshotFlow = combine(remoteFlow, bindingsFlow) { remote, bindings ->
|
|
||||||
RemoteSnapshot(remote, bindings)
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ActionState(
|
|
||||||
val isLearnMode: Boolean,
|
|
||||||
val learningButtonId: String?,
|
|
||||||
val sendingButtonId: String?,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val actionStateFlow = combine(
|
|
||||||
isLearnModeFlow,
|
|
||||||
learningButtonIdFlow,
|
|
||||||
sendingButtonIdFlow,
|
|
||||||
) { isLearn, learningBtn, sendingBtn ->
|
|
||||||
ActionState(isLearn, learningBtn, sendingBtn)
|
|
||||||
}
|
|
||||||
|
|
||||||
val state: StateFlow<RemotePanelUiState> = combine(
|
|
||||||
snapshotFlow,
|
|
||||||
localAcStateFlow,
|
|
||||||
actionStateFlow,
|
|
||||||
messageFlow,
|
|
||||||
) { snapshot, localAc, action, msg ->
|
|
||||||
val resolvedAc = localAc ?: snapshot.remote?.acState ?: AcStateSnapshot()
|
|
||||||
RemotePanelUiState(
|
|
||||||
profileId = profileId,
|
|
||||||
remote = snapshot.remote,
|
|
||||||
bindings = snapshot.bindings,
|
|
||||||
acState = resolvedAc,
|
|
||||||
isLearnMode = action.isLearnMode,
|
|
||||||
learningButtonId = action.learningButtonId,
|
|
||||||
sendingButtonId = action.sendingButtonId,
|
|
||||||
loading = snapshot.remote == null,
|
|
||||||
message = msg,
|
|
||||||
)
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
|
||||||
initialValue = RemotePanelUiState(profileId = profileId, loading = true),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun toggleLearnMode() {
|
|
||||||
val newMode = !isLearnModeFlow.value
|
|
||||||
isLearnModeFlow.value = newMode
|
|
||||||
if (newMode) {
|
|
||||||
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onButtonClick(buttonId: String) {
|
|
||||||
if (isLearnModeFlow.value) {
|
|
||||||
learnButton(buttonId)
|
|
||||||
} else {
|
|
||||||
sendButton(buttonId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun sendButton(buttonId: String) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentRemoteId = remoteId ?: return
|
|
||||||
if (state.value.sendingButtonId != null || state.value.learningButtonId != null) return
|
|
||||||
|
|
||||||
sendingButtonIdFlow.value = buttonId
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
when (val res = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(buttonId)))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sendingButtonIdFlow.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun learnButton(buttonId: String) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentRemoteId = remoteId ?: return
|
|
||||||
if (state.value.learningButtonId != null) return
|
|
||||||
|
|
||||||
learningButtonIdFlow.value = buttonId
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf(buttonId)))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
learningButtonIdFlow.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addCustomButton(displayName: String) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentRemoteId = remoteId ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val res = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已添加按键【${displayName}】,开启学习模式即可录制"))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun deleteButton(buttonId: String) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentRemoteId = remoteId ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val res = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("按键已删除"))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun switchLayout(newLayout: PanelLayoutStyle) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentRemoteId = remoteId ?: return
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val res = updateVirtualRemoteLayout(currentProfile, currentRemoteId, newLayout)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已切换面板布局风格"))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleAcPower() {
|
|
||||||
val current = state.value.acState
|
|
||||||
updateAndDebounceAc(current.copy(power = !current.power))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun adjustAcTemperature(delta: Int) {
|
|
||||||
val current = state.value.acState
|
|
||||||
val target = (current.temperature + delta).coerceIn(16, 30)
|
|
||||||
if (target != current.temperature) {
|
|
||||||
updateAndDebounceAc(current.copy(temperature = target))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAcMode(mode: AcMode) {
|
|
||||||
val current = state.value.acState
|
|
||||||
updateAndDebounceAc(current.copy(mode = mode))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setAcFanSpeed(fanSpeed: AcFanSpeed) {
|
|
||||||
val current = state.value.acState
|
|
||||||
updateAndDebounceAc(current.copy(fanSpeed = fanSpeed))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleAcVerticalSwing() {
|
|
||||||
val current = state.value.acState
|
|
||||||
updateAndDebounceAc(current.copy(verticalSwing = !current.verticalSwing))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleAcHorizontalSwing() {
|
|
||||||
val current = state.value.acState
|
|
||||||
updateAndDebounceAc(current.copy(horizontalSwing = !current.horizontalSwing))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleAcEco() {
|
|
||||||
val current = state.value.acState
|
|
||||||
updateAndDebounceAc(current.copy(eco = !current.eco))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAndDebounceAc(newState: AcStateSnapshot) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentRemoteId = remoteId ?: return
|
|
||||||
|
|
||||||
// 1. Optimistic UI update
|
|
||||||
localAcStateFlow.value = newState
|
|
||||||
|
|
||||||
// 2. Debounce transmission (300ms)
|
|
||||||
acDebounceJob?.cancel()
|
|
||||||
acDebounceJob = viewModelScope.launch {
|
|
||||||
delay(300L)
|
|
||||||
when (val res = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已同步空调状态:${newState.temperature}℃ | ${newState.mode.name}"))
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> handleDomainError(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearMessage() {
|
|
||||||
messageFlow.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDomainError(error: DomainError) {
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = PanelUiMessage.SessionInvalid
|
|
||||||
is DomainError.DeviceOffline -> messageFlow.value = PanelUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
|
||||||
)
|
|
||||||
else -> messageFlow.value = PanelUiMessage.Error(
|
|
||||||
error.detail?.let { UiText.DynamicString(it) }
|
|
||||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-331
@@ -1,331 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
sealed interface RemoteUiMessage {
|
|
||||||
data class Info(val text: UiText) : RemoteUiMessage
|
|
||||||
data class Error(val text: UiText) : RemoteUiMessage
|
|
||||||
data object SessionInvalid : RemoteUiMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
data class InfraredRemoteUiState(
|
|
||||||
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
|
||||||
val isLearningMode: Boolean = false,
|
|
||||||
val profileId: ProviderProfileId? = null,
|
|
||||||
val providerType: ProviderType? = null,
|
|
||||||
val providerDisplayName: String? = null,
|
|
||||||
val savedDeviceName: DeviceName? = null,
|
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
|
||||||
val sendingKeySlot: String? = null,
|
|
||||||
val activeLearningSlot: String? = null,
|
|
||||||
val message: RemoteUiMessage? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class InfraredRemoteViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val keyRepository: InfraredKeyRepository,
|
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
|
||||||
private val providerProfileStore: ProviderProfileStore,
|
|
||||||
private val learningCoordinator: InfraredLearningCoordinator,
|
|
||||||
private val sendInfraredKey: SendInfraredKey,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
|
||||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
|
||||||
|
|
||||||
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
|
|
||||||
private val isLearningModeFlow = MutableStateFlow(false)
|
|
||||||
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
|
|
||||||
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
|
|
||||||
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
|
|
||||||
private var activeLearningJob: Job? = null
|
|
||||||
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (profileId != null) {
|
|
||||||
val startupCutoff = System.currentTimeMillis()
|
|
||||||
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class InfraredRemoteConfigSnapshot(
|
|
||||||
val providerProfile: ProviderProfile? = null,
|
|
||||||
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
|
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
|
||||||
val configError: DomainError? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
|
||||||
if (profileId == null) {
|
|
||||||
flowOf(InfraredRemoteConfigSnapshot())
|
|
||||||
} else {
|
|
||||||
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
|
|
||||||
val active = profiles.find { it.id == profileId }
|
|
||||||
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
|
|
||||||
when (deviceResult) {
|
|
||||||
is DomainResult.Failure -> flowOf(
|
|
||||||
InfraredRemoteConfigSnapshot(
|
|
||||||
providerProfile = active,
|
|
||||||
configError = deviceResult.error,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
|
||||||
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
|
|
||||||
when (keysResult) {
|
|
||||||
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
|
|
||||||
providerProfile = active,
|
|
||||||
savedDeviceName = deviceName,
|
|
||||||
configError = keysResult.error,
|
|
||||||
)
|
|
||||||
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
|
|
||||||
providerProfile = active,
|
|
||||||
savedDeviceName = deviceName,
|
|
||||||
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val state: StateFlow<InfraredRemoteUiState> = combine(
|
|
||||||
sceneFlow,
|
|
||||||
isLearningModeFlow,
|
|
||||||
configSnapshotFlow,
|
|
||||||
combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg ->
|
|
||||||
Triple(sending, learning, msg)
|
|
||||||
},
|
|
||||||
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
|
|
||||||
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
|
|
||||||
is DomainError.DeviceOffline -> RemoteUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
|
||||||
)
|
|
||||||
else -> RemoteUiMessage.Error(
|
|
||||||
error.detail?.let { UiText.DynamicString(it) }
|
|
||||||
?: UiText.StringResource(R.string.infrared_msg_load_failed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
InfraredRemoteUiState(
|
|
||||||
selectedScene = scene,
|
|
||||||
isLearningMode = isLearning,
|
|
||||||
profileId = profileId,
|
|
||||||
providerType = snapshot.providerProfile?.type,
|
|
||||||
providerDisplayName = snapshot.providerProfile?.displayName,
|
|
||||||
savedDeviceName = snapshot.savedDeviceName,
|
|
||||||
learnedSlotMap = snapshot.learnedSlotMap,
|
|
||||||
sendingKeySlot = sendingSlot,
|
|
||||||
activeLearningSlot = learningSlot,
|
|
||||||
message = resolvedMessage,
|
|
||||||
)
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
|
||||||
initialValue = InfraredRemoteUiState(profileId = profileId),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun switchScene(scene: RemoteScene) {
|
|
||||||
sceneFlow.value = scene
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleLearningMode(enabled: Boolean) {
|
|
||||||
isLearningModeFlow.value = enabled
|
|
||||||
if (!enabled) {
|
|
||||||
val shutdownCutoff = System.currentTimeMillis()
|
|
||||||
underlyingLearningDeferred?.cancel()
|
|
||||||
underlyingLearningDeferred = null
|
|
||||||
activeLearningJob?.cancel()
|
|
||||||
activeLearningJob = null
|
|
||||||
activeLearningSlotFlow.value = null
|
|
||||||
profileId?.let { profile ->
|
|
||||||
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
messageFlow.value = RemoteUiMessage.Info(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_learning_mode_enabled),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onButtonClick(spec: RemoteButtonSpec) {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME
|
|
||||||
|
|
||||||
if (isLearningModeFlow.value) {
|
|
||||||
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
|
||||||
messageFlow.value = RemoteUiMessage.Info(
|
|
||||||
UiText.StringResource(
|
|
||||||
R.string.infrared_msg_recording_key,
|
|
||||||
listOf(spec.displayName, spec.defaultSlot),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
activeLearningSlotFlow.value = spec.defaultSlot
|
|
||||||
val deferred = when (existingKey?.status) {
|
|
||||||
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
|
||||||
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
|
||||||
null -> learningCoordinator.learn(
|
|
||||||
profileId = currentProfile,
|
|
||||||
deviceName = currentDevice,
|
|
||||||
displayName = spec.displayName,
|
|
||||||
sdkKey = spec.defaultSlot,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
underlyingLearningDeferred = deferred
|
|
||||||
val job = viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
val result = if (existingKey != null) {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
(deferred as kotlinx.coroutines.Deferred<DomainResult<Unit>>).await()
|
|
||||||
} else {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
when (val res = (deferred as kotlinx.coroutines.Deferred<DomainResult<InfraredKeyId>>).await()) {
|
|
||||||
is DomainResult.Success -> DomainResult.Success(Unit)
|
|
||||||
is DomainResult.Failure -> DomainResult.Failure(res.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (result) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.Info(
|
|
||||||
UiText.StringResource(
|
|
||||||
R.string.infrared_msg_learned_success,
|
|
||||||
listOf(spec.displayName),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
handleDomainError(result.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
|
||||||
throw e
|
|
||||||
} finally {
|
|
||||||
if (underlyingLearningDeferred === deferred) {
|
|
||||||
underlyingLearningDeferred = null
|
|
||||||
}
|
|
||||||
if (activeLearningSlotFlow.value == spec.defaultSlot) {
|
|
||||||
activeLearningSlotFlow.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
activeLearningJob = job
|
|
||||||
} else {
|
|
||||||
if (sendingKeySlotFlow.value != null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
|
||||||
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error(
|
|
||||||
UiText.StringResource(
|
|
||||||
R.string.infrared_msg_key_not_learned,
|
|
||||||
listOf(spec.displayName),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendingKeySlotFlow.value = spec.defaultSlot
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.Info(
|
|
||||||
UiText.StringResource(
|
|
||||||
R.string.infrared_msg_signal_sent,
|
|
||||||
listOf(spec.displayName),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
handleDomainError(result.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sendingKeySlotFlow.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
val teardownCutoff = System.currentTimeMillis()
|
|
||||||
underlyingLearningDeferred?.cancel()
|
|
||||||
underlyingLearningDeferred = null
|
|
||||||
activeLearningJob?.cancel()
|
|
||||||
activeLearningJob = null
|
|
||||||
profileId?.let { profile ->
|
|
||||||
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearMessage() {
|
|
||||||
messageFlow.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDomainError(error: DomainError) {
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.SessionInvalid
|
|
||||||
}
|
|
||||||
is DomainError.DeviceOffline -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error(
|
|
||||||
UiText.StringResource(
|
|
||||||
R.string.infrared_msg_device_offline,
|
|
||||||
listOf(error.externalCode ?: "20206"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error(
|
|
||||||
error.detail?.let { UiText.DynamicString(it) }
|
|
||||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-61
@@ -1,61 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
|
|
||||||
sealed interface UiText {
|
|
||||||
data class DynamicString(val value: String) : UiText
|
|
||||||
data class StringResource(
|
|
||||||
@StringRes val resId: Int,
|
|
||||||
val args: List<Any> = emptyList(),
|
|
||||||
) : UiText
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class RemoteScene {
|
|
||||||
AIR_CONDITIONER,
|
|
||||||
TELEVISION,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RemoteButtonSpec(
|
|
||||||
val id: String,
|
|
||||||
val defaultSlot: String,
|
|
||||||
@StringRes val nameRes: Int,
|
|
||||||
val displayName: String,
|
|
||||||
val symbol: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
object RemoteSceneConfig {
|
|
||||||
object AirConditioner {
|
|
||||||
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻")
|
|
||||||
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲")
|
|
||||||
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼")
|
|
||||||
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀")
|
|
||||||
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨")
|
|
||||||
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔")
|
|
||||||
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱")
|
|
||||||
|
|
||||||
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
|
|
||||||
}
|
|
||||||
|
|
||||||
object Television {
|
|
||||||
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻")
|
|
||||||
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇")
|
|
||||||
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲")
|
|
||||||
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼")
|
|
||||||
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀")
|
|
||||||
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶")
|
|
||||||
val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
|
|
||||||
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
|
|
||||||
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -")
|
|
||||||
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +")
|
|
||||||
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -")
|
|
||||||
val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠")
|
|
||||||
val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩")
|
|
||||||
val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰")
|
|
||||||
|
|
||||||
val buttons = listOf(
|
|
||||||
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
|
|
||||||
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-174
@@ -1,174 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun AcRemotePanel(
|
|
||||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
|
||||||
activeLearningSlot: String?,
|
|
||||||
sendingKeySlot: String?,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
RemoteChassis(modifier = modifier) {
|
|
||||||
// 顶部虚拟 LCD 液晶显示区
|
|
||||||
AcLcdDisplay()
|
|
||||||
|
|
||||||
// 电源与温度控制行
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
// 电源按键
|
|
||||||
val powerSpec = RemoteSceneConfig.AirConditioner.POWER
|
|
||||||
val isPowerLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY
|
|
||||||
val isPowerLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot
|
|
||||||
|
|
||||||
RemoteCircleButton(
|
|
||||||
spec = powerSpec,
|
|
||||||
isLearned = isPowerLearned,
|
|
||||||
isLoading = isPowerLoading,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onClick = { onButtonClick(powerSpec) },
|
|
||||||
size = 72.dp,
|
|
||||||
containerColor = Color(0xFFE53935),
|
|
||||||
contentColor = Color.White,
|
|
||||||
)
|
|
||||||
|
|
||||||
// 垂直胶囊温度调节器
|
|
||||||
val tempUpSpec = RemoteSceneConfig.AirConditioner.TEMP_UP
|
|
||||||
val tempDownSpec = RemoteSceneConfig.AirConditioner.TEMP_DOWN
|
|
||||||
|
|
||||||
VerticalCapsuleRocker(
|
|
||||||
upSpec = tempUpSpec,
|
|
||||||
downSpec = tempDownSpec,
|
|
||||||
isUpLearned = learnedSlotMap[tempUpSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isDownLearned = learnedSlotMap[tempDownSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isUpLoading = activeLearningSlot == tempUpSpec.defaultSlot || sendingKeySlot == tempUpSpec.defaultSlot,
|
|
||||||
isDownLoading = activeLearningSlot == tempDownSpec.defaultSlot || sendingKeySlot == tempDownSpec.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onUpClick = { onButtonClick(tempUpSpec) },
|
|
||||||
onDownClick = { onButtonClick(tempDownSpec) },
|
|
||||||
label = stringResource(R.string.infrared_label_temp_control),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
// 功能按键网格 (模式 / 风速 / 扫风 / 定时)
|
|
||||||
Column(
|
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
AcGridButton(RemoteSceneConfig.AirConditioner.MODE, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
|
||||||
AcGridButton(RemoteSceneConfig.AirConditioner.FAN, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
) {
|
|
||||||
AcGridButton(RemoteSceneConfig.AirConditioner.SWING, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
|
||||||
AcGridButton(RemoteSceneConfig.AirConditioner.TIMER, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AcGridButton(
|
|
||||||
spec: RemoteButtonSpec,
|
|
||||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
|
||||||
activeLearningSlot: String?,
|
|
||||||
sendingKeySlot: String?,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
|
||||||
) {
|
|
||||||
RemoteRectButton(
|
|
||||||
spec = spec,
|
|
||||||
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onClick = { onButtonClick(spec) },
|
|
||||||
width = 120.dp,
|
|
||||||
height = 52.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AcLcdDisplay() {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(96.dp)
|
|
||||||
.shadow(4.dp, shape = RoundedCornerShape(16.dp)),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = Color(0xFF1E293B),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_label_cool_mode),
|
|
||||||
color = Color(0xFF38BDF8),
|
|
||||||
fontSize = 14.sp,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_label_ac_status),
|
|
||||||
color = Color(0xFF94A3B8),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "26°C",
|
|
||||||
color = Color(0xFF4ADE80),
|
|
||||||
fontSize = 38.sp,
|
|
||||||
fontFamily = FontFamily.Monospace,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-263
@@ -1,263 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.FilterChip
|
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
|
|
||||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteScene
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InfraredRemoteRoute(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
viewModel: InfraredRemoteViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
InfraredRemoteScreen(
|
|
||||||
state = state,
|
|
||||||
onBack = onBack,
|
|
||||||
onOpenSettings = onOpenSettings,
|
|
||||||
onSessionInvalid = onSessionInvalid,
|
|
||||||
onSceneSelected = viewModel::switchScene,
|
|
||||||
onToggleLearningMode = viewModel::toggleLearningMode,
|
|
||||||
onButtonClick = viewModel::onButtonClick,
|
|
||||||
onMessageShown = viewModel::clearMessage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
internal fun InfraredRemoteScreen(
|
|
||||||
state: InfraredRemoteUiState,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onOpenSettings: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
onSceneSelected: (RemoteScene) -> Unit,
|
|
||||||
onToggleLearningMode: (Boolean) -> Unit,
|
|
||||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
|
||||||
onMessageShown: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
|
||||||
state.message?.let { msg ->
|
|
||||||
when (msg) {
|
|
||||||
is RemoteUiMessage.Info -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is RemoteUiMessage.Error -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is RemoteUiMessage.SessionInvalid -> {
|
|
||||||
onSessionInvalid()
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_remote_title),
|
|
||||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
navigationIcon = {
|
|
||||||
TextButton(onClick = onBack) {
|
|
||||||
Text(stringResource(R.string.infrared_back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
// 学习模式开关
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = if (state.isLearningMode) {
|
|
||||||
stringResource(R.string.infrared_remote_recording)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.infrared_remote_learning_mode)
|
|
||||||
},
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
|
|
||||||
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = state.isLearningMode,
|
|
||||||
onCheckedChange = onToggleLearningMode,
|
|
||||||
modifier = Modifier.testTag("remote_learning_mode_switch"),
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = MaterialTheme.colorScheme.error,
|
|
||||||
checkedTrackColor = MaterialTheme.colorScheme.errorContainer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按键管理 / 设置入口按钮
|
|
||||||
IconButton(
|
|
||||||
onClick = onOpenSettings,
|
|
||||||
modifier = Modifier.testTag("remote_settings_button"),
|
|
||||||
) {
|
|
||||||
Text("⚙️", fontSize = 20.sp)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
) { innerPadding ->
|
|
||||||
ABoxSurface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// 学习模式顶部横幅提示
|
|
||||||
if (state.isLearningMode) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
|
|
||||||
shape = RoundedCornerShape(12.dp),
|
|
||||||
)
|
|
||||||
.padding(vertical = 8.dp, horizontal = 12.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_remote_learning_banner),
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(
|
|
||||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 场景切换 Tab (空调 | 电视)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.Center,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
FilterChip(
|
|
||||||
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
|
|
||||||
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
|
|
||||||
label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(end = 8.dp)
|
|
||||||
.testTag("scene_tab_ac"),
|
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
|
||||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
FilterChip(
|
|
||||||
selected = state.selectedScene == RemoteScene.TELEVISION,
|
|
||||||
onClick = { onSceneSelected(RemoteScene.TELEVISION) },
|
|
||||||
label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
|
|
||||||
modifier = Modifier.testTag("scene_tab_tv"),
|
|
||||||
colors = FilterChipDefaults.filterChipColors(
|
|
||||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 对应场景遥控器面板
|
|
||||||
when (state.selectedScene) {
|
|
||||||
RemoteScene.AIR_CONDITIONER -> {
|
|
||||||
AcRemotePanel(
|
|
||||||
learnedSlotMap = state.learnedSlotMap,
|
|
||||||
activeLearningSlot = state.activeLearningSlot,
|
|
||||||
sendingKeySlot = state.sendingKeySlot,
|
|
||||||
isLearningMode = state.isLearningMode,
|
|
||||||
onButtonClick = onButtonClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
RemoteScene.TELEVISION -> {
|
|
||||||
TvRemotePanel(
|
|
||||||
learnedSlotMap = state.learnedSlotMap,
|
|
||||||
activeLearningSlot = state.activeLearningSlot,
|
|
||||||
sendingKeySlot = state.sendingKeySlot,
|
|
||||||
isLearningMode = state.isLearningMode,
|
|
||||||
onButtonClick = onButtonClick,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-429
@@ -1,429 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.Divider
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RemoteChassis(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp)
|
|
||||||
.shadow(12.dp, shape = RoundedCornerShape(32.dp)),
|
|
||||||
shape = RoundedCornerShape(32.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(20.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RemoteCircleButton(
|
|
||||||
spec: RemoteButtonSpec,
|
|
||||||
isLearned: Boolean,
|
|
||||||
isLoading: Boolean,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
size: Dp = 64.dp,
|
|
||||||
containerColor: Color? = null,
|
|
||||||
contentColor: Color? = null,
|
|
||||||
) {
|
|
||||||
val defaultContainer = when {
|
|
||||||
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
isLearned -> containerColor ?: MaterialTheme.colorScheme.primary
|
|
||||||
else -> MaterialTheme.colorScheme.surface
|
|
||||||
}
|
|
||||||
|
|
||||||
val defaultContent = when {
|
|
||||||
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
isLearned -> contentColor ?: MaterialTheme.colorScheme.onPrimary
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
}
|
|
||||||
|
|
||||||
val borderModifier = if (isLearningMode) {
|
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
|
|
||||||
} else if (!isLearned) {
|
|
||||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = modifier
|
|
||||||
.size(size)
|
|
||||||
.then(borderModifier)
|
|
||||||
.testTag("remote_button_${spec.id}"),
|
|
||||||
shape = CircleShape,
|
|
||||||
color = defaultContainer,
|
|
||||||
contentColor = defaultContent,
|
|
||||||
shadowElevation = if (isLearned) 4.dp else 1.dp,
|
|
||||||
enabled = !isLoading,
|
|
||||||
onClick = onClick,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(size * 0.45f),
|
|
||||||
strokeWidth = 2.5.dp,
|
|
||||||
color = defaultContent,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = spec.symbol,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(
|
|
||||||
fontSize = if (spec.symbol.length <= 2) 22.sp else 14.sp,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RemoteRectButton(
|
|
||||||
spec: RemoteButtonSpec,
|
|
||||||
isLearned: Boolean,
|
|
||||||
isLoading: Boolean,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
width: Dp = 100.dp,
|
|
||||||
height: Dp = 48.dp,
|
|
||||||
) {
|
|
||||||
val containerColor = when {
|
|
||||||
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
isLearned -> MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
else -> MaterialTheme.colorScheme.surface
|
|
||||||
}
|
|
||||||
val contentColor = when {
|
|
||||||
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
isLearned -> MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
|
||||||
}
|
|
||||||
|
|
||||||
val borderModifier = if (isLearningMode) {
|
|
||||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp))
|
|
||||||
} else if (!isLearned) {
|
|
||||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
|
|
||||||
Surface(
|
|
||||||
modifier = modifier
|
|
||||||
.width(width)
|
|
||||||
.height(height)
|
|
||||||
.then(borderModifier)
|
|
||||||
.testTag("remote_button_${spec.id}"),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
color = containerColor,
|
|
||||||
contentColor = contentColor,
|
|
||||||
shadowElevation = if (isLearned) 3.dp else 1.dp,
|
|
||||||
enabled = !isLoading,
|
|
||||||
onClick = onClick,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
strokeWidth = 2.dp,
|
|
||||||
color = contentColor,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = spec.symbol,
|
|
||||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun VerticalCapsuleRocker(
|
|
||||||
upSpec: RemoteButtonSpec,
|
|
||||||
downSpec: RemoteButtonSpec,
|
|
||||||
isUpLearned: Boolean,
|
|
||||||
isDownLearned: Boolean,
|
|
||||||
isUpLoading: Boolean,
|
|
||||||
isDownLoading: Boolean,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onUpClick: () -> Unit,
|
|
||||||
onDownClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
label: String = "",
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = modifier,
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(68.dp)
|
|
||||||
.height(136.dp)
|
|
||||||
.shadow(4.dp, shape = RoundedCornerShape(34.dp)),
|
|
||||||
shape = RoundedCornerShape(34.dp),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
) {
|
|
||||||
// Top half
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp))
|
|
||||||
.background(
|
|
||||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
else Color.Transparent,
|
|
||||||
)
|
|
||||||
.clickable(enabled = !isUpLoading, onClick = onUpClick)
|
|
||||||
.testTag("remote_button_${upSpec.id}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isUpLoading) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = upSpec.symbol,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
|
||||||
|
|
||||||
// Bottom half
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(RoundedCornerShape(bottomStart = 34.dp, bottomEnd = 34.dp))
|
|
||||||
.background(
|
|
||||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
else Color.Transparent,
|
|
||||||
)
|
|
||||||
.clickable(enabled = !isDownLoading, onClick = onDownClick)
|
|
||||||
.testTag("remote_button_${downSpec.id}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isDownLoading) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = downSpec.symbol,
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (label.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun RemoteDpadWheel(
|
|
||||||
upSpec: RemoteButtonSpec,
|
|
||||||
downSpec: RemoteButtonSpec,
|
|
||||||
leftSpec: RemoteButtonSpec,
|
|
||||||
rightSpec: RemoteButtonSpec,
|
|
||||||
okSpec: RemoteButtonSpec,
|
|
||||||
isUpLearned: Boolean,
|
|
||||||
isDownLearned: Boolean,
|
|
||||||
isLeftLearned: Boolean,
|
|
||||||
isRightLearned: Boolean,
|
|
||||||
isOkLearned: Boolean,
|
|
||||||
isUpLoading: Boolean,
|
|
||||||
isDownLoading: Boolean,
|
|
||||||
isLeftLoading: Boolean,
|
|
||||||
isRightLoading: Boolean,
|
|
||||||
isOkLoading: Boolean,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onUpClick: () -> Unit,
|
|
||||||
onDownClick: () -> Unit,
|
|
||||||
onLeftClick: () -> Unit,
|
|
||||||
onRightClick: () -> Unit,
|
|
||||||
onOkClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
wheelSize: Dp = 190.dp,
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = modifier
|
|
||||||
.size(wheelSize)
|
|
||||||
.shadow(6.dp, shape = CircleShape),
|
|
||||||
shape = CircleShape,
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surface,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
// Up Button
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.padding(top = 10.dp)
|
|
||||||
.size(54.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
else Color.Transparent,
|
|
||||||
)
|
|
||||||
.clickable(enabled = !isUpLoading, onClick = onUpClick)
|
|
||||||
.testTag("remote_button_${upSpec.id}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isUpLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
|
||||||
else Text(upSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Down Button
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomCenter)
|
|
||||||
.padding(bottom = 10.dp)
|
|
||||||
.size(54.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
else Color.Transparent,
|
|
||||||
)
|
|
||||||
.clickable(enabled = !isDownLoading, onClick = onDownClick)
|
|
||||||
.testTag("remote_button_${downSpec.id}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isDownLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
|
||||||
else Text(downSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Left Button
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterStart)
|
|
||||||
.padding(start = 10.dp)
|
|
||||||
.size(54.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
else if (isLeftLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
else Color.Transparent,
|
|
||||||
)
|
|
||||||
.clickable(enabled = !isLeftLoading, onClick = onLeftClick)
|
|
||||||
.testTag("remote_button_${leftSpec.id}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isLeftLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
|
||||||
else Text(leftSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right Button
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.CenterEnd)
|
|
||||||
.padding(end = 10.dp)
|
|
||||||
.size(54.dp)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(
|
|
||||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
|
||||||
else if (isRightLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
|
||||||
else Color.Transparent,
|
|
||||||
)
|
|
||||||
.clickable(enabled = !isRightLoading, onClick = onRightClick)
|
|
||||||
.testTag("remote_button_${rightSpec.id}"),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isRightLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
|
||||||
else Text(rightSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center OK Button
|
|
||||||
RemoteCircleButton(
|
|
||||||
spec = okSpec,
|
|
||||||
isLearned = isOkLearned,
|
|
||||||
isLoading = isOkLoading,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onClick = onOkClick,
|
|
||||||
size = 62.dp,
|
|
||||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
|
||||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
|
||||||
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TvRemotePanel(
|
|
||||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
|
||||||
activeLearningSlot: String?,
|
|
||||||
sendingKeySlot: String?,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
RemoteChassis(modifier = modifier) {
|
|
||||||
// 顶部电源与静音行
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
val powerSpec = RemoteSceneConfig.Television.POWER
|
|
||||||
RemoteCircleButton(
|
|
||||||
spec = powerSpec,
|
|
||||||
isLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onClick = { onButtonClick(powerSpec) },
|
|
||||||
size = 56.dp,
|
|
||||||
containerColor = Color(0xFFE53935),
|
|
||||||
contentColor = Color.White,
|
|
||||||
)
|
|
||||||
|
|
||||||
val muteSpec = RemoteSceneConfig.Television.MUTE
|
|
||||||
RemoteCircleButton(
|
|
||||||
spec = muteSpec,
|
|
||||||
isLearned = learnedSlotMap[muteSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isLoading = activeLearningSlot == muteSpec.defaultSlot || sendingKeySlot == muteSpec.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onClick = { onButtonClick(muteSpec) },
|
|
||||||
size = 56.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 中心圆形方向轮盘 (D-Pad Wheel)
|
|
||||||
val upSpec = RemoteSceneConfig.Television.NAV_UP
|
|
||||||
val downSpec = RemoteSceneConfig.Television.NAV_DOWN
|
|
||||||
val leftSpec = RemoteSceneConfig.Television.NAV_LEFT
|
|
||||||
val rightSpec = RemoteSceneConfig.Television.NAV_RIGHT
|
|
||||||
val okSpec = RemoteSceneConfig.Television.OK
|
|
||||||
|
|
||||||
RemoteDpadWheel(
|
|
||||||
upSpec = upSpec,
|
|
||||||
downSpec = downSpec,
|
|
||||||
leftSpec = leftSpec,
|
|
||||||
rightSpec = rightSpec,
|
|
||||||
okSpec = okSpec,
|
|
||||||
isUpLearned = learnedSlotMap[upSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isDownLearned = learnedSlotMap[downSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isLeftLearned = learnedSlotMap[leftSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isRightLearned = learnedSlotMap[rightSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isOkLearned = learnedSlotMap[okSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isUpLoading = activeLearningSlot == upSpec.defaultSlot || sendingKeySlot == upSpec.defaultSlot,
|
|
||||||
isDownLoading = activeLearningSlot == downSpec.defaultSlot || sendingKeySlot == downSpec.defaultSlot,
|
|
||||||
isLeftLoading = activeLearningSlot == leftSpec.defaultSlot || sendingKeySlot == leftSpec.defaultSlot,
|
|
||||||
isRightLoading = activeLearningSlot == rightSpec.defaultSlot || sendingKeySlot == rightSpec.defaultSlot,
|
|
||||||
isOkLoading = activeLearningSlot == okSpec.defaultSlot || sendingKeySlot == okSpec.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onUpClick = { onButtonClick(upSpec) },
|
|
||||||
onDownClick = { onButtonClick(downSpec) },
|
|
||||||
onLeftClick = { onButtonClick(leftSpec) },
|
|
||||||
onRightClick = { onButtonClick(rightSpec) },
|
|
||||||
onOkClick = { onButtonClick(okSpec) },
|
|
||||||
)
|
|
||||||
|
|
||||||
// 系统功能按键行 (返回 / 主页 / 菜单)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
TvActionButton(RemoteSceneConfig.Television.BACK, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
|
||||||
TvActionButton(RemoteSceneConfig.Television.HOME, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
|
||||||
TvActionButton(RemoteSceneConfig.Television.MENU, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
|
|
||||||
// 音量与频道双摇杆胶囊
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
// 音量控制
|
|
||||||
val volUp = RemoteSceneConfig.Television.VOL_UP
|
|
||||||
val volDown = RemoteSceneConfig.Television.VOL_DOWN
|
|
||||||
VerticalCapsuleRocker(
|
|
||||||
upSpec = volUp,
|
|
||||||
downSpec = volDown,
|
|
||||||
isUpLearned = learnedSlotMap[volUp.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isDownLearned = learnedSlotMap[volDown.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isUpLoading = activeLearningSlot == volUp.defaultSlot || sendingKeySlot == volUp.defaultSlot,
|
|
||||||
isDownLoading = activeLearningSlot == volDown.defaultSlot || sendingKeySlot == volDown.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onUpClick = { onButtonClick(volUp) },
|
|
||||||
onDownClick = { onButtonClick(volDown) },
|
|
||||||
label = stringResource(R.string.infrared_label_vol),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 频道控制
|
|
||||||
val chUp = RemoteSceneConfig.Television.CH_UP
|
|
||||||
val chDown = RemoteSceneConfig.Television.CH_DOWN
|
|
||||||
VerticalCapsuleRocker(
|
|
||||||
upSpec = chUp,
|
|
||||||
downSpec = chDown,
|
|
||||||
isUpLearned = learnedSlotMap[chUp.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isDownLearned = learnedSlotMap[chDown.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isUpLoading = activeLearningSlot == chUp.defaultSlot || sendingKeySlot == chUp.defaultSlot,
|
|
||||||
isDownLoading = activeLearningSlot == chDown.defaultSlot || sendingKeySlot == chDown.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onUpClick = { onButtonClick(chUp) },
|
|
||||||
onDownClick = { onButtonClick(chDown) },
|
|
||||||
label = stringResource(R.string.infrared_label_ch),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TvActionButton(
|
|
||||||
spec: RemoteButtonSpec,
|
|
||||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
|
||||||
activeLearningSlot: String?,
|
|
||||||
sendingKeySlot: String?,
|
|
||||||
isLearningMode: Boolean,
|
|
||||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
|
||||||
) {
|
|
||||||
RemoteRectButton(
|
|
||||||
spec = spec,
|
|
||||||
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
|
||||||
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
|
|
||||||
isLearningMode = isLearningMode,
|
|
||||||
onClick = { onButtonClick(spec) },
|
|
||||||
width = 84.dp,
|
|
||||||
height = 44.dp,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-303
@@ -1,303 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.Button
|
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.SnackbarHost
|
|
||||||
import androidx.compose.material3.SnackbarHostState
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
||||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun InfraredControlRoute(
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
viewModel: InfraredControlViewModel = hiltViewModel(),
|
|
||||||
) {
|
|
||||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
InfraredControlScreen(
|
|
||||||
state = state,
|
|
||||||
onBack = onBack,
|
|
||||||
onSessionInvalid = onSessionInvalid,
|
|
||||||
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
|
||||||
onSaveDeviceName = viewModel::saveDeviceName,
|
|
||||||
onMessageShown = viewModel::clearMessage,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
internal fun InfraredControlScreen(
|
|
||||||
state: InfraredControlUiState,
|
|
||||||
onBack: () -> Unit,
|
|
||||||
onSessionInvalid: () -> Unit,
|
|
||||||
onDeviceNameChanged: (String) -> Unit,
|
|
||||||
onSaveDeviceName: () -> Unit,
|
|
||||||
onMessageShown: () -> Unit,
|
|
||||||
) {
|
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
|
||||||
val context = LocalContext.current
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
LaunchedEffect(state.message) {
|
|
||||||
state.message?.let { msg ->
|
|
||||||
when (msg) {
|
|
||||||
is InfraredUiMessage.Info -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is InfraredUiMessage.Error -> {
|
|
||||||
val text = when (val t = msg.text) {
|
|
||||||
is UiText.DynamicString -> t.value
|
|
||||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
|
||||||
}
|
|
||||||
snackbarHostState.showSnackbar(text)
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
is InfraredUiMessage.SessionInvalid -> {
|
|
||||||
onSessionInvalid()
|
|
||||||
onMessageShown()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
|
||||||
topBar = {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(stringResource(R.string.infrared_control_title)) },
|
|
||||||
navigationIcon = {
|
|
||||||
TextButton(onClick = onBack) {
|
|
||||||
Text(stringResource(R.string.infrared_back))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
|
||||||
) { innerPadding ->
|
|
||||||
ABoxSurface(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding),
|
|
||||||
) {
|
|
||||||
if (state.loading) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(scrollState)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
// 1. 当前环境卡片
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "运行环境与连接信息",
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
|
|
||||||
val envText = when (state.providerType) {
|
|
||||||
ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation)
|
|
||||||
ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real)
|
|
||||||
null -> state.profileId?.value ?: "未知环境"
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_mode_label, envText),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
)
|
|
||||||
|
|
||||||
state.providerDisplayName?.let {
|
|
||||||
Text(
|
|
||||||
text = "当前 Profile: $it",
|
|
||||||
fontSize = 13.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. ABSDK 硬件设备名称配置(修改当前设备名)
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_device_config_section),
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_device_config_desc),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_device_name_current, state.savedDeviceName.value),
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
|
||||||
value = state.deviceNameInput,
|
|
||||||
onValueChange = onDeviceNameChanged,
|
|
||||||
label = { Text("修改设备名(如:红外控制器)") },
|
|
||||||
singleLine = true,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.testTag("infrared_device_name_input"),
|
|
||||||
)
|
|
||||||
|
|
||||||
Button(
|
|
||||||
onClick = onSaveDeviceName,
|
|
||||||
enabled = state.deviceNameInput.isNotBlank() && state.deviceNameInput != state.savedDeviceName.value,
|
|
||||||
modifier = Modifier.testTag("infrared_save_device_button"),
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.infrared_device_save_changes))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 硬件槽位与遥控器概览
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.infrared_device_stats_section),
|
|
||||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
|
|
||||||
QuotaDetailProgress(quota = state.slotQuota)
|
|
||||||
|
|
||||||
Card(
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.background,
|
|
||||||
),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
Text("💡 槽位工作原理:", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
|
||||||
Text("• 静态独占槽位 (Slot 1..99):用于物理学习录制的按键或预设静态按键,硬件直发低延迟(~20ms)。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
Text("• 动态共享缓冲 (Slot 100):用于空调全状态算法写码与动态模式遥控器,按需现场写入发射,不消耗独占槽位。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun QuotaDetailProgress(quota: SlotQuotaSummary) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}",
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
fontSize = 13.sp,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "剩余可用静态槽位: ${quota.remainingStaticCount}",
|
|
||||||
fontSize = 12.sp,
|
|
||||||
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(8.dp)
|
|
||||||
.clip(RoundedCornerShape(4.dp)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.ui
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
|
|
||||||
sealed interface InfraredUiMessage {
|
|
||||||
data class Info(val text: UiText) : InfraredUiMessage
|
|
||||||
data class Error(val text: UiText) : InfraredUiMessage
|
|
||||||
data object SessionInvalid : InfraredUiMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
data class InfraredControlUiState(
|
|
||||||
val profileId: ProviderProfileId? = null,
|
|
||||||
val providerType: ProviderType? = null,
|
|
||||||
val providerDisplayName: String? = null,
|
|
||||||
val deviceNameInput: String = "",
|
|
||||||
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
|
|
||||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
|
||||||
val totalRemotesCount: Int = 0,
|
|
||||||
val loading: Boolean = true,
|
|
||||||
val message: InfraredUiMessage? = null,
|
|
||||||
)
|
|
||||||
-184
@@ -1,184 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class InfraredControlViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
|
||||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
|
||||||
private val providerProfileStore: ProviderProfileStore,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
|
||||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
|
||||||
|
|
||||||
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
|
|
||||||
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
|
||||||
|
|
||||||
private data class ServerState(
|
|
||||||
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
|
|
||||||
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
|
||||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
|
||||||
val remotesCount: Int = 0,
|
|
||||||
val loading: Boolean = false,
|
|
||||||
val configError: DomainError? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val serverStateFlow: Flow<ServerState> =
|
|
||||||
if (profileId == null) {
|
|
||||||
flowOf(ServerState(loading = false))
|
|
||||||
} else {
|
|
||||||
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
|
|
||||||
val active = profiles.find { it.id == profileId }
|
|
||||||
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
|
|
||||||
when (deviceResult) {
|
|
||||||
is DomainResult.Failure -> flowOf(
|
|
||||||
ServerState(
|
|
||||||
providerProfile = active,
|
|
||||||
configError = deviceResult.error,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
|
||||||
combine(
|
|
||||||
virtualRemoteRepository.observeRemotes(profileId, deviceName),
|
|
||||||
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
|
|
||||||
) { remotesResult, quotaResult ->
|
|
||||||
val remotes = (remotesResult as? DomainResult.Success)?.value ?: emptyList()
|
|
||||||
val quota = (quotaResult as? DomainResult.Success)?.value
|
|
||||||
?: SlotQuotaSummary()
|
|
||||||
ServerState(
|
|
||||||
providerProfile = active,
|
|
||||||
savedDevice = deviceName,
|
|
||||||
slotQuota = quota,
|
|
||||||
remotesCount = remotes.size,
|
|
||||||
loading = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val state: StateFlow<InfraredControlUiState> = combine(
|
|
||||||
serverStateFlow,
|
|
||||||
deviceNameInputFlow,
|
|
||||||
messageFlow,
|
|
||||||
) { serverState, input, msg ->
|
|
||||||
val resolvedMessage = msg ?: serverState.configError?.let { error ->
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
|
|
||||||
is DomainError.DeviceOffline -> InfraredUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
|
||||||
)
|
|
||||||
else -> InfraredUiMessage.Error(
|
|
||||||
error.detail?.let { UiText.DynamicString(it) }
|
|
||||||
?: UiText.StringResource(R.string.infrared_msg_load_failed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val effectiveInput = input ?: serverState.savedDevice.value
|
|
||||||
|
|
||||||
InfraredControlUiState(
|
|
||||||
profileId = profileId,
|
|
||||||
providerType = serverState.providerProfile?.type,
|
|
||||||
providerDisplayName = serverState.providerProfile?.displayName,
|
|
||||||
deviceNameInput = effectiveInput,
|
|
||||||
savedDeviceName = serverState.savedDevice,
|
|
||||||
slotQuota = serverState.slotQuota,
|
|
||||||
totalRemotesCount = serverState.remotesCount,
|
|
||||||
loading = serverState.loading,
|
|
||||||
message = resolvedMessage,
|
|
||||||
)
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
|
||||||
initialValue = InfraredControlUiState(profileId = profileId, loading = true),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun onDeviceNameChanged(value: String) {
|
|
||||||
deviceNameInputFlow.value = value
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveDeviceName() {
|
|
||||||
val currentProfile = profileId ?: return
|
|
||||||
val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value
|
|
||||||
val trimmed = input.trim()
|
|
||||||
if (trimmed.isBlank()) {
|
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_device_name_blank),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModelScope.launch {
|
|
||||||
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = InfraredUiMessage.Info(
|
|
||||||
UiText.StringResource(R.string.infrared_msg_device_name_saved),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
handleDomainError(result.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearMessage() {
|
|
||||||
messageFlow.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDomainError(error: DomainError) {
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> {
|
|
||||||
messageFlow.value = InfraredUiMessage.SessionInvalid
|
|
||||||
}
|
|
||||||
is DomainError.DeviceOffline -> {
|
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
|
||||||
UiText.StringResource(
|
|
||||||
R.string.infrared_msg_device_offline,
|
|
||||||
listOf(error.externalCode ?: "20206"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
messageFlow.value = InfraredUiMessage.Error(
|
|
||||||
error.detail?.let { UiText.DynamicString(it) }
|
|
||||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="infrared_title">红外遥控</string>
|
|
||||||
<string name="infrared_mode_fake">模式:模拟体验</string>
|
|
||||||
<string name="infrared_mode_real">模式:ABBox 真实硬件</string>
|
|
||||||
<string name="infrared_switch_mode">切换模式</string>
|
|
||||||
<string name="infrared_device_name_label">红外设备名称</string>
|
|
||||||
<string name="infrared_device_name_hint">例如:ir-living</string>
|
|
||||||
<string name="infrared_saved_device">已配置设备:%1$s</string>
|
|
||||||
<string name="infrared_no_device_saved">未配置红外设备</string>
|
|
||||||
<string name="infrared_save_device">保存设备</string>
|
|
||||||
<string name="infrared_keys_title">已学习按键</string>
|
|
||||||
<string name="infrared_no_keys">暂无已保存按键</string>
|
|
||||||
<string name="infrared_learn_new_key">学习新按键</string>
|
|
||||||
<string name="infrared_status_learning">学习中…</string>
|
|
||||||
<string name="infrared_status_ready">已就绪</string>
|
|
||||||
<string name="infrared_status_failed">学习失败</string>
|
|
||||||
<string name="infrared_send">发射</string>
|
|
||||||
<string name="infrared_sending">发射中…</string>
|
|
||||||
<string name="infrared_retry">重试</string>
|
|
||||||
<string name="infrared_rename">重命名</string>
|
|
||||||
<string name="infrared_delete">删除</string>
|
|
||||||
<string name="infrared_learn_dialog_title">学习红外按键</string>
|
|
||||||
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器,点击开始后将遥控器对准红外转发器并按下目标按键。</string>
|
|
||||||
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
|
|
||||||
<string name="infrared_learn_dialog_slot_label">按键槽位(1-100)</string>
|
|
||||||
<string name="infrared_key_slot_label">按键编号:%1$s</string>
|
|
||||||
<string name="infrared_learn_dialog_start">开始学习</string>
|
|
||||||
<string name="infrared_rename_dialog_title">重命名按键</string>
|
|
||||||
<string name="infrared_rename_dialog_input_label">新按键名称</string>
|
|
||||||
<string name="infrared_delete_dialog_title">删除按键</string>
|
|
||||||
<string name="infrared_delete_dialog_message">仅从本机删除该按键记录,ABox 主机上可能仍保留已录制码值。</string>
|
|
||||||
<string name="infrared_dialog_cancel">取消</string>
|
|
||||||
<string name="infrared_dialog_confirm">确认</string>
|
|
||||||
<string name="infrared_back">返回</string>
|
|
||||||
<string name="infrared_remote_title">红外遥控器</string>
|
|
||||||
<string name="infrared_remote_recording">录制中</string>
|
|
||||||
<string name="infrared_remote_learning_mode">学习模式</string>
|
|
||||||
<string name="infrared_remote_learning_banner">🔴 学习模式已开启:点击任意按键开始录制实体遥控器信号</string>
|
|
||||||
<string name="infrared_remote_scene_ac">❄️ 空调面板</string>
|
|
||||||
<string name="infrared_remote_scene_tv">📺 电视面板</string>
|
|
||||||
<string name="infrared_remote_settings">设置</string>
|
|
||||||
|
|
||||||
<string name="infrared_msg_learning_mode_enabled">🔴 学习模式已开启:点击任意按键开始录制</string>
|
|
||||||
<string name="infrared_msg_recording_key">正在录制【%1$s】(槽位 %2$s),请按下实体遥控器按键…</string>
|
|
||||||
<string name="infrared_msg_learned_success">【%1$s】录制成功</string>
|
|
||||||
<string name="infrared_msg_key_not_learned">按键【%1$s】尚未学习,开启右上角学习模式即可录制</string>
|
|
||||||
<string name="infrared_msg_signal_sent">已发射信号:%1$s</string>
|
|
||||||
<string name="infrared_msg_device_offline">红外设备离线(错误码:%1$s)</string>
|
|
||||||
<string name="infrared_msg_load_failed">加载数据失败</string>
|
|
||||||
<string name="infrared_msg_operation_failed">操作失败</string>
|
|
||||||
<string name="infrared_label_temp_control">温度调节</string>
|
|
||||||
<string name="infrared_label_vol">音量</string>
|
|
||||||
<string name="infrared_label_ch">频道</string>
|
|
||||||
<string name="infrared_label_cool_mode">❄️ 制冷模式</string>
|
|
||||||
<string name="infrared_label_ac_status">💨 自动风速 | ↔ 自动扫风</string>
|
|
||||||
|
|
||||||
<string name="infrared_key_ac_power">空调-电源</string>
|
|
||||||
<string name="infrared_key_ac_temp_up">空调-升温</string>
|
|
||||||
<string name="infrared_key_ac_temp_down">空调-降温</string>
|
|
||||||
<string name="infrared_key_ac_mode">空调-模式</string>
|
|
||||||
<string name="infrared_key_ac_fan">空调-风速</string>
|
|
||||||
<string name="infrared_key_ac_swing">空调-扫风</string>
|
|
||||||
<string name="infrared_key_ac_timer">空调-定时</string>
|
|
||||||
|
|
||||||
<string name="infrared_key_tv_power">电视-电源</string>
|
|
||||||
<string name="infrared_key_tv_mute">电视-静音</string>
|
|
||||||
<string name="infrared_key_tv_nav_up">电视-向上</string>
|
|
||||||
<string name="infrared_key_tv_nav_down">电视-向下</string>
|
|
||||||
<string name="infrared_key_tv_nav_left">电视-向左</string>
|
|
||||||
<string name="infrared_key_tv_nav_right">电视-向右</string>
|
|
||||||
<string name="infrared_key_tv_ok">电视-确定</string>
|
|
||||||
<string name="infrared_key_tv_vol_up">电视-音量+</string>
|
|
||||||
<string name="infrared_key_tv_vol_down">电视-音量-</string>
|
|
||||||
<string name="infrared_key_tv_ch_up">电视-频道+</string>
|
|
||||||
<string name="infrared_key_tv_ch_down">电视-频道-</string>
|
|
||||||
<string name="infrared_key_tv_home">电视-主页</string>
|
|
||||||
<string name="infrared_key_tv_back">电视-返回</string>
|
|
||||||
<string name="infrared_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>
|
|
||||||
<string name="infrared_device_name_current">当前绑定设备:%1$s</string>
|
|
||||||
<string name="infrared_device_save_changes">保存修改</string>
|
|
||||||
<string name="infrared_device_stats_section">硬件槽位与遥控器概览</string>
|
|
||||||
<string name="infrared_mode_label">运行环境:%1$s</string>
|
|
||||||
<string name="infrared_mode_local_simulation">本地体验(模拟)</string>
|
|
||||||
<string name="infrared_mode_abox_real">ABox(真实硬件)</string>
|
|
||||||
<string name="infrared_msg_device_name_saved">设备名称已成功保存</string>
|
|
||||||
<string name="infrared_msg_device_name_blank">设备名称不能为空</string>
|
|
||||||
<string name="infrared_msg_key_name_blank">按键名称不能为空</string>
|
|
||||||
<string name="infrared_msg_slot_invalid">按键编号必须在 1 到 100 之间</string>
|
|
||||||
<string name="infrared_msg_key_renamed">按键已重命名</string>
|
|
||||||
<string name="infrared_msg_key_deleted">按键已从本机删除</string>
|
|
||||||
<string name="infrared_msg_key_not_ready">按键尚未就绪,无法发射</string>
|
|
||||||
<string name="infrared_hub_title">红外中枢</string>
|
|
||||||
<string name="infrared_hub_my_remotes">我的遥控器</string>
|
|
||||||
<string name="infrared_hub_slot_quota">硬件槽位:%1$d / %2$d 已占用(剩余 %3$d)</string>
|
|
||||||
<string name="infrared_hub_static_quota">静态占用:%1$d</string>
|
|
||||||
<string name="infrared_hub_dynamic_scratch">动态缓冲:Slot %1$s</string>
|
|
||||||
<string name="infrared_hub_remotes_title">虚拟遥控器</string>
|
|
||||||
<string name="infrared_hub_no_remotes">暂无虚拟遥控器,点击右上角或下方 ➕ 添加</string>
|
|
||||||
<string name="infrared_hub_add_remote">添加遥控器</string>
|
|
||||||
<string name="infrared_hub_mode_static">🚀 静态独占(%1$s 槽位)</string>
|
|
||||||
<string name="infrared_hub_mode_dynamic">📦 动态缓冲(共用 Slot %1$s)</string>
|
|
||||||
<string name="infrared_hub_create_dialog_title">添加虚拟遥控器</string>
|
|
||||||
<string name="infrared_hub_device_type_label">设备类型</string>
|
|
||||||
<string name="infrared_hub_brand_label">预设码库 / 品牌</string>
|
|
||||||
<string name="infrared_hub_trans_mode_label">传输模式</string>
|
|
||||||
<string name="infrared_hub_trans_mode_static_desc">超低延迟(~20ms),极速响应,占用 ~15 个硬件槽位</string>
|
|
||||||
<string name="infrared_hub_trans_mode_dynamic_desc">节省槽位(0 额外槽位),现场写码发射(~80ms)</string>
|
|
||||||
<string name="infrared_hub_quota_insufficient">硬件槽位不足,请选择动态写码模式</string>
|
|
||||||
<string name="infrared_hub_raw_keys_button">硬件槽位与底层按键管理</string>
|
|
||||||
<string name="infrared_hub_device_type_tv">📺 电视机 / 电视盒子</string>
|
|
||||||
<string name="infrared_hub_device_type_ac">❄️ 空调设备</string>
|
|
||||||
<string name="infrared_hub_device_type_fan">🌀 电风扇 / 空气净化器</string>
|
|
||||||
<string name="infrared_hub_device_type_custom">🎛️ 自定义面板</string>
|
|
||||||
</resources>
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
<resources>
|
|
||||||
<string name="infrared_title">Infrared Control</string>
|
|
||||||
<string name="infrared_mode_fake">Mode: Fake / Simulated</string>
|
|
||||||
<string name="infrared_mode_real">Mode: Real ABSDK</string>
|
|
||||||
<string name="infrared_switch_mode">Switch Mode</string>
|
|
||||||
<string name="infrared_device_name_label">Infrared Device Name</string>
|
|
||||||
<string name="infrared_device_name_hint">e.g. ir-living</string>
|
|
||||||
<string name="infrared_saved_device">Saved Device: %1$s</string>
|
|
||||||
<string name="infrared_no_device_saved">No infrared device configured</string>
|
|
||||||
<string name="infrared_save_device">Save Device</string>
|
|
||||||
<string name="infrared_keys_title">Learned Keys</string>
|
|
||||||
<string name="infrared_no_keys">No infrared keys saved</string>
|
|
||||||
<string name="infrared_learn_new_key">Learn New Key</string>
|
|
||||||
<string name="infrared_status_learning">Learning…</string>
|
|
||||||
<string name="infrared_status_ready">Ready</string>
|
|
||||||
<string name="infrared_status_failed">Failed</string>
|
|
||||||
<string name="infrared_send">Send</string>
|
|
||||||
<string name="infrared_sending">Sending…</string>
|
|
||||||
<string name="infrared_retry">Retry</string>
|
|
||||||
<string name="infrared_rename">Rename</string>
|
|
||||||
<string name="infrared_delete">Delete</string>
|
|
||||||
<string name="infrared_learn_dialog_title">Learn Infrared Key</string>
|
|
||||||
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
|
|
||||||
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
|
|
||||||
<string name="infrared_learn_dialog_slot_label">Key Slot (1-100)</string>
|
|
||||||
<string name="infrared_key_slot_label">Key Slot: %1$s</string>
|
|
||||||
<string name="infrared_learn_dialog_start">Start Learning</string>
|
|
||||||
<string name="infrared_rename_dialog_title">Rename Key</string>
|
|
||||||
<string name="infrared_rename_dialog_input_label">New Display Name</string>
|
|
||||||
<string name="infrared_delete_dialog_title">Delete Key</string>
|
|
||||||
<string name="infrared_delete_dialog_message">Only removes the key locally from this app. ABox server may retain the learned key.</string>
|
|
||||||
<string name="infrared_dialog_cancel">Cancel</string>
|
|
||||||
<string name="infrared_dialog_confirm">Confirm</string>
|
|
||||||
<string name="infrared_back">Back</string>
|
|
||||||
<string name="infrared_remote_title">Infrared Remote</string>
|
|
||||||
<string name="infrared_remote_recording">Recording</string>
|
|
||||||
<string name="infrared_hub_my_remotes">My Remotes</string>
|
|
||||||
<string name="infrared_remote_learning_mode">Learn Mode</string>
|
|
||||||
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
|
|
||||||
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
|
|
||||||
<string name="infrared_remote_scene_tv">📺 Television</string>
|
|
||||||
<string name="infrared_remote_settings">Settings</string>
|
|
||||||
|
|
||||||
<string name="infrared_msg_learning_mode_enabled">🔴 Learn mode active: tap any button to record</string>
|
|
||||||
<string name="infrared_msg_recording_key">Recording [%1$s] (Slot %2$s), press remote button…</string>
|
|
||||||
<string name="infrared_msg_learned_success">[%1$s] recorded successfully</string>
|
|
||||||
<string name="infrared_msg_key_not_learned">Button [%1$s] is not learned. Enable learn mode to record.</string>
|
|
||||||
<string name="infrared_msg_signal_sent">Sent: %1$s</string>
|
|
||||||
<string name="infrared_msg_device_offline">Infrared controller offline (code: %1$s)</string>
|
|
||||||
<string name="infrared_msg_load_failed">Failed to load data</string>
|
|
||||||
<string name="infrared_msg_operation_failed">Operation failed</string>
|
|
||||||
<string name="infrared_label_temp_control">Temp Control</string>
|
|
||||||
<string name="infrared_label_vol">VOL</string>
|
|
||||||
<string name="infrared_label_ch">CH</string>
|
|
||||||
<string name="infrared_label_cool_mode">❄️ Cool Mode</string>
|
|
||||||
<string name="infrared_label_ac_status">💨 Auto Fan | ↔ Swing</string>
|
|
||||||
|
|
||||||
<string name="infrared_key_ac_power">AC Power</string>
|
|
||||||
<string name="infrared_key_ac_temp_up">AC Temp +</string>
|
|
||||||
<string name="infrared_key_ac_temp_down">AC Temp -</string>
|
|
||||||
<string name="infrared_key_ac_mode">AC Mode</string>
|
|
||||||
<string name="infrared_key_ac_fan">AC Fan Speed</string>
|
|
||||||
<string name="infrared_key_ac_swing">AC Swing</string>
|
|
||||||
<string name="infrared_key_ac_timer">AC Timer</string>
|
|
||||||
|
|
||||||
<string name="infrared_key_tv_power">TV Power</string>
|
|
||||||
<string name="infrared_key_tv_mute">TV Mute</string>
|
|
||||||
<string name="infrared_key_tv_nav_up">TV Up</string>
|
|
||||||
<string name="infrared_key_tv_nav_down">TV Down</string>
|
|
||||||
<string name="infrared_key_tv_nav_left">TV Left</string>
|
|
||||||
<string name="infrared_key_tv_nav_right">TV Right</string>
|
|
||||||
<string name="infrared_key_tv_ok">TV OK</string>
|
|
||||||
<string name="infrared_key_tv_vol_up">TV Vol +</string>
|
|
||||||
<string name="infrared_key_tv_vol_down">TV Vol -</string>
|
|
||||||
<string name="infrared_key_tv_ch_up">TV CH +</string>
|
|
||||||
<string name="infrared_key_tv_ch_down">TV CH -</string>
|
|
||||||
<string name="infrared_key_tv_home">TV Home</string>
|
|
||||||
<string name="infrared_key_tv_back">TV Back</string>
|
|
||||||
<string name="infrared_key_tv_menu">TV Menu</string>
|
|
||||||
<string name="infrared_control_title">Device Configuration</string>
|
|
||||||
<string name="infrared_device_config_section">ABSDK Device Name</string>
|
|
||||||
<string name="infrared_device_config_desc">ABSDK matches hardware devices by device name (default: 红外控制器). Commands will be dispatched to this target device.</string>
|
|
||||||
<string name="infrared_device_name_current">Current Bound Device: %1$s</string>
|
|
||||||
<string name="infrared_device_save_changes">Save Changes</string>
|
|
||||||
<string name="infrared_device_stats_section">Hardware Slot Budget</string>
|
|
||||||
<string name="infrared_mode_label">Environment: %1$s</string>
|
|
||||||
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
|
|
||||||
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
|
|
||||||
<string name="infrared_msg_device_name_saved">Device name saved successfully</string>
|
|
||||||
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
|
|
||||||
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
|
|
||||||
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
|
|
||||||
<string name="infrared_msg_key_renamed">Key renamed</string>
|
|
||||||
<string name="infrared_msg_key_deleted">Key removed locally</string>
|
|
||||||
<string name="infrared_msg_key_not_ready">Key is not ready to send</string>
|
|
||||||
<string name="infrared_hub_title">Infrared Hub</string>
|
|
||||||
<string name="infrared_hub_slot_quota">Hardware Slots: %1$d / %2$d allocated (%3$d free)</string>
|
|
||||||
<string name="infrared_hub_static_quota">Static: %1$d</string>
|
|
||||||
<string name="infrared_hub_dynamic_scratch">Dynamic Buffer: Slot %1$s</string>
|
|
||||||
<string name="infrared_hub_remotes_title">Virtual Remotes</string>
|
|
||||||
<string name="infrared_hub_no_remotes">No virtual remotes added yet. Tap + to add one.</string>
|
|
||||||
<string name="infrared_hub_add_remote">Add Remote</string>
|
|
||||||
<string name="infrared_hub_mode_static">🚀 Static Mode (%1$s slots)</string>
|
|
||||||
<string name="infrared_hub_mode_dynamic">📦 Dynamic Mode (Scratch Slot %1$s)</string>
|
|
||||||
<string name="infrared_hub_create_dialog_title">Add Virtual Remote</string>
|
|
||||||
<string name="infrared_hub_device_type_label">Device Type</string>
|
|
||||||
<string name="infrared_hub_brand_label">Brand / Preset Library</string>
|
|
||||||
<string name="infrared_hub_trans_mode_label">Transmission Mode</string>
|
|
||||||
<string name="infrared_hub_trans_mode_static_desc">Low latency (~20ms), instant response, occupies ~15 slots</string>
|
|
||||||
<string name="infrared_hub_trans_mode_dynamic_desc">Saves slots (0 extra slots), writes code on-the-fly (~80ms)</string>
|
|
||||||
<string name="infrared_hub_quota_insufficient">Slot quota exceeded. Please choose Dynamic mode.</string>
|
|
||||||
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
|
|
||||||
<string name="infrared_hub_device_type_tv">📺 Television (TV)</string>
|
|
||||||
<string name="infrared_hub_device_type_ac">❄️ Air Conditioner (AC)</string>
|
|
||||||
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
|
|
||||||
<string name="infrared_hub_device_type_custom">🎛️ Custom Panel</string>
|
|
||||||
</resources>
|
|
||||||
-156
@@ -1,156 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.application
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class InfraredLearningCoordinatorTest {
|
|
||||||
|
|
||||||
private val profile = ProviderProfileId("profile1")
|
|
||||||
private val device = DeviceName("ir-1")
|
|
||||||
private val clock = EpochMillisClock { 1_000L }
|
|
||||||
private val repository = FakeInfraredKeyRepository()
|
|
||||||
private val gateway = FakeInfraredGateway()
|
|
||||||
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
|
|
||||||
|
|
||||||
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
|
|
||||||
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
|
|
||||||
private val relearn = RelearnInfraredKey(repository, gateway, clock)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learnRunsAsynchronouslyInExternalScope() = runTest {
|
|
||||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
|
||||||
|
|
||||||
coordinator.learn(profile, device, "Power")
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val keysResult = repository.observeKeys(profile, device).first()
|
|
||||||
assertTrue(keysResult is DomainResult.Success)
|
|
||||||
val keys = (keysResult as DomainResult.Success).value
|
|
||||||
assertEquals(1, keys.size)
|
|
||||||
assertEquals(InfraredKeyStatus.READY, keys.single().status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryRunsAsynchronouslyInExternalScope() = runTest {
|
|
||||||
val failedKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("key1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "app_ir_key1",
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "20206",
|
|
||||||
lastErrorDetail = "offline",
|
|
||||||
createdAtEpochMillis = 500L,
|
|
||||||
updatedAtEpochMillis = 500L,
|
|
||||||
)
|
|
||||||
repository.insert(failedKey)
|
|
||||||
|
|
||||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
|
||||||
|
|
||||||
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun relearnRunsAsynchronouslyInExternalScope() = runTest {
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("key1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "app_ir_key1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 500L,
|
|
||||||
updatedAtEpochMillis = 500L,
|
|
||||||
)
|
|
||||||
repository.insert(readyKey)
|
|
||||||
|
|
||||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
|
||||||
|
|
||||||
coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failInterruptedLearningFailsLearningKeys() = runTest {
|
|
||||||
val learningKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("key1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "app_ir_key1",
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 500L,
|
|
||||||
updatedAtEpochMillis = 500L,
|
|
||||||
)
|
|
||||||
repository.insert(learningKey)
|
|
||||||
|
|
||||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
|
||||||
|
|
||||||
coordinator.failInterruptedLearning(profile, 2_000L)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, updated.status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun subsequentLearnWaitsForCleanupCompletion() = runTest {
|
|
||||||
val learningKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("key1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 500L,
|
|
||||||
updatedAtEpochMillis = 500L,
|
|
||||||
)
|
|
||||||
repository.insert(learningKey)
|
|
||||||
|
|
||||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
|
||||||
|
|
||||||
coordinator.failInterruptedLearning(profile, cutoffEpochMillis = 1000L)
|
|
||||||
val relearnJob = coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val relearnResult = relearnJob.await()
|
|
||||||
assertTrue(relearnResult is DomainResult.Success)
|
|
||||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-163
@@ -1,163 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.hub
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class InfraredHubViewModelTest {
|
|
||||||
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
|
||||||
private val testScope = TestScope(testDispatcher)
|
|
||||||
|
|
||||||
private val profileId = ProviderProfileId("profile1")
|
|
||||||
private val providerProfile = ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local Simulation")
|
|
||||||
private val device = DeviceName("ir-1")
|
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
|
||||||
|
|
||||||
private val profileStore = FakeProviderProfileStore()
|
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
|
||||||
private val remoteRepo = FakeVirtualRemoteRepository()
|
|
||||||
private val gateway = FakeInfraredGateway()
|
|
||||||
|
|
||||||
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
|
|
||||||
private val deleteRemote = DeleteVirtualRemote(remoteRepo)
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
Dispatchers.setMain(testDispatcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun teardown() {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createViewModel(): InfraredHubViewModel = InfraredHubViewModel(
|
|
||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
|
||||||
providerProfileStore = profileStore,
|
|
||||||
deviceConfigurationRepository = deviceRepo,
|
|
||||||
virtualRemoteRepository = remoteRepo,
|
|
||||||
createVirtualRemote = createRemote,
|
|
||||||
deleteVirtualRemote = deleteRemote,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun initialStateLoadsRemotesAndSlotQuota() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
|
||||||
assertEquals(device, vm.state.value.savedDeviceName)
|
|
||||||
assertEquals(0, vm.state.value.remotes.size)
|
|
||||||
assertEquals(99, vm.state.value.slotQuota.remainingStaticCount)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun createRemoteAddsToRepositoryAndUpdatesState() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.createRemote(
|
|
||||||
name = "Living Room TV",
|
|
||||||
type = RemoteDeviceType.TV,
|
|
||||||
brandModel = "generic_tv",
|
|
||||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, vm.state.value.remotes.size)
|
|
||||||
assertEquals("Living Room TV", vm.state.value.remotes.first().displayName)
|
|
||||||
assertEquals(TransmissionMode.STATIC_SLOTS, vm.state.value.remotes.first().transmissionMode)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteRemoteRemovesFromRepository() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.createRemote(
|
|
||||||
name = "Fan",
|
|
||||||
type = RemoteDeviceType.FAN,
|
|
||||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val remoteId = vm.state.value.remotes.first().id
|
|
||||||
vm.deleteRemote(remoteId)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(0, vm.state.value.remotes.size)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun createCustomRemoteWithTvLayoutAssignsLayoutAndStaticSlots() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.createRemote(
|
|
||||||
name = "Custom TV Remote",
|
|
||||||
type = RemoteDeviceType.CUSTOM,
|
|
||||||
brandModel = null,
|
|
||||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
|
||||||
layoutStyle = com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV,
|
|
||||||
)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, vm.state.value.remotes.size)
|
|
||||||
val created = vm.state.value.remotes.first()
|
|
||||||
assertEquals("Custom TV Remote", created.displayName)
|
|
||||||
assertEquals(com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, created.layoutStyle)
|
|
||||||
assertEquals(TransmissionMode.STATIC_SLOTS, created.transmissionMode)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-229
@@ -1,229 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.panel
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
|
||||||
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
|
||||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
|
||||||
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.advanceTimeBy
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runCurrent
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class RemotePanelViewModelTest {
|
|
||||||
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
|
||||||
private val testScope = TestScope(testDispatcher)
|
|
||||||
|
|
||||||
private val profileId = ProviderProfileId("profile1")
|
|
||||||
private val device = DeviceName("ir-1")
|
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
|
||||||
|
|
||||||
private val remoteRepo = FakeVirtualRemoteRepository()
|
|
||||||
private val gateway = FakeInfraredGateway()
|
|
||||||
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
|
|
||||||
private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway)
|
|
||||||
private val learnKey = LearnVirtualRemoteKey(remoteRepo, gateway)
|
|
||||||
private val addKey = AddVirtualRemoteKey(remoteRepo)
|
|
||||||
private val deleteKey = DeleteVirtualRemoteKey(remoteRepo)
|
|
||||||
private val updateLayout = UpdateVirtualRemoteLayout(remoteRepo)
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
Dispatchers.setMain(testDispatcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun teardown() {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createViewModel(remoteId: String): RemotePanelViewModel = RemotePanelViewModel(
|
|
||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)),
|
|
||||||
virtualRemoteRepository = remoteRepo,
|
|
||||||
sendVirtualRemoteAction = sendAction,
|
|
||||||
learnVirtualRemoteKey = learnKey,
|
|
||||||
addVirtualRemoteKey = addKey,
|
|
||||||
deleteVirtualRemoteKey = deleteKey,
|
|
||||||
updateVirtualRemoteLayout = updateLayout,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tvButtonTriggersInstantTransmissionInNormalMode() = testScope.runTest {
|
|
||||||
val res = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "TV",
|
|
||||||
type = RemoteDeviceType.TV,
|
|
||||||
brandModel = "generic_tv",
|
|
||||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
|
||||||
)
|
|
||||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
|
||||||
|
|
||||||
val vm = createViewModel(remoteId.value)
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.onButtonClick("TV_POWER")
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
assertEquals("1", gateway.sentKeys.first().key)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learnModeToggledTriggersLearningWhenButtonIsClicked() = testScope.runTest {
|
|
||||||
val res = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "TV",
|
|
||||||
type = RemoteDeviceType.TV,
|
|
||||||
brandModel = "generic_tv",
|
|
||||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
|
||||||
)
|
|
||||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
|
||||||
|
|
||||||
val vm = createViewModel(remoteId.value)
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// 1. Toggle learn mode ON
|
|
||||||
vm.toggleLearnMode()
|
|
||||||
runCurrent()
|
|
||||||
assertTrue(vm.state.value.isLearnMode)
|
|
||||||
|
|
||||||
// 2. Click button -> triggers learn instead of send
|
|
||||||
vm.onButtonClick("TV_POWER")
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
|
||||||
assertEquals(0, gateway.sentKeys.size)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun addAndRemoveCustomButtonUpdatesBindings() = testScope.runTest {
|
|
||||||
val res = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Custom",
|
|
||||||
type = RemoteDeviceType.CUSTOM,
|
|
||||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
)
|
|
||||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
|
||||||
|
|
||||||
val vm = createViewModel(remoteId.value)
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Add button
|
|
||||||
vm.addCustomButton("Sound Effect")
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val added = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
|
|
||||||
assertNotNull(added)
|
|
||||||
|
|
||||||
// Delete button
|
|
||||||
vm.deleteButton(added!!.buttonId)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
val afterDelete = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
|
|
||||||
assertEquals(null, afterDelete)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun switchLayoutUpdatesRemoteLayoutStyle() = testScope.runTest {
|
|
||||||
val res = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Custom Box",
|
|
||||||
type = RemoteDeviceType.CUSTOM,
|
|
||||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
)
|
|
||||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
|
||||||
|
|
||||||
val vm = createViewModel(remoteId.value)
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.switchLayout(PanelLayoutStyle.TV_NAV)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(PanelLayoutStyle.TV_NAV, vm.state.value.remote?.layoutStyle)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest {
|
|
||||||
val res = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "AC",
|
|
||||||
type = RemoteDeviceType.AIR_CONDITIONER,
|
|
||||||
brandModel = "gree_ac",
|
|
||||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
)
|
|
||||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
|
||||||
|
|
||||||
val vm = createViewModel(remoteId.value)
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// 1. Click +1 temp (optimistic update to 27)
|
|
||||||
vm.adjustAcTemperature(1)
|
|
||||||
runCurrent()
|
|
||||||
assertEquals(27, vm.state.value.acState.temperature)
|
|
||||||
|
|
||||||
// 2. Click +1 temp again within debounce window (optimistic update to 28)
|
|
||||||
advanceTimeBy(100L)
|
|
||||||
vm.adjustAcTemperature(1)
|
|
||||||
runCurrent()
|
|
||||||
assertEquals(28, vm.state.value.acState.temperature)
|
|
||||||
|
|
||||||
// Advance debounce window to fire (300ms)
|
|
||||||
advanceTimeBy(400L)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
// Only 1 download and 1 transmission fired for final state 28
|
|
||||||
assertEquals(1, gateway.downloadedCodes.size)
|
|
||||||
assertEquals("100", gateway.downloadedCodes.first().key)
|
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
assertEquals("100", gateway.sentKeys.first().key)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-350
@@ -1,350 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class InfraredRemoteViewModelTest {
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
|
||||||
private val testScope = TestScope(testDispatcher)
|
|
||||||
|
|
||||||
private val profile = ProviderProfileId("abox")
|
|
||||||
private val device = DeviceName("ir-living")
|
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
|
||||||
|
|
||||||
private val keyRepo = FakeInfraredKeyRepository()
|
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
|
||||||
private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore()
|
|
||||||
private val gateway = FakeInfraredGateway()
|
|
||||||
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
|
|
||||||
|
|
||||||
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
|
|
||||||
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
|
|
||||||
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock)
|
|
||||||
private val send = SendInfraredKey(keyRepo, gateway)
|
|
||||||
private lateinit var coordinator: InfraredLearningCoordinator
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
Dispatchers.setMain(testDispatcher)
|
|
||||||
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun teardown() {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createViewModel(): InfraredRemoteViewModel {
|
|
||||||
return InfraredRemoteViewModel(
|
|
||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
|
||||||
keyRepository = keyRepo,
|
|
||||||
deviceConfigurationRepository = deviceRepo,
|
|
||||||
providerProfileStore = profileStore,
|
|
||||||
learningCoordinator = coordinator,
|
|
||||||
sendInfraredKey = send,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "空调-电源",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
assertEquals("1", gateway.sentKeys.first().key)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
|
||||||
assertEquals(null, vm.state.value.activeLearningSlot)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
vm.toggleLearningMode(false)
|
|
||||||
|
|
||||||
assertEquals(null, vm.state.value.activeLearningSlot)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "空调-电源",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val failedKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "空调-电源",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "20206",
|
|
||||||
lastErrorDetail = "offline",
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(failedKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
|
||||||
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
|
||||||
assertEquals(
|
|
||||||
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
|
|
||||||
vm.state.value.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learningFailurePropagatesErrorMessage() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
RemoteUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_offline, listOf("20206"))),
|
|
||||||
vm.state.value.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
vm.toggleLearningMode(false)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(null, vm.state.value.activeLearningSlot)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "空调-电源",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
|
||||||
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
|
||||||
assertEquals(
|
|
||||||
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
|
|
||||||
vm.state.value.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(profile, device)
|
|
||||||
val learningKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "空调-电源",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(learningKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
|
||||||
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
|
||||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
|
||||||
assertEquals(
|
|
||||||
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
|
|
||||||
vm.state.value.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest {
|
|
||||||
deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure")
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-21
@@ -1,21 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class RemoteModelsTest {
|
|
||||||
@Test
|
|
||||||
fun acSpecsHaveUniqueSlotsBetween1And20() {
|
|
||||||
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
|
|
||||||
assertEquals(slots.size, slots.distinct().size)
|
|
||||||
assertTrue(slots.all { it.toInt() in 1..20 })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tvSpecsHaveUniqueSlotsBetween21And50() {
|
|
||||||
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
|
|
||||||
assertEquals(slots.size, slots.distinct().size)
|
|
||||||
assertTrue(slots.all { it.toInt() in 21..50 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-149
@@ -1,149 +0,0 @@
|
|||||||
package com.flagship.abox.manager.infrared.ui
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfile
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.ProviderType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
|
||||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
|
||||||
import com.flagship.abox.manager.infrared.R
|
|
||||||
import com.flagship.abox.manager.infrared.remote.UiText
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
|
||||||
import kotlinx.coroutines.test.TestScope
|
|
||||||
import kotlinx.coroutines.test.advanceUntilIdle
|
|
||||||
import kotlinx.coroutines.test.resetMain
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import kotlinx.coroutines.test.setMain
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class InfraredControlViewModelTest {
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
|
||||||
private val testScope = TestScope(testDispatcher)
|
|
||||||
|
|
||||||
private val profileId = ProviderProfileId("profile1")
|
|
||||||
private val providerProfile = ProviderProfile(
|
|
||||||
id = profileId,
|
|
||||||
type = ProviderType.LOCAL_EXPERIENCE,
|
|
||||||
displayName = "Local Simulation",
|
|
||||||
)
|
|
||||||
private val device = DeviceName("ir-living")
|
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
|
||||||
|
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
|
||||||
private val remoteRepo = FakeVirtualRemoteRepository()
|
|
||||||
private val profileStore = FakeProviderProfileStore()
|
|
||||||
private val gateway = FakeInfraredGateway()
|
|
||||||
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
Dispatchers.setMain(testDispatcher)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun teardown() {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createViewModel(): InfraredControlViewModel {
|
|
||||||
return InfraredControlViewModel(
|
|
||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
|
||||||
deviceConfigurationRepository = deviceRepo,
|
|
||||||
virtualRemoteRepository = remoteRepo,
|
|
||||||
providerProfileStore = profileStore,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
|
||||||
assertEquals(device, vm.state.value.savedDeviceName)
|
|
||||||
assertEquals("ir-living", vm.state.value.deviceNameInput)
|
|
||||||
assertEquals(0, vm.state.value.totalRemotesCount)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun saveDeviceNameUpdatesDeviceConfiguration() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.onDeviceNameChanged("ir-bedroom")
|
|
||||||
vm.saveDeviceName()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(DeviceName("ir-bedroom"), vm.state.value.savedDeviceName)
|
|
||||||
assertEquals(
|
|
||||||
InfraredUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)),
|
|
||||||
vm.state.value.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun blankDeviceNameEmitsErrorMessage() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.onDeviceNameChanged(" ")
|
|
||||||
vm.saveDeviceName()
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
InfraredUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)),
|
|
||||||
vm.state.value.message,
|
|
||||||
)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest {
|
|
||||||
profileStore.saveProfile(providerProfile)
|
|
||||||
deviceRepo.setDeviceName(profileId, device)
|
|
||||||
createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Living TV",
|
|
||||||
type = RemoteDeviceType.TV,
|
|
||||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, vm.state.value.totalRemotesCount)
|
|
||||||
assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size)
|
|
||||||
assertEquals(16, vm.state.value.slotQuota.usedCount)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,4 +6,3 @@ plugins {
|
|||||||
alias(libs.plugins.kotlin.kapt) apply false
|
alias(libs.plugins.kotlin.kapt) apply false
|
||||||
alias(libs.plugins.hilt.android) apply false
|
alias(libs.plugins.hilt.android) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,589 +0,0 @@
|
|||||||
# Infrared Remote Control UI & Multi-Scene Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Build a realistic, multi-scene infrared remote control UI (Air Conditioner & Television) with learning/control mode toggle, fixed slot key bindings (1-100), graphic component modeling (circles, capsules, D-pad wheels), and settings navigation.
|
|
||||||
|
|
||||||
**Architecture:** Add scene models and predefined key specs in `:android:feature:infrared`. Implement `InfraredRemoteViewModel` to observe Room keys and coordinator actions. Build Compose panels for AC and TV remotes, and integrate the navigation route `infrared_remote/{profileId}` alongside `infrared_settings/{profileId}`.
|
|
||||||
|
|
||||||
**Tech Stack:** Kotlin 1.9.24, Jetpack Compose Material 3, Android Gradle Plugin 8.7.3, Room, StateFlow, Hilt, Coroutines.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-08-24-infrared-remote-ui-design.md`
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- Remote buttons display clean symbols/icons, never raw Chinese long names on the button faces.
|
|
||||||
- Every button maps to a dedicated 1-100 integer slot (AC: 1–20, TV: 21–50).
|
|
||||||
- Learning mode toggle activates visual indicator and switches button clicks from `sendKey` to `learnKey`.
|
|
||||||
- Settings navigation allows jumping to the existing `InfraredControlScreen` (renamed to Settings) to view raw entries and device names.
|
|
||||||
- Follow TDD with unit and Compose UI tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Define Remote Scenes and Key Specs
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt`
|
|
||||||
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Produces: `RemoteScene`, `RemoteButtonId`, `RemoteButtonSpec`, and `RemoteSceneConfig`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write test for predefined AC and TV remote button specifications**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class RemoteModelsTest {
|
|
||||||
@Test
|
|
||||||
fun acSpecsHaveUniqueSlotsBetween1And20() {
|
|
||||||
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
|
|
||||||
assertEquals(slots.size, slots.distinct().size)
|
|
||||||
assert(slots.all { it.toInt() in 1..20 })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun tvSpecsHaveUniqueSlotsBetween21And50() {
|
|
||||||
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
|
|
||||||
assertEquals(slots.size, slots.distinct().size)
|
|
||||||
assert(slots.all { it.toInt() in 21..50 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify failure**
|
|
||||||
|
|
||||||
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
|
|
||||||
Expected: Compilation failure.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `RemoteModels.kt`**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
enum class RemoteScene {
|
|
||||||
AIR_CONDITIONER,
|
|
||||||
TELEVISION,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RemoteButtonSpec(
|
|
||||||
val id: String,
|
|
||||||
val defaultSlot: String,
|
|
||||||
val displayName: String,
|
|
||||||
val symbol: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
object RemoteSceneConfig {
|
|
||||||
object AirConditioner {
|
|
||||||
val POWER = RemoteButtonSpec("AC_POWER", "1", "空调-电源", "⏻")
|
|
||||||
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", "空调-温度+", "▲")
|
|
||||||
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", "空调-温度-", "▼")
|
|
||||||
val MODE = RemoteButtonSpec("AC_MODE", "4", "空调-模式", "模式 ❄/☀")
|
|
||||||
val FAN = RemoteButtonSpec("AC_FAN", "5", "空调-风速", "风速 💨")
|
|
||||||
val SWING = RemoteButtonSpec("AC_SWING", "6", "空调-扫风", "扫风 ↔")
|
|
||||||
val TIMER = RemoteButtonSpec("AC_TIMER", "7", "空调-定时", "定时 ⏱")
|
|
||||||
|
|
||||||
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
|
|
||||||
}
|
|
||||||
|
|
||||||
object Television {
|
|
||||||
val POWER = RemoteButtonSpec("TV_POWER", "21", "电视-电源", "⏻")
|
|
||||||
val MUTE = RemoteButtonSpec("TV_MUTE", "22", "电视-静音", "🔇")
|
|
||||||
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", "电视-上", "▲")
|
|
||||||
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", "电视-下", "▼")
|
|
||||||
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", "电视-左", "◀")
|
|
||||||
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", "电视-右", "▶")
|
|
||||||
val OK = RemoteButtonSpec("TV_OK", "27", "电视-确定", "OK")
|
|
||||||
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", "电视-音量+", "VOL +")
|
|
||||||
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", "电视-音量-", "VOL -")
|
|
||||||
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", "电视-频道+", "CH +")
|
|
||||||
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", "电视-频道-", "CH -")
|
|
||||||
val HOME = RemoteButtonSpec("TV_HOME", "32", "电视-主页", "🏠")
|
|
||||||
val BACK = RemoteButtonSpec("TV_BACK", "33", "电视-返回", "↩")
|
|
||||||
val MENU = RemoteButtonSpec("TV_MENU", "34", "电视-菜单", "☰")
|
|
||||||
|
|
||||||
val buttons = listOf(
|
|
||||||
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
|
|
||||||
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.RemoteModelsTest"`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModels.kt android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/RemoteModelsTest.kt
|
|
||||||
git commit -m "feat: define infrared remote scenes and button specifications"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Implement Infrared Remote ViewModel
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModel.kt`
|
|
||||||
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteUiState.kt`
|
|
||||||
- Test: `android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteViewModelTest.kt`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `InfraredKeyRepository`, `InfraredDeviceConfigurationRepository`, `InfraredLearningCoordinator`, `SendInfraredKey`, `InfraredRuntimeModeStore`.
|
|
||||||
- Produces: `StateFlow<InfraredRemoteUiState>` with `onButtonClick`, `toggleLearningMode`, `switchScene`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing ViewModel test**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.*
|
|
||||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
|
||||||
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
|
|
||||||
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
|
|
||||||
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
|
||||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.*
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.*
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
private class TestModeStore : InfraredRuntimeModeStore {
|
|
||||||
private val modeFlow = MutableStateFlow(InfraredRuntimeMode.FAKE)
|
|
||||||
override val mode: Flow<InfraredRuntimeMode> = modeFlow
|
|
||||||
override suspend fun currentMode(): InfraredRuntimeMode = modeFlow.value
|
|
||||||
override suspend fun setMode(mode: InfraredRuntimeMode): DomainResult<Unit> {
|
|
||||||
modeFlow.value = mode
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
class InfraredRemoteViewModelTest {
|
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
|
||||||
private val testScope = TestScope(testDispatcher)
|
|
||||||
|
|
||||||
private val profile = ProviderProfileId("abox")
|
|
||||||
private val fakeProfile = ProviderProfileId("abox:infrared-fake")
|
|
||||||
private val device = DeviceName("ir-living")
|
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
|
||||||
|
|
||||||
private val keyRepo = FakeInfraredKeyRepository()
|
|
||||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
|
||||||
private val modeStore = TestModeStore()
|
|
||||||
private val resolver = InfraredRuntimeProfileResolver()
|
|
||||||
private val gateway = FakeInfraredGateway()
|
|
||||||
private val factory = InfraredKeyFactory({ InfraredKeyId("id-1") }, clock)
|
|
||||||
|
|
||||||
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
|
|
||||||
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
|
|
||||||
private val send = SendInfraredKey(keyRepo, gateway)
|
|
||||||
private lateinit var coordinator: InfraredLearningCoordinator
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
Dispatchers.setMain(testDispatcher)
|
|
||||||
coordinator = InfraredLearningCoordinator(learn, retry, testScope)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun teardown() {
|
|
||||||
Dispatchers.resetMain()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun createViewModel(): InfraredRemoteViewModel {
|
|
||||||
return InfraredRemoteViewModel(
|
|
||||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
|
||||||
keyRepository = keyRepo,
|
|
||||||
deviceConfigurationRepository = deviceRepo,
|
|
||||||
runtimeModeStore = modeStore,
|
|
||||||
profileResolver = resolver,
|
|
||||||
learningCoordinator = coordinator,
|
|
||||||
sendInfraredKey = send,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
|
||||||
val readyKey = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId("k1"),
|
|
||||||
profileId = fakeProfile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "空调-电源",
|
|
||||||
sdkKey = "1",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 100L,
|
|
||||||
updatedAtEpochMillis = 100L,
|
|
||||||
)
|
|
||||||
keyRepo.insert(readyKey)
|
|
||||||
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
assertEquals("1", gateway.sentKeys.first().key)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
|
|
||||||
deviceRepo.setDeviceName(fakeProfile, device)
|
|
||||||
val vm = createViewModel()
|
|
||||||
val job = launch { vm.state.collect {} }
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
vm.toggleLearningMode(true)
|
|
||||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
|
||||||
advanceUntilIdle()
|
|
||||||
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
assertEquals("1", gateway.learnedKeys.first().key)
|
|
||||||
|
|
||||||
job.cancel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify failure**
|
|
||||||
|
|
||||||
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
|
|
||||||
Expected: Compilation failure.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `InfraredRemoteUiState` and `InfraredRemoteViewModel`**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import androidx.lifecycle.SavedStateHandle
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.*
|
|
||||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
|
||||||
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
|
|
||||||
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeModeStore
|
|
||||||
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeProfileResolver
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
sealed interface RemoteUiMessage {
|
|
||||||
data class Info(val text: String) : RemoteUiMessage
|
|
||||||
data class Error(val text: String) : RemoteUiMessage
|
|
||||||
data object SessionInvalid : RemoteUiMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
data class InfraredRemoteUiState(
|
|
||||||
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
|
||||||
val isLearningMode: Boolean = false,
|
|
||||||
val runtimeMode: InfraredRuntimeMode = InfraredRuntimeMode.FAKE,
|
|
||||||
val effectiveProfileId: ProviderProfileId? = null,
|
|
||||||
val savedDeviceName: DeviceName? = null,
|
|
||||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
|
||||||
val sendingKeySlot: String? = null,
|
|
||||||
val activeLearningSlot: String? = null,
|
|
||||||
val message: RemoteUiMessage? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
|
||||||
@HiltViewModel
|
|
||||||
class InfraredRemoteViewModel @Inject constructor(
|
|
||||||
savedStateHandle: SavedStateHandle,
|
|
||||||
private val keyRepository: InfraredKeyRepository,
|
|
||||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
|
||||||
private val runtimeModeStore: InfraredRuntimeModeStore,
|
|
||||||
private val profileResolver: InfraredRuntimeProfileResolver,
|
|
||||||
private val learningCoordinator: InfraredLearningCoordinator,
|
|
||||||
private val sendInfraredKey: SendInfraredKey,
|
|
||||||
) : ViewModel() {
|
|
||||||
|
|
||||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
|
||||||
private val authenticatedProfileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
|
||||||
|
|
||||||
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
|
|
||||||
private val isLearningModeFlow = MutableStateFlow(false)
|
|
||||||
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
|
|
||||||
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
|
|
||||||
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
|
|
||||||
|
|
||||||
private val effectiveProfileAndMode = runtimeModeStore.mode.flatMapLatest { mode ->
|
|
||||||
if (authenticatedProfileId == null) {
|
|
||||||
flowOf(Pair(null, mode))
|
|
||||||
} else {
|
|
||||||
flowOf(Pair(profileResolver.resolve(authenticatedProfileId, mode), mode))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val deviceNameFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
|
||||||
if (profile == null) flowOf(DomainResult.Success(null))
|
|
||||||
else deviceConfigurationRepository.observeDeviceName(profile)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val keysFlow = effectiveProfileAndMode.flatMapLatest { (profile, _) ->
|
|
||||||
if (profile == null) flowOf(DomainResult.Success(emptyList()))
|
|
||||||
else {
|
|
||||||
deviceConfigurationRepository.observeDeviceName(profile).flatMapLatest { deviceResult ->
|
|
||||||
when (deviceResult) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
val device = deviceResult.value
|
|
||||||
if (device == null) flowOf(DomainResult.Success(emptyList()))
|
|
||||||
else keyRepository.observeKeys(profile, device)
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> flowOf(DomainResult.Failure(deviceResult.error))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val state: StateFlow<InfraredRemoteUiState> = combine(
|
|
||||||
sceneFlow,
|
|
||||||
isLearningModeFlow,
|
|
||||||
effectiveProfileAndMode,
|
|
||||||
deviceNameFlow,
|
|
||||||
keysFlow,
|
|
||||||
sendingKeySlotFlow,
|
|
||||||
activeLearningSlotFlow,
|
|
||||||
messageFlow,
|
|
||||||
) { scene, isLearning, profileAndMode, deviceResult, keysResult, sendingSlot, learningSlot, msg ->
|
|
||||||
val (profile, mode) = profileAndMode
|
|
||||||
val savedDevice = (deviceResult as? DomainResult.Success<DeviceName?>)?.value
|
|
||||||
val keys = (keysResult as? DomainResult.Success<List<SavedInfraredKey>>)?.value ?: emptyList()
|
|
||||||
val slotMap = keys.associateBy { it.sdkKey }
|
|
||||||
|
|
||||||
InfraredRemoteUiState(
|
|
||||||
selectedScene = scene,
|
|
||||||
isLearningMode = isLearning,
|
|
||||||
runtimeMode = mode,
|
|
||||||
effectiveProfileId = profile,
|
|
||||||
savedDeviceName = savedDevice,
|
|
||||||
learnedSlotMap = slotMap,
|
|
||||||
sendingKeySlot = sendingSlot,
|
|
||||||
activeLearningSlot = learningSlot,
|
|
||||||
message = msg,
|
|
||||||
)
|
|
||||||
}.stateIn(
|
|
||||||
scope = viewModelScope,
|
|
||||||
started = SharingStarted.WhileSubscribed(5_000),
|
|
||||||
initialValue = InfraredRemoteUiState(),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun switchScene(scene: RemoteScene) {
|
|
||||||
sceneFlow.value = scene
|
|
||||||
}
|
|
||||||
|
|
||||||
fun toggleLearningMode(enabled: Boolean) {
|
|
||||||
isLearningModeFlow.value = enabled
|
|
||||||
if (enabled) {
|
|
||||||
messageFlow.value = RemoteUiMessage.Info("🔴 学习录制模式已开启:点击任意按键即可开始录制")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onButtonClick(spec: RemoteButtonSpec) {
|
|
||||||
val currentProfile = state.value.effectiveProfileId ?: return
|
|
||||||
val savedDevice = state.value.savedDeviceName
|
|
||||||
if (savedDevice == null) {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error("未配置红外设备名,请点击右上角设置配置")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.value.isLearningMode) {
|
|
||||||
activeLearningSlotFlow.value = spec.defaultSlot
|
|
||||||
learningCoordinator.learn(
|
|
||||||
profileId = currentProfile,
|
|
||||||
deviceName = savedDevice,
|
|
||||||
displayName = spec.displayName,
|
|
||||||
sdkKey = spec.defaultSlot,
|
|
||||||
)
|
|
||||||
messageFlow.value = RemoteUiMessage.Info("正在录制【${spec.displayName}】(Slot ${spec.defaultSlot}),请按物理遥控器按键...")
|
|
||||||
} else {
|
|
||||||
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
|
||||||
if (key == null || key.status != InfraredKeyStatus.READY) {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error("按键【${spec.displayName}】尚未学习,请开启右上角学习模式进行录制")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sendingKeySlotFlow.value = spec.defaultSlot
|
|
||||||
viewModelScope.launch {
|
|
||||||
try {
|
|
||||||
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.Info("已发射: ${spec.symbol}")
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
handleDomainError(result.error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
sendingKeySlotFlow.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearMessage() {
|
|
||||||
messageFlow.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleDomainError(error: DomainError) {
|
|
||||||
when (error) {
|
|
||||||
is DomainError.TokenInvalid, is DomainError.Authentication -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.SessionInvalid
|
|
||||||
}
|
|
||||||
is DomainError.DeviceOffline -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error("红外控制器离线 (错误码: ${error.externalCode ?: "20206"})")
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
messageFlow.value = RemoteUiMessage.Error(error.detail ?: "操作失败")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `./gradlew :android:feature:infrared:testDebugUnitTest --tests "*.InfraredRemoteViewModelTest"`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote android/feature/infrared/src/test/kotlin/com/flagship/abox/manager/infrared/remote
|
|
||||||
git commit -m "feat: implement infrared remote control viewmodel"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Build Custom Remote Visual Components
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/RemoteComponents.kt`
|
|
||||||
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/AcRemotePanel.kt`
|
|
||||||
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/TvRemotePanel.kt`
|
|
||||||
- Create: `android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui/InfraredRemoteScreen.kt`
|
|
||||||
- Test: `android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote/InfraredRemoteScreenTest.kt`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Implement Compose visual elements**
|
|
||||||
- `RemoteCircleButton`, `VerticalCapsuleRocker`, `RemoteDpadWheel`, `AcLcdDisplay`.
|
|
||||||
- Compose panels for `AcRemotePanel` and `TvRemotePanel`.
|
|
||||||
- Assemble `InfraredRemoteScreen` with top learning switch and settings action.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Write Compose UI Test**
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
package com.flagship.abox.manager.infrared.remote
|
|
||||||
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class InfraredRemoteScreenTest {
|
|
||||||
@get:Rule
|
|
||||||
val composeTestRule = createComposeRule()
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rendersAcPanelAndTvPanelCorrectly() {
|
|
||||||
val state = InfraredRemoteUiState(
|
|
||||||
selectedScene = RemoteScene.AIR_CONDITIONER,
|
|
||||||
savedDeviceName = DeviceName("ir-1"),
|
|
||||||
)
|
|
||||||
var clickedSpec: RemoteButtonSpec? = null
|
|
||||||
|
|
||||||
composeTestRule.setContent {
|
|
||||||
InfraredRemoteScreen(
|
|
||||||
state = state,
|
|
||||||
onBack = {},
|
|
||||||
onOpenSettings = {},
|
|
||||||
onSessionInvalid = {},
|
|
||||||
onSceneSelected = {},
|
|
||||||
onToggleLearningMode = {},
|
|
||||||
onButtonClick = { clickedSpec = it },
|
|
||||||
onMessageShown = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
|
|
||||||
assertEquals("AC_POWER", clickedSpec?.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run compilation and tests**
|
|
||||||
|
|
||||||
Run: `./gradlew :android:feature:infrared:compileDebugAndroidTestKotlin :android:feature:infrared:testDebugUnitTest`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add android/feature/infrared/src/main/kotlin/com/flagship/abox/manager/infrared/remote/ui android/feature/infrared/src/androidTest/kotlin/com/flagship/abox/manager/infrared/remote
|
|
||||||
git commit -m "feat: add infrared remote control Compose screen and panels"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Integrate Navigation and App Flow
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `android/app/src/main/kotlin/com/flagship/abox/manager/ui/ABoxManagerApp.kt`
|
|
||||||
- Modify: `android/app/src/main/res/values/strings.xml`
|
|
||||||
- Modify: `android/app/src/main/res/values-zh-rCN/strings.xml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Wire Navigation**
|
|
||||||
- Route `infrared/{profileId}` now loads `InfraredRemoteRoute`.
|
|
||||||
- Add Route `infrared_settings/{profileId}` that loads `InfraredControlRoute` (the settings & raw list view).
|
|
||||||
- Top bar of `InfraredRemoteScreen` has ⚙️ button navigating to `infrared_settings/{profileId}`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run Full Verification Suite & Build APK**
|
|
||||||
|
|
||||||
Run: `./gradlew :domain:test :testing:fakes:test :absdk-bridge:test :android:feature:infrared:testDebugUnitTest :android:app:testDebugUnitTest :android:app:assembleDebug`
|
|
||||||
Expected: BUILD SUCCESSFUL.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit and Push**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add android/app android/feature/infrared
|
|
||||||
git commit -m "feat: wire infrared remote screen and settings navigation"
|
|
||||||
git push origin feat/moler-infrared-control
|
|
||||||
```
|
|
||||||
@@ -1,606 +0,0 @@
|
|||||||
# 红外学习与发射控制设计
|
|
||||||
|
|
||||||
## 1. 背景与目标
|
|
||||||
|
|
||||||
本项目当前可用于真实验证的设备包括 ABox 主机、红外控制器、智能插座和温湿度计。本阶段由 moler 负责红外能力,范围聚焦于红外控制器的基础闭环:
|
|
||||||
|
|
||||||
1. 在登录成功后的现有页面提供一个临时入口;
|
|
||||||
2. 手动配置并记住一个 ABSDK 红外设备名;
|
|
||||||
3. 创建并命名多个红外按键;
|
|
||||||
4. 通过 ABSDK 发起红外学习;
|
|
||||||
5. 保存可重复使用的按键绑定;
|
|
||||||
6. 通过 ABSDK 发射已学习按键;
|
|
||||||
7. 支持 Fake 和真实 ABSDK 调试模式;
|
|
||||||
8. 为后续生活场景保留稳定按键引用。
|
|
||||||
|
|
||||||
首版前端只用于功能验证,沿用现有 Compose 和设计系统基础组件,不定义最终产品视觉风格。后续统一视觉改版只替换展示层,不改变红外领域、持久化、应用用例或硬件接口。
|
|
||||||
|
|
||||||
## 2. 已确认的 ABSDK 能力边界
|
|
||||||
|
|
||||||
ABSDK 0630 提供以下红外公开方法:
|
|
||||||
|
|
||||||
```java
|
|
||||||
ABRet sendIr(String irDevName, String irKey)
|
|
||||||
ABRet studyIrByIrDevName(String irDevName, String irKey)
|
|
||||||
ABRet downloadIrCodes(List<IrInfo> irInfos)
|
|
||||||
```
|
|
||||||
|
|
||||||
现有领域层已将其隔离为 `InfraredGateway`:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
interface InfraredGateway {
|
|
||||||
suspend fun sendKey(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
key: String,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun learnKey(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
key: String,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun downloadCodes(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
codes: List<InfraredCode>,
|
|
||||||
): DomainResult<List<InfraredCodeDownloadResult>>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
本设计必须遵守以下事实:
|
|
||||||
|
|
||||||
- 学习接口只确认返回成功码和消息,没有已确认的原始红外码返回值;
|
|
||||||
- ABSDK 没有公开停止学习方法;
|
|
||||||
- ABSDK 没有公开获取学习结果或读取已学习原始码的方法;
|
|
||||||
- `downloadIrCodes()` 是把调用方已经拥有的红外码写入主机,不是读取学习结果;
|
|
||||||
- ABSDK 没有设备扫描或设备列表接口;
|
|
||||||
- 红外方法按 `irDevName + irKey` 寻址;
|
|
||||||
- SDK 调用为同步阻塞调用,现有 Bridge 在 `Dispatchers.IO` 上通过全局 `Mutex` 串行执行;
|
|
||||||
- SDK 使用固定明文 HTTP 目标,真实接入存在已知传输安全风险。
|
|
||||||
|
|
||||||
因此首版保存的是本地按键绑定,而不是原始红外波形或码值:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ProviderProfileId + DeviceName + sdkKey + displayName + learning status
|
|
||||||
```
|
|
||||||
|
|
||||||
真实硬件闭环成立的判据是:
|
|
||||||
|
|
||||||
```text
|
|
||||||
learnKey(deviceName, sdkKey) 成功
|
|
||||||
↓
|
|
||||||
sendKey(deviceName, sdkKey)
|
|
||||||
↓
|
|
||||||
实体被控设备产生正确响应
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. 范围
|
|
||||||
|
|
||||||
### 3.1 本阶段包含
|
|
||||||
|
|
||||||
- 红外功能的领域模型和本地持久化端口;
|
|
||||||
- Room 数据库、DAO 和 Repository;
|
|
||||||
- 学习、重试、发射、删除和恢复应用用例;
|
|
||||||
- 红外 ViewModel 和简单 Compose 页面;
|
|
||||||
- 登录成功页面的临时红外入口;
|
|
||||||
- 手动输入并记住一个 `irDevName`;
|
|
||||||
- 单红外控制器、多按键;
|
|
||||||
- Fake/Real ABSDK 显式调试模式;
|
|
||||||
- 自动化测试;
|
|
||||||
- 真机验收清单;
|
|
||||||
- 面向未来生活场景的稳定按键引用。
|
|
||||||
|
|
||||||
### 3.2 本阶段不包含
|
|
||||||
|
|
||||||
- 最终 UI 视觉规范或复杂遥控器布局;
|
|
||||||
- 多个遥控器管理界面;
|
|
||||||
- ABSDK 不存在的设备扫描;
|
|
||||||
- 原始红外码读取或持久化;
|
|
||||||
- 红外码库导入 UI;
|
|
||||||
- 硬件停止学习;
|
|
||||||
- 完整生活场景模型、规则引擎或场景执行器;
|
|
||||||
- Release 中默认启用真实 ABSDK;
|
|
||||||
- 绕过或隐藏厂商明文 HTTP 风险;
|
|
||||||
- 删除 ABox 服务端已学习按键。
|
|
||||||
|
|
||||||
## 4. 架构
|
|
||||||
|
|
||||||
采用独立应用编排层,保持现有 `InfraredGateway` 作为纯硬件能力端口:
|
|
||||||
|
|
||||||
```text
|
|
||||||
登录成功页面
|
|
||||||
└─ 临时入口:红外控制
|
|
||||||
↓
|
|
||||||
InfraredControlScreen
|
|
||||||
↓
|
|
||||||
InfraredControlViewModel
|
|
||||||
↓
|
|
||||||
应用用例
|
|
||||||
├─ LearnInfraredKey
|
|
||||||
├─ RetryInfraredKeyLearning
|
|
||||||
├─ SendInfraredKey
|
|
||||||
├─ DeleteInfraredKey
|
|
||||||
├─ RecoverInterruptedInfraredLearning
|
|
||||||
└─ ObserveInfraredKeys
|
|
||||||
↙ ↘
|
|
||||||
InfraredKeyRepository InfraredGateway
|
|
||||||
↓ ├─ FakeInfraredGateway
|
|
||||||
RoomInfraredKeyRepository └─ AbsdkBridge
|
|
||||||
↓
|
|
||||||
ABSDK 0630
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4.1 边界原则
|
|
||||||
|
|
||||||
- `InfraredGateway` 只负责学习、发射和码库下载,不负责 Room、页面状态或遥控器管理;
|
|
||||||
- 不修改 `learnKey()` 返回类型,不虚构 ABSDK 原始码结果;
|
|
||||||
- 不把 `ABSDK`、`ABRet`、`IrInfo` 或原始 Map 暴露给上层;
|
|
||||||
- ViewModel 不直接编排数据库和硬件之间的一致性,统一通过应用用例;
|
|
||||||
- Room Entity 不作为领域或 UI 公共模型;
|
|
||||||
- Fake 和真实 Bridge 必须共用相同的领域接口和用例;
|
|
||||||
- 所有真实 Gateway 接口必须指向同一个进程级 `AbsdkBridge` 实例,保持全局会话和互斥锁一致。
|
|
||||||
|
|
||||||
## 5. 数据模型
|
|
||||||
|
|
||||||
### 5.1 稳定标识
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
@JvmInline
|
|
||||||
value class InfraredKeyId(val value: String)
|
|
||||||
|
|
||||||
data class InfraredKeyRef(
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val keyId: InfraredKeyId,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
`InfraredKeyRef` 是未来生活场景使用的最小稳定引用。场景不应保存显示名、设备名或厂商 Key。
|
|
||||||
|
|
||||||
### 5.2 学习状态
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
enum class InfraredKeyStatus {
|
|
||||||
LEARNING,
|
|
||||||
READY,
|
|
||||||
FAILED,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
状态表示 App 对最近一次学习调用结果的本地记录,不代表设备主动上报的实时状态。
|
|
||||||
|
|
||||||
### 5.3 领域模型
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
data class SavedInfraredKey(
|
|
||||||
val id: InfraredKeyId,
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val deviceName: DeviceName,
|
|
||||||
val displayName: String,
|
|
||||||
val sdkKey: String,
|
|
||||||
val status: InfraredKeyStatus,
|
|
||||||
val lastErrorCode: String?,
|
|
||||||
val createdAtEpochMillis: Long,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
字段约束:
|
|
||||||
|
|
||||||
- `id` 在 App 内稳定,供页面、删除和未来生活场景引用;
|
|
||||||
- `profileId` 用于不同登录配置的数据隔离;
|
|
||||||
- `deviceName` 是传给 ABSDK 的真实红外设备名;
|
|
||||||
- `displayName` 是用户可见名称,可以修改;
|
|
||||||
- `sdkKey` 是传给 ABSDK 的稳定 Key,学习后不随显示名改变;
|
|
||||||
- `lastErrorCode` 仅保存脱敏厂商错误码,不保存密码、Token、原始 Map 或红外载荷;
|
|
||||||
- 时间字段由应用层时钟提供,测试中使用可控时钟。
|
|
||||||
|
|
||||||
### 5.4 SDK Key
|
|
||||||
|
|
||||||
App 自动生成 SDK Key,不允许用户直接编辑。格式应:
|
|
||||||
|
|
||||||
- 仅包含安全 ASCII 字符;
|
|
||||||
- 在同一 Provider 和红外设备下唯一;
|
|
||||||
- 重试学习时复用;
|
|
||||||
- 修改显示名时保持不变;
|
|
||||||
- 不直接使用中文、空格或特殊字符。
|
|
||||||
|
|
||||||
建议格式:
|
|
||||||
|
|
||||||
```text
|
|
||||||
app_ir_<normalized-infrared-key-id>
|
|
||||||
```
|
|
||||||
|
|
||||||
实际长度上限尚未由 ABSDK 文档确认,实现前需选择保守长度,并列入真机验证。
|
|
||||||
|
|
||||||
## 6. 本地持久化
|
|
||||||
|
|
||||||
采用 Room。Repository 边界至少支持:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
interface InfraredKeyRepository {
|
|
||||||
fun observeKeys(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<SavedInfraredKey>>>
|
|
||||||
|
|
||||||
suspend fun getKey(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
): DomainResult<SavedInfraredKey?>
|
|
||||||
|
|
||||||
suspend fun insertLearningKey(
|
|
||||||
key: SavedInfraredKey,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun updateStatus(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
status: InfraredKeyStatus,
|
|
||||||
lastErrorCode: String?,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun renameKey(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
displayName: String,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun deleteKey(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun findInterruptedLearning(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
): DomainResult<List<SavedInfraredKey>>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
具体签名可在实施计划中按项目既有风格调整,但必须保持职责和语义。
|
|
||||||
|
|
||||||
### 6.1 数据库约束
|
|
||||||
|
|
||||||
- 主键:`InfraredKeyId`;
|
|
||||||
- 索引:`ProviderProfileId + DeviceName`;
|
|
||||||
- 唯一约束:`ProviderProfileId + DeviceName + sdkKey`;
|
|
||||||
- 查询和删除必须包含 Provider 隔离;
|
|
||||||
- 不保存原始红外码;
|
|
||||||
- 不把 Room Entity 暴露给领域层;
|
|
||||||
- 首版预留 schema version 和迁移测试基础。
|
|
||||||
|
|
||||||
### 6.2 单设备策略
|
|
||||||
|
|
||||||
页面记住一个当前 `irDevName`,并只展示该设备下的按键。切换当前设备名不会迁移或重写旧按键;旧按键仍绑定学习时使用的设备名。
|
|
||||||
|
|
||||||
设备名可以保存到 Room 配置表或 DataStore。若采用 DataStore,只用于当前设备名和 debug 模式等轻量配置,不承载红外按键集合。
|
|
||||||
|
|
||||||
## 7. 学习流程
|
|
||||||
|
|
||||||
`LearnInfraredKey` 的流程:
|
|
||||||
|
|
||||||
1. 验证用户已登录且存在有效 `ProviderProfileId`;
|
|
||||||
2. 验证 `deviceName` 和 `displayName` 非空;
|
|
||||||
3. 生成稳定 `InfraredKeyId` 和 `sdkKey`;
|
|
||||||
4. 先写入状态为 `LEARNING` 的记录;
|
|
||||||
5. 在应用级协程作用域调用 `InfraredGateway.learnKey()`;
|
|
||||||
6. 成功时更新为 `READY` 并清空错误码;
|
|
||||||
7. 失败时更新为 `FAILED` 并保存脱敏错误码;
|
|
||||||
8. 如果状态更新失败,记录可恢复的本地数据错误,但不重新生成 SDK Key;
|
|
||||||
9. 页面通过 Room Flow 观察最终状态。
|
|
||||||
|
|
||||||
### 7.1 重试
|
|
||||||
|
|
||||||
重试必须:
|
|
||||||
|
|
||||||
- 使用原 `InfraredKeyId`;
|
|
||||||
- 使用原 `sdkKey`;
|
|
||||||
- 先将状态更新为 `LEARNING`;
|
|
||||||
- 再调用 ABSDK;
|
|
||||||
- 不创建重复按键记录。
|
|
||||||
|
|
||||||
### 7.2 页面离开
|
|
||||||
|
|
||||||
学习调用开始后,页面可以退出。任务继续在应用级作用域等待 SDK 返回,并更新 Room。ViewModel 销毁不应直接取消已经提交的学习编排。
|
|
||||||
|
|
||||||
这不是后台系统任务保证:如果进程被系统杀死,底层同步网络请求及设备状态无法可靠恢复。
|
|
||||||
|
|
||||||
### 7.3 进程恢复
|
|
||||||
|
|
||||||
App 启动或登录恢复后,查找遗留的 `LEARNING` 记录:
|
|
||||||
|
|
||||||
```text
|
|
||||||
LEARNING → FAILED
|
|
||||||
错误提示:上次学习结果未确认,请重新学习
|
|
||||||
```
|
|
||||||
|
|
||||||
不得自动标记为 `READY`,也不得自动发射来试探设备状态。
|
|
||||||
|
|
||||||
### 7.4 取消语义
|
|
||||||
|
|
||||||
ABSDK 没有停止学习 API。首版 UI 不提供或宣称“停止硬件学习”。用户可以离开页面,但学习调用仍等待成功、失败或 SDK 超时。
|
|
||||||
|
|
||||||
## 8. 发射流程
|
|
||||||
|
|
||||||
`SendInfraredKey` 的流程:
|
|
||||||
|
|
||||||
1. 使用 `InfraredKeyRef` 查询本地记录;
|
|
||||||
2. 记录不存在时返回领域错误;
|
|
||||||
3. 仅允许 `READY` 状态发射;
|
|
||||||
4. 取得记录中的 `profileId + deviceName + sdkKey`;
|
|
||||||
5. 调用 `InfraredGateway.sendKey()`;
|
|
||||||
6. 成功时向 UI 返回一次性成功事件;
|
|
||||||
7. 失败时保留按键 `READY` 状态,仅报告本次错误;
|
|
||||||
8. 不自动重试,避免重复操作电视、空调等实体设备。
|
|
||||||
|
|
||||||
未来生活场景通过稳定 `InfraredKeyRef` 调用同一发送用例或薄执行器:
|
|
||||||
|
|
||||||
```kotlin
|
|
||||||
interface InfraredActionExecutor {
|
|
||||||
suspend fun execute(ref: InfraredKeyRef): DomainResult<Unit>
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
本阶段只定义或保证稳定引用边界,不实现 Scene 模型、规则引擎或场景执行顺序。
|
|
||||||
|
|
||||||
## 9. 删除与重命名
|
|
||||||
|
|
||||||
### 9.1 删除
|
|
||||||
|
|
||||||
- `FAILED` 和 `READY` 记录可以删除;
|
|
||||||
- `LEARNING` 中不允许删除,避免后台结果写入已不存在的记录;
|
|
||||||
- 删除只移除本地引用;
|
|
||||||
- ABSDK 没有删除服务端红外 Key 的公开接口;
|
|
||||||
- UI 必须提示“ABox 服务端可能仍保留该按键”;
|
|
||||||
- 删除后未来场景引用解析失败,不再发射。
|
|
||||||
|
|
||||||
### 9.2 重命名
|
|
||||||
|
|
||||||
- 只修改 `displayName`;
|
|
||||||
- 不修改 `id`、`deviceName` 或 `sdkKey`;
|
|
||||||
- 重命名后学习和发射能力保持不变;
|
|
||||||
- 场景引用保持有效。
|
|
||||||
|
|
||||||
## 10. Android 前端
|
|
||||||
|
|
||||||
### 10.1 临时入口
|
|
||||||
|
|
||||||
在当前登录成功后的页面添加“红外控制”按钮,导航到独立 `InfraredControlScreen`。未来正式信息架构完成后只迁移入口。
|
|
||||||
|
|
||||||
### 10.2 页面内容
|
|
||||||
|
|
||||||
首版页面只包含:
|
|
||||||
|
|
||||||
- 返回按钮和标题;
|
|
||||||
- 当前数据源标识:Fake 或真实 ABox;
|
|
||||||
- 红外控制器设备名输入和保存;
|
|
||||||
- 已学习按键列表;
|
|
||||||
- 学习新按键按钮;
|
|
||||||
- 按键状态;
|
|
||||||
- READY 按键的发射按钮;
|
|
||||||
- FAILED 按键的重试按钮;
|
|
||||||
- 删除入口和确认提示;
|
|
||||||
- 基础错误反馈。
|
|
||||||
|
|
||||||
不加入复杂遥控器模板、图标系统、网格布局、品牌色或动画规范。
|
|
||||||
|
|
||||||
### 10.3 设备名
|
|
||||||
|
|
||||||
- ABSDK 没有设备扫描能力;
|
|
||||||
- 首次进入时手动填写 `irDevName`;
|
|
||||||
- 保存后本地记住;
|
|
||||||
- 没有设备名时禁止学习;
|
|
||||||
- 修改设备名前提示旧按键仍绑定旧设备;
|
|
||||||
- 列表只显示当前设备名下的按键。
|
|
||||||
|
|
||||||
### 10.4 学习交互
|
|
||||||
|
|
||||||
用户输入显示名称后开始学习。页面提示:
|
|
||||||
|
|
||||||
```text
|
|
||||||
请将实体遥控器对准红外控制器并按下目标按键。
|
|
||||||
```
|
|
||||||
|
|
||||||
按键显示 `LEARNING`、`READY` 或 `FAILED`。允许离开页面,但不显示“停止硬件学习”。
|
|
||||||
|
|
||||||
### 10.5 发射交互
|
|
||||||
|
|
||||||
- 只有 `READY` 按键可发射;
|
|
||||||
- 发射期间禁用该按键,防止重复点击;
|
|
||||||
- 成功显示短暂提示;
|
|
||||||
- 失败显示具体错误;
|
|
||||||
- 不自动重试;
|
|
||||||
- 单次失败不删除或降级按键状态。
|
|
||||||
|
|
||||||
### 10.6 删除提示
|
|
||||||
|
|
||||||
删除确认文案应明确:
|
|
||||||
|
|
||||||
```text
|
|
||||||
仅从本机列表移除,ABox 服务端可能仍保留该按键。
|
|
||||||
```
|
|
||||||
|
|
||||||
## 11. Debug Fake/Real 路由
|
|
||||||
|
|
||||||
Debug 构建提供显式运行模式:
|
|
||||||
|
|
||||||
- `FAKE`:页面开发、自动化测试和错误模拟;
|
|
||||||
- `REAL_ABSDK`:复用当前登录会话,调用真实 `AbsdkBridge`。
|
|
||||||
|
|
||||||
约束:
|
|
||||||
|
|
||||||
- 页面持续显示当前模式;
|
|
||||||
- 不允许在学习任务进行中切换模式;
|
|
||||||
- Fake 和 Real 共用同一 ViewModel、应用用例和 Room Repository;
|
|
||||||
- 模式和 Provider 数据必须隔离,避免 Fake 按键误用于真实设备;
|
|
||||||
- Fake 支持按 `ProviderProfileId + DeviceName + sdkKey` 配置成功或失败;
|
|
||||||
- 真实模式的所有 Gateway 接口绑定同一 `AbsdkBridge` 单例;
|
|
||||||
- 真实模式必须显式 opt in `CleartextAbsdkTransport`;
|
|
||||||
- Release 暂不启用真实 Bridge,除非项目明确批准传输安全方案。
|
|
||||||
|
|
||||||
真实 App 接入还必须补齐:
|
|
||||||
|
|
||||||
- `:absdk-bridge` 依赖;
|
|
||||||
- Hilt 绑定;
|
|
||||||
- `INTERNET` 权限;
|
|
||||||
- 经批准的明文网络配置;
|
|
||||||
- 登录会话与红外 Gateway 的同一 Bridge 实例;
|
|
||||||
- Token 失效后的页面返回登录流程。
|
|
||||||
|
|
||||||
## 12. 错误处理
|
|
||||||
|
|
||||||
继续使用现有 `DomainResult` 和 `DomainError`。不为首版扩充 sealed 类型,优先利用已有分类和 `externalCode`。
|
|
||||||
|
|
||||||
| 情况 | UI 反馈 | 本地状态 |
|
|
||||||
|---|---|---|
|
|
||||||
| Token 无效 | 登录已失效,返回登录 | 学习按键为 FAILED;发射按键仍为 READY |
|
|
||||||
| 红外设备不存在 | 检查设备名 | 学习按键为 FAILED |
|
|
||||||
| 红外设备离线 | 检查供电和网络 | 学习按键为 FAILED;发射按键仍为 READY |
|
|
||||||
| KEY/码值不存在 | 建议重新学习 | 发射按键仍为 READY,由用户决定重学 |
|
|
||||||
| 红外发射失败 | 显示失败,可手动重试 | READY |
|
|
||||||
| 网络超时 | 网络或 ABox 服务不可用 | 学习按键为 FAILED;发射按键仍为 READY |
|
|
||||||
| 本地保存失败 | 无法开始学习 | 不调用硬件学习 |
|
|
||||||
| 学习成功但最终状态写入失败 | 显示数据保存异常并保留恢复信息 | 不生成新 sdkKey |
|
|
||||||
| 未知错误 | 通用提示及脱敏错误码 | 按操作类型处理 |
|
|
||||||
|
|
||||||
日志不得包含:
|
|
||||||
|
|
||||||
- 用户密码;
|
|
||||||
- Token;
|
|
||||||
- 原始 SDK Map;
|
|
||||||
- 原始红外码或载荷;
|
|
||||||
- 可用于重放的敏感字段。
|
|
||||||
|
|
||||||
## 13. 自动化测试
|
|
||||||
|
|
||||||
### 13.1 应用用例
|
|
||||||
|
|
||||||
覆盖:
|
|
||||||
|
|
||||||
1. 学习前先保存 `LEARNING`;
|
|
||||||
2. 学习成功更新为 `READY`;
|
|
||||||
3. 学习失败更新为 `FAILED`;
|
|
||||||
4. 重试复用原 `sdkKey`;
|
|
||||||
5. 修改显示名不改变 `sdkKey`;
|
|
||||||
6. 只有 `READY` 可以发射;
|
|
||||||
7. 发射使用保存的 `deviceName + sdkKey`;
|
|
||||||
8. 不同 Provider 数据隔离;
|
|
||||||
9. 遗留 `LEARNING` 恢复为 `FAILED`;
|
|
||||||
10. 删除后稳定引用失效;
|
|
||||||
11. ViewModel 销毁后应用级学习任务仍可落库;
|
|
||||||
12. 发射失败不改变 READY;
|
|
||||||
13. 进行中的学习阻止运行模式切换。
|
|
||||||
|
|
||||||
### 13.2 Room
|
|
||||||
|
|
||||||
覆盖:
|
|
||||||
|
|
||||||
- 新增、观察、查询、重命名和删除;
|
|
||||||
- `ProviderProfileId + DeviceName` 过滤;
|
|
||||||
- `sdkKey` 唯一约束;
|
|
||||||
- Provider 隔离;
|
|
||||||
- 状态转换;
|
|
||||||
- 稳定 ID 跨重命名不变;
|
|
||||||
- 首版 schema 和迁移测试基础;
|
|
||||||
- 不存储原始红外载荷。
|
|
||||||
|
|
||||||
### 13.3 Fake
|
|
||||||
|
|
||||||
扩展现有 Fake,覆盖:
|
|
||||||
|
|
||||||
- 按 Provider、设备和 Key 脚本化结果;
|
|
||||||
- 调用参数与顺序;
|
|
||||||
- 连续响应;
|
|
||||||
- 成功、离线、超时和通用失败;
|
|
||||||
- 不同 Provider 互不影响。
|
|
||||||
|
|
||||||
### 13.4 Bridge
|
|
||||||
|
|
||||||
保留现有覆盖并补充:
|
|
||||||
|
|
||||||
- 学习参数和调用顺序;
|
|
||||||
- 发射参数和调用顺序;
|
|
||||||
- 红外错误码 `20201` 至 `20206` 的映射;
|
|
||||||
- 学习、发射与其他设备调用的全局串行;
|
|
||||||
- Token 失效清理会话;
|
|
||||||
- SDK 空值和异常防御;
|
|
||||||
- 不改变 `downloadCodes()` 的部分成功语义。
|
|
||||||
|
|
||||||
### 13.5 ViewModel 与 Compose
|
|
||||||
|
|
||||||
只验证功能,不验证最终视觉:
|
|
||||||
|
|
||||||
- 无设备名不能学习;
|
|
||||||
- 保存和恢复设备名;
|
|
||||||
- 创建学习任务;
|
|
||||||
- 显示学习中、成功和失败;
|
|
||||||
- 重试学习;
|
|
||||||
- 点击发射并防重复点击;
|
|
||||||
- 删除确认;
|
|
||||||
- Fake/Real 模式标识;
|
|
||||||
- 登录失效引导;
|
|
||||||
- 基本中文和英文资源。
|
|
||||||
|
|
||||||
## 14. 真机验收
|
|
||||||
|
|
||||||
第一轮使用现有 ABox 主机和红外控制器:
|
|
||||||
|
|
||||||
1. 登录真实账号;
|
|
||||||
2. 输入后台提供的准确 `irDevName`;
|
|
||||||
3. 创建唯一按键,例如“测试电源”;
|
|
||||||
4. 调用学习并记录耗时、返回码和消息;
|
|
||||||
5. 学习成功后立即发射;
|
|
||||||
6. 验证实体电器是否响应;
|
|
||||||
7. 用同一按键重复发射;
|
|
||||||
8. 重新学习同一 `sdkKey`,验证覆盖或报错行为;
|
|
||||||
9. 红外控制器断电后验证离线错误;
|
|
||||||
10. 不按实体遥控器,验证实际超时行为;
|
|
||||||
11. 页面退出后验证后台结果落库;
|
|
||||||
12. 进程被杀后验证遗留 LEARNING 恢复;
|
|
||||||
13. Token 失效后验证返回登录;
|
|
||||||
14. 记录 SDK 是否存在未文档化 `dicDatas`,但不将内容写入生产日志。
|
|
||||||
|
|
||||||
### 14.1 必须验证的未知项
|
|
||||||
|
|
||||||
- 学习调用是立即返回还是阻塞到捕获完成;
|
|
||||||
- 学习成功是否自动绑定 `sdkKey`;
|
|
||||||
- 同一 `sdkKey` 重学是否覆盖;
|
|
||||||
- 无信号时实际超时时间;
|
|
||||||
- 页面离开后底层调用是否可完成;
|
|
||||||
- 设备名格式、大小写和特殊字符规则;
|
|
||||||
- 服务端是否永久保留已学习 Key;
|
|
||||||
- SDK 是否返回未文档化数据;
|
|
||||||
- `sdkKey` 的长度限制;
|
|
||||||
- 真实模式所需初始化信息和调用顺序。
|
|
||||||
|
|
||||||
这些未知项不得通过业务层假设隐藏。真机结果应更新协议文档和后续设计。
|
|
||||||
|
|
||||||
## 15. 验收标准
|
|
||||||
|
|
||||||
本阶段完成需要满足:
|
|
||||||
|
|
||||||
- 登录成功页面可以进入简单红外页面;
|
|
||||||
- 可以手动配置并恢复一个红外设备名;
|
|
||||||
- 可以创建多个命名按键;
|
|
||||||
- 学习调用前已持久化 LEARNING;
|
|
||||||
- 学习成功或失败能正确落库;
|
|
||||||
- READY 按键可以调用发射;
|
|
||||||
- 显示名修改不影响发射;
|
|
||||||
- Fake 模式可以覆盖主要状态和错误;
|
|
||||||
- Real 模式明确标识并调用真实 Bridge;
|
|
||||||
- 页面退出后学习任务仍可更新状态;
|
|
||||||
- 进程中断后遗留学习状态可以保守恢复;
|
|
||||||
- 自动化测试通过;
|
|
||||||
- 真机至少完成一次“学习成功 → 发射 → 实体响应”;
|
|
||||||
- 未确认硬件语义被记录,不伪装为已支持;
|
|
||||||
- 未来生活场景可以通过稳定 `InfraredKeyRef` 引用按键。
|
|
||||||
|
|
||||||
## 16. 后续工作
|
|
||||||
|
|
||||||
本设计完成后再分别规划:
|
|
||||||
|
|
||||||
1. 多遥控器和房间组织;
|
|
||||||
2. 统一产品视觉和正式导航入口;
|
|
||||||
3. 红外码库导入;
|
|
||||||
4. 生活场景编排;
|
|
||||||
5. Scene 顺序执行和单项结果;
|
|
||||||
6. 厂商明文 HTTP 的正式安全方案;
|
|
||||||
7. 真机验证后对学习语义、超时和错误模型的修订。
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
# 红外遥控器界面与多场景控制设计规范 / Infrared Remote Control UI Specification
|
|
||||||
|
|
||||||
## 1. 背景与目标 (Background & Goals)
|
|
||||||
|
|
||||||
在完成了底层红外学习、发射、Room 数据库、1~100 整数 Key 槽位管理以及 Fake/Real ABSDK 路由的基础上,本项目需要提供专业且直观的前端遥控器人机交互界面。
|
|
||||||
|
|
||||||
### 核心目标
|
|
||||||
1. **多场景遥控面板**:提供空调 (Air Conditioner) 与电视 (Television) 两大经典场景遥控器界面,使用圆形、圆角矩形、胶囊、圆形方向盘等拟物/现代图形组件构建真实遥控器外观。
|
|
||||||
2. **学习模式与控制模式切换**:右上角提供“红外学习模式”开关,在控制模式下点击按键发射红外,在学习模式下点击按键录制红外。
|
|
||||||
3. **固定槽位绑定 (Fixed Slot Binding)**:遥控器上的每一个功能按键具有预设唯一的 1~100 整数槽位编号(例如空调电源为 Slot 1,电视电源为 Slot 21)。
|
|
||||||
4. **简洁的符号化设计**:遥控器按键表面只展示标准图标与简写符号(⏻, +, -, ∧, ∨, ❄, 🔊 等),不展示底层数据库中文长名称。
|
|
||||||
5. **设置页面解耦与导航**:原“红外控制页面”定位为“红外学习设置页面”(Infrared Settings),通过遥控器右上角设置图标 ⚙️ 进入,用于配置红外设备名和管理全部已学按键列表。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 槽位与按键分配定义 (Slot & Button Mapping)
|
|
||||||
|
|
||||||
每个按键定义为一个 `RemoteButtonSpec`,包含按键唯一标识 `id`、对应默认槽位 `defaultSlot`、默认显示名称 `displayName`、图标及在遥控器面板中的图形位置。
|
|
||||||
|
|
||||||
### 2.1 空调遥控器 (Air Conditioner: Slots 1–20)
|
|
||||||
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `AC_POWER` | 电源 ⏻ | `1` | `空调-电源` | 顶部圆形独立红色高亮按键 |
|
|
||||||
| `AC_TEMP_UP` | 温度+ 🔼 | `2` | `空调-温度+` | 垂直胶囊上半部分 |
|
|
||||||
| `AC_TEMP_DOWN` | 温度- 🔽 | `3` | `空调-温度-` | 垂直胶囊下半部分 |
|
|
||||||
| `AC_MODE` | 模式 ❄/☀ | `4` | `空调-模式` | 圆角矩形 |
|
|
||||||
| `AC_FAN` | 风速 💨 | `5` | `空调-风速` | 圆角矩形 |
|
|
||||||
| `AC_SWING` | 扫风 ↔️ | `6` | `空调-扫风` | 圆角矩形 |
|
|
||||||
| `AC_TIMER` | 定时 ⏱️ | `7` | `空调-定时` | 圆角矩形 |
|
|
||||||
|
|
||||||
### 2.2 电视遥控器 (Television: Slots 21–50)
|
|
||||||
| 按键 ID | 功能/符号 | 槽位 (irKey) | 默认名称 | UI 形态 |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| `TV_POWER` | 电源 ⏻ | `21` | `电视-电源` | 顶部圆形红色按键 |
|
|
||||||
| `TV_MUTE` | 静音 🔇 | `22` | `电视-静音` | 顶部圆形灰色按键 |
|
|
||||||
| `TV_NAV_UP` | 方向上 🔼 | `23` | `电视-上` | 方向环形盘上弧段 |
|
|
||||||
| `TV_NAV_DOWN` | 方向下 🔽 | `24` | `电视-下` | 方向环形盘下弧段 |
|
|
||||||
| `TV_NAV_LEFT` | 方向左 ◀️ | `25` | `电视-左` | 方向环形盘左弧段 |
|
|
||||||
| `TV_NAV_RIGHT` | 方向右 ▶️ | `26` | `电视-右` | 方向环形盘右弧段 |
|
|
||||||
| `TV_OK` | 确认 🆗 | `27` | `电视-确定` | 方向盘中心圆形 |
|
|
||||||
| `TV_VOL_UP` | 音量+ 🔊+ | `28` | `电视-音量+` | 垂直胶囊上半部分 |
|
|
||||||
| `TV_VOL_DOWN` | 音量- 🔉- | `29` | `电视-音量-` | 垂直胶囊下半部分 |
|
|
||||||
| `TV_CH_UP` | 频道+ 📺+ | `30` | `电视-频道+` | 垂直胶囊上半部分 |
|
|
||||||
| `TV_CH_DOWN` | 频道- 📺- | `31` | `电视-频道-` | 垂直胶囊下半部分 |
|
|
||||||
| `TV_HOME` | 主页 🏠 | `32` | `电视-主页` | 中部圆角胶囊按键 |
|
|
||||||
| `TV_BACK` | 返回 ↩️ | `33` | `电视-返回` | 中部圆角胶囊按键 |
|
|
||||||
| `TV_MENU` | 菜单 ☰ | `34` | `电视-菜单` | 中部圆角胶囊按键 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 架构与组件设计 (Architecture & Component Design)
|
|
||||||
|
|
||||||
### 3.1 导航结构
|
|
||||||
```text
|
|
||||||
FoundationScreen (首页/网关登录)
|
|
||||||
↓ 点击 "红外控制" (infrared/{profileId})
|
|
||||||
InfraredRemoteScreen (遥控器主界面,默认空调/电视)
|
|
||||||
├─ 学习模式开关 (Toggle: Learning Mode)
|
|
||||||
├─ 场景切换器 (Tab: ❄️ 空调 | 📺 电视)
|
|
||||||
├─ 空调遥控器面板 (AcRemotePanel)
|
|
||||||
├─ 电视遥控器面板 (TvRemotePanel)
|
|
||||||
└─ 点击右上角 ⚙️ 设置图标
|
|
||||||
↓ (infrared_settings/{profileId})
|
|
||||||
InfraredSettingsScreen (原红外设置/按键管理列表页面)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3.2 状态管理 (`InfraredRemoteViewModel` & `InfraredRemoteUiState`)
|
|
||||||
- `InfraredRemoteUiState`:
|
|
||||||
- `selectedScene`: `RemoteScene.AIR_CONDITIONER` 或 `RemoteScene.TELEVISION`
|
|
||||||
- `isLearningMode`: `Boolean`(是否处于学习录制模式)
|
|
||||||
- `activeLearningKeySlot`: `String?`(当前正在进行学习请求的按键槽位)
|
|
||||||
- `sendingKeySlot`: `String?`(当前正在发射信号的按键槽位)
|
|
||||||
- `learnedSlots`: `Map<String, SavedInfraredKey>`(已在 Room 数据库中记录为 READY 的槽位映射)
|
|
||||||
- `savedDeviceName`: `DeviceName?`(当前配置的红外设备名,如 `红外控制器`)
|
|
||||||
- `runtimeMode`: `InfraredRuntimeMode`(Fake 或 Real ABSDK)
|
|
||||||
- `message`: `UiMessage?`(提示信息或错误)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 交互与视觉规范 (Interaction & Visuals)
|
|
||||||
|
|
||||||
### 4.1 视觉造型
|
|
||||||
1. **遥控器本体外壳 (Remote Body Chassis)**:
|
|
||||||
- 居中放置的圆角卡片,具有微阴影、圆角半径 `28.dp`、外框及哑光质感。
|
|
||||||
2. **空调 LCD 虚拟屏**:
|
|
||||||
- 顶部黑色圆角框,内部呈现液晶感绿色/白色字样(如当前目标温度 `26°C` 与工作状态)。
|
|
||||||
3. **垂直双向胶囊摇杆 (Vertical Capsule Rockers)**:
|
|
||||||
- 上半区与下半区独立响应点击,中间由细微分割线隔开,常用于“温度 +/-”、“音量 +/-”及“频道 +/-”。
|
|
||||||
4. **圆形导航方向盘 (D-Pad Wheel)**:
|
|
||||||
- 直径约 `180.dp` 的圆形组合组件,外环包含 4 个扇区方向按键,内嵌独立的圆形 OK 键。
|
|
||||||
|
|
||||||
### 4.2 按键状态机反馈
|
|
||||||
1. **控制模式 (学习开关 OFF)**:
|
|
||||||
- **已录制 (READY)**:按键实体呈现饱满质感,点击触发波纹反馈,调用 `sendKey` 发射红外,提示“已发射”。
|
|
||||||
- **未录制 (UNLEARNED)**:按键显示浅色未激活状态;点击弹出轻量提示:`“按键尚未学习,请打开右上角学习模式”`。
|
|
||||||
2. **学习录制模式 (学习开关 ON)**:
|
|
||||||
- 顶部呈现橙色高亮提示条:`“🔴 学习模式已开启:点击任意按键开始录制”`。
|
|
||||||
- 所有按键进入可录制状态(带提示外框)。
|
|
||||||
- 点击目标按键后:
|
|
||||||
- 按键上显示微型 Loading 光圈。
|
|
||||||
- 调用 `learnKey(deviceName, defaultSlot, displayName)`。
|
|
||||||
- 手机或控制器捕获红外信号成功后,按键变为 READY 并伴随成功提示。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 验证与测试标准 (Verification & Acceptance Criteria)
|
|
||||||
|
|
||||||
1. **单元测试 (Unit Tests)**:
|
|
||||||
- `InfraredRemoteViewModelTest` 覆盖场景切换、学习模式开关、按键已学/未学状态映射、点击发射以及点击学习请求的转发。
|
|
||||||
2. **UI 测试 (Compose Tests)**:
|
|
||||||
- 空调与电视遥控器按键正常渲染并带正确 Tag。
|
|
||||||
- 学习模式切换后 UI 状态正确切换。
|
|
||||||
3. **真机与编译验收**:
|
|
||||||
- 模块与 App 通过 JVM 测试与打包验证。
|
|
||||||
- 手机上点击空调/电视按键能发起正确的 Slot 编号学习与发射。
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
# 红外控制硬件验证规范与记录 / Infrared Hardware Validation Guide & Records
|
|
||||||
|
|
||||||
## 1. 硬件环境与已知设备信息 / Hardware Environment
|
|
||||||
|
|
||||||
本阶段用于真实验证的实体设备信息如下:
|
|
||||||
|
|
||||||
- **设备名称**: 红外控制器 (例如 `红外控制器` 或厂商平台绑定的 `irDevName`)
|
|
||||||
- **IEEE 地址**: `1c1cfd001dba533c` (短地址: `d822`)
|
|
||||||
- **信号强度 (LQI/RSSI)**: `-4`
|
|
||||||
- **电量**: `100%`
|
|
||||||
- **设备类型**: 红外控制器 (Infrared Controller)
|
|
||||||
- **网关/主机**: ABox 智能主机
|
|
||||||
- **被控实体**: 实体空调 / 电视遥控器及对应电器
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 硬件测试记录表 / Hardware Validation Log
|
|
||||||
|
|
||||||
| 日期 (Date) | App Commit | 运行模式 (Mode) | Profile ID | 设备名称 (Device Name) | SDK Key | 操作 (Operation) | 耗时 (ms) | ABSDK Code | 脱敏消息 (Message) | 物理响应 (Physical Response) | 结论 (Result) |
|
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
||||||
| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Learn | 120 | 00000 | success | 模拟学习完成并入库 READY | PASS (Fake) |
|
|
||||||
| 2026-08-24 | b27c67f | FAKE | abox:infrared-fake | ir-living | app_ir_power1 | Send | 45 | 00000 | success | 模拟发射成功 | PASS (Fake) |
|
|
||||||
| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Learn | - | - | - | 遥控器对准控制器按下目标按键 | 待真机执行 |
|
|
||||||
| 2026-08-24 | 待填 | REAL_ABSDK | abox | 红外控制器 | app_ir_xxxx | Send | - | - | - | 实体电器执行开关/调节响应 | 待真机执行 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 标准真机测试流程 (12 项用例) / Standard 12-Step Test Procedure
|
|
||||||
|
|
||||||
### 用例 1: 真实会话认证前置 (Authenticate Shared Bridge)
|
|
||||||
- **前置**: 通过登录模块对进程单例 `AbsdkBridge` 进行登录认证。
|
|
||||||
- **步骤**: 检查进入红外控制页面前,会话 Token 是否有效。
|
|
||||||
- **预期**: 若未认证,进入 Real 模式操作时返回 `TokenInvalid` 并提示返回登录。
|
|
||||||
|
|
||||||
### 用例 2: 输入并保存真实设备名 (Enter and Save irDevName)
|
|
||||||
- **步骤**: 在“红外设备名称”输入框中输入 `红外控制器` 或后台分配的真实设备名,点击“保存设备名”。
|
|
||||||
- **预期**: 本地 Room 保存成功,页面显示“已保存设备:红外控制器”,启用“学习新按键”入口。
|
|
||||||
|
|
||||||
### 用例 3: 学习全新唯一按键 (Learn Unique Key)
|
|
||||||
- **步骤**: 点击“学习新按键”,输入显示名称(如“空调开”),点击“开始学习”;将物理遥控器对准红外控制器按下按键。
|
|
||||||
- **预期**: 本地先写入 `LEARNING` 状态,硬件捕获完成后状态自动刷新为 `READY`,记录响应耗时与返回码。
|
|
||||||
|
|
||||||
### 用例 4: 立即发射已学习按键 (Send Learned Key Immediately)
|
|
||||||
- **步骤**: 学习成功后,点击该按键右侧的“发射”按钮。
|
|
||||||
- **预期**: ABSDK 返回 `00000`,实体电器执行对应操作,发射期间按钮防重复点击。
|
|
||||||
|
|
||||||
### 用例 5: 重复发射验证稳定性 (Repeat Send)
|
|
||||||
- **步骤**: 间隔 3 秒再次点击同一按键的“发射”按钮。
|
|
||||||
- **预期**: 发射稳定成功,电器再次响应,按键保持 `READY` 状态不变。
|
|
||||||
|
|
||||||
### 用例 6: 重新学习同一 SDK Key (Relearn Same Key)
|
|
||||||
- **步骤**: 针对已存在或失败的按键点击“重试”/重新学习。
|
|
||||||
- **预期**: 复用相同的 `InfraredKeyId` 和 `sdkKey`,先变更为 `LEARNING`,学习完成后恢复 `READY`,不生成冗余记录。
|
|
||||||
|
|
||||||
### 用例 7: 控制器断电/离线测试 (Controller Offline)
|
|
||||||
- **步骤**: 拔掉红外控制器电源,发起学习或发射。
|
|
||||||
- **预期**: ABSDK 返回 `20206`,领域层转换为 `DomainError.DeviceOffline`,学习按键标为 `FAILED`,UI 提示控制器离线。
|
|
||||||
|
|
||||||
### 用例 8: 遥控器无信号/超时测试 (No Signal / Learning Timeout)
|
|
||||||
- **步骤**: 点击“开始学习”,但不按物理遥控器任何按键,等待 SDK 返回。
|
|
||||||
- **预期**: 记录实际超时等待时间与错误码(如 `20204`/`20205`),按键转换为 `FAILED` 并允许重试。
|
|
||||||
|
|
||||||
### 用例 9: 学习过程中离开页面 (Leave Page During Learning)
|
|
||||||
- **步骤**: 点击开始学习后,立即点击“返回”离开红外页面;稍后重新进入。
|
|
||||||
- **预期**: Application 协程作用域内任务继续执行,学习完成后 Room 数据库正确持久化结果,重新进入显示最新状态。
|
|
||||||
|
|
||||||
### 用例 10: 进程被杀死后状态恢复 (Process Interruption Recovery)
|
|
||||||
- **步骤**: 在 `LEARNING` 状态下强制杀死 App 进程并重启。
|
|
||||||
- **预期**: App 启动或重新进入页面时,`RecoverInterruptedInfraredLearning` 将遗留的 `LEARNING` 保守恢复为 `FAILED`,提示用户重新学习。
|
|
||||||
|
|
||||||
### 用例 11: Token 失效与重新登录 (Token Expiration)
|
|
||||||
- **步骤**: 主机端使 Token 失效或超过 2 小时后发起红外操作。
|
|
||||||
- **预期**: 返回 `TokenInvalid`,本地会话被清除,页面提示登录失效并导航返回。
|
|
||||||
|
|
||||||
### 用例 12: 检查未文档化响应字段 (Inspect Undocumented Fields)
|
|
||||||
- **步骤**: 在调试构建中检查 `ABRet.getDicDatas()` 是否包含未文档化字段。
|
|
||||||
- **安全约束**: 严禁将 Raw Payload、Token、密码或未脱敏数据打印至生产日志。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 协议与硬件事实确认记录 / Observed Facts
|
|
||||||
|
|
||||||
- **SDK 阻塞模式**: 同步阻塞 HTTP POST 调用,由 `AbsdkBridge` 调度至 `Dispatchers.IO` 并通过进程级 `Mutex` 串行执行。
|
|
||||||
- **数据隔离**: `FAKE` 模式自动使用 `profileId:infrared-fake` 隔离数据,`REAL_ABSDK` 模式使用主会话数据。
|
|
||||||
- **安全门禁**: 真实 ABSDK 依赖明文传输,Release 构建中完全禁用硬件接口并由 `InfraredReleaseModule` 安全阻断。
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
object AcCodeGenerator {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates a standard protocol Hex string for an Air Conditioner state.
|
|
||||||
* Uses Gree/Generic standard 64-bit frame:
|
|
||||||
* Byte 0: Mode (3 bits) | Power (1 bit) | Fan Speed (2 bits) | Swing (2 bits)
|
|
||||||
* Byte 1: Temperature (4 bits: temp - 16) | Timer (4 bits)
|
|
||||||
* Byte 2: Auxiliary options (Eco, Turbo, Health, Light)
|
|
||||||
* Byte 3: Checksum
|
|
||||||
*/
|
|
||||||
fun generateHex(state: AcStateSnapshot, brand: String? = null): String {
|
|
||||||
val modeBits = when (state.mode) {
|
|
||||||
AcMode.AUTO -> 0b000
|
|
||||||
AcMode.COOL -> 0b001
|
|
||||||
AcMode.DRY -> 0b010
|
|
||||||
AcMode.FAN -> 0b011
|
|
||||||
AcMode.HEAT -> 0b100
|
|
||||||
}
|
|
||||||
val powerBit = if (state.power) 0b1 else 0b0
|
|
||||||
val fanBits = when (state.fanSpeed) {
|
|
||||||
AcFanSpeed.AUTO -> 0b00
|
|
||||||
AcFanSpeed.LOW -> 0b01
|
|
||||||
AcFanSpeed.MEDIUM -> 0b10
|
|
||||||
AcFanSpeed.HIGH -> 0b11
|
|
||||||
}
|
|
||||||
val swingBits = (if (state.verticalSwing) 0b01 else 0b00) or (if (state.horizontalSwing) 0b10 else 0b00)
|
|
||||||
|
|
||||||
val byte0 = ((modeBits and 0x07) shl 5) or
|
|
||||||
((powerBit and 0x01) shl 4) or
|
|
||||||
((fanBits and 0x03) shl 2) or
|
|
||||||
(swingBits and 0x03)
|
|
||||||
|
|
||||||
val clampedTemp = state.temperature.coerceIn(16, 30)
|
|
||||||
val tempBits = (clampedTemp - 16) and 0x0F
|
|
||||||
val byte1 = (tempBits shl 4) and 0xF0
|
|
||||||
|
|
||||||
val ecoBit = if (state.eco) 0b1 else 0b0
|
|
||||||
val byte2 = (ecoBit and 0x01) shl 7
|
|
||||||
|
|
||||||
val checksum = (byte0 + byte1 + byte2) and 0xFF
|
|
||||||
|
|
||||||
return buildString {
|
|
||||||
append("0x")
|
|
||||||
append(String.format("%02X", byte0))
|
|
||||||
append(String.format("%02X", byte1))
|
|
||||||
append(String.format("%02X", byte2))
|
|
||||||
append(String.format("%02X", checksum))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class AddVirtualRemoteKey(
|
|
||||||
private val repository: VirtualRemoteRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
displayName: String,
|
|
||||||
): DomainResult<RemoteKeyBinding> {
|
|
||||||
val trimmed = displayName.trim()
|
|
||||||
if (trimmed.isBlank()) {
|
|
||||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button name cannot be blank"))
|
|
||||||
}
|
|
||||||
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
|
|
||||||
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
}
|
|
||||||
|
|
||||||
val buttonId = "CUSTOM_" + UUID.randomUUID().toString().take(8).uppercase()
|
|
||||||
val assignedSlot = if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
|
|
||||||
// Find next available slot
|
|
||||||
val bindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
|
|
||||||
val usedSlots = bindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
|
||||||
val nextSlot = (1..99).firstOrNull { it !in usedSlots }
|
|
||||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "槽位配额已满,无法分配静态槽位"))
|
|
||||||
nextSlot.toString()
|
|
||||||
} else null
|
|
||||||
|
|
||||||
val binding = RemoteKeyBinding(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
remoteId = remoteId,
|
|
||||||
profileId = profileId,
|
|
||||||
buttonId = buttonId,
|
|
||||||
displayName = trimmed,
|
|
||||||
assignedSlot = assignedSlot,
|
|
||||||
rawHexCode = null,
|
|
||||||
status = InfraredKeyStatus.FAILED, // Initially unlearned
|
|
||||||
)
|
|
||||||
|
|
||||||
when (val res = repository.updateKeyBinding(binding)) {
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
return DomainResult.Success(binding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-110
@@ -1,110 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.InfraredCode
|
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
class CreateVirtualRemote(
|
|
||||||
private val repository: VirtualRemoteRepository,
|
|
||||||
private val gateway: InfraredGateway,
|
|
||||||
private val clock: EpochMillisClock,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
displayName: String,
|
|
||||||
type: RemoteDeviceType,
|
|
||||||
brandModel: String? = null,
|
|
||||||
transmissionMode: TransmissionMode = if (type == RemoteDeviceType.CUSTOM) TransmissionMode.STATIC_SLOTS else TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
layoutStyle: PanelLayoutStyle? = null,
|
|
||||||
): DomainResult<VirtualRemoteId> {
|
|
||||||
val trimmedName = displayName.trim()
|
|
||||||
if (trimmedName.isBlank()) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Remote display name cannot be blank"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val resolvedLayout = layoutStyle ?: VirtualRemote.defaultLayoutFor(type)
|
|
||||||
val resolvedTransMode = if (type == RemoteDeviceType.CUSTOM) {
|
|
||||||
TransmissionMode.STATIC_SLOTS
|
|
||||||
} else {
|
|
||||||
transmissionMode
|
|
||||||
}
|
|
||||||
|
|
||||||
val templates = PresetCodeLibrary.getTemplatesForType(type, brandModel, resolvedLayout)
|
|
||||||
val remoteId = VirtualRemoteId(UUID.randomUUID().toString().replace("-", "").take(16))
|
|
||||||
val now = clock.now()
|
|
||||||
|
|
||||||
val initialAcState = if (type == RemoteDeviceType.AIR_CONDITIONER) AcStateSnapshot() else null
|
|
||||||
|
|
||||||
val remote = VirtualRemote(
|
|
||||||
id = remoteId,
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
displayName = trimmedName,
|
|
||||||
type = type,
|
|
||||||
brandModel = brandModel,
|
|
||||||
layoutStyle = resolvedLayout,
|
|
||||||
transmissionMode = resolvedTransMode,
|
|
||||||
dynamicScratchSlot = VirtualRemote.DEFAULT_DYNAMIC_SCRATCH_SLOT,
|
|
||||||
acState = initialAcState,
|
|
||||||
createdAtEpochMillis = now,
|
|
||||||
updatedAtEpochMillis = now,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Build key bindings
|
|
||||||
val bindings = if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) {
|
|
||||||
templates.mapIndexed { index, template ->
|
|
||||||
RemoteKeyBinding(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
remoteId = remoteId,
|
|
||||||
profileId = profileId,
|
|
||||||
buttonId = template.buttonId,
|
|
||||||
displayName = template.displayName,
|
|
||||||
assignedSlot = (index + 1).toString(),
|
|
||||||
rawHexCode = template.defaultHexCode,
|
|
||||||
status = if (template.defaultHexCode != null) InfraredKeyStatus.READY else InfraredKeyStatus.FAILED,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
templates.map { template ->
|
|
||||||
RemoteKeyBinding(
|
|
||||||
id = UUID.randomUUID().toString(),
|
|
||||||
remoteId = remoteId,
|
|
||||||
profileId = profileId,
|
|
||||||
buttonId = template.buttonId,
|
|
||||||
displayName = template.displayName,
|
|
||||||
assignedSlot = null,
|
|
||||||
rawHexCode = template.defaultHexCode,
|
|
||||||
status = if (template.defaultHexCode != null || type == RemoteDeviceType.AIR_CONDITIONER) {
|
|
||||||
InfraredKeyStatus.READY
|
|
||||||
} else {
|
|
||||||
InfraredKeyStatus.FAILED
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If static and has default hex codes, download to hardware slots
|
|
||||||
if (resolvedTransMode == TransmissionMode.STATIC_SLOTS) {
|
|
||||||
val codesToDownload = bindings.mapNotNull { b ->
|
|
||||||
if (b.assignedSlot != null && b.rawHexCode != null) {
|
|
||||||
InfraredCode(deviceName, b.assignedSlot, b.rawHexCode)
|
|
||||||
} else null
|
|
||||||
}
|
|
||||||
if (codesToDownload.isNotEmpty()) {
|
|
||||||
when (val dlResult = gateway.downloadCodes(profileId, codesToDownload)) {
|
|
||||||
is DomainResult.Failure -> return dlResult
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return repository.createRemote(remote, bindings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
class DeleteVirtualRemote(
|
|
||||||
private val repository: VirtualRemoteRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<Unit> = repository.deleteRemote(profileId, remoteId)
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
class DeleteVirtualRemoteKey(
|
|
||||||
private val repository: VirtualRemoteRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
buttonId: String,
|
|
||||||
): DomainResult<Unit> = repository.deleteKeyBinding(remoteId, buttonId)
|
|
||||||
}
|
|
||||||
-86
@@ -1,86 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
fun interface InfraredKeyIdGenerator {
|
|
||||||
fun nextId(): InfraredKeyId
|
|
||||||
}
|
|
||||||
|
|
||||||
fun interface EpochMillisClock {
|
|
||||||
fun now(): Long
|
|
||||||
}
|
|
||||||
|
|
||||||
class InfraredKeyFactory(
|
|
||||||
private val idGenerator: InfraredKeyIdGenerator,
|
|
||||||
private val clock: EpochMillisClock,
|
|
||||||
) {
|
|
||||||
fun create(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
displayName: String,
|
|
||||||
sdkKey: String? = null,
|
|
||||||
): DomainResult<SavedInfraredKey> {
|
|
||||||
val normalizedName = displayName.trim()
|
|
||||||
if (
|
|
||||||
profileId.value.isBlank() ||
|
|
||||||
profileId.value != profileId.value.trim() ||
|
|
||||||
deviceName.value.isBlank() ||
|
|
||||||
deviceName.value != deviceName.value.trim() ||
|
|
||||||
normalizedName.isBlank()
|
|
||||||
) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Profile, device name, and display name are required"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val id = idGenerator.nextId()
|
|
||||||
val canonicalId = canonicalizeId(id.value)
|
|
||||||
?: return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
|
|
||||||
)
|
|
||||||
|
|
||||||
val finalSdkKey = if (sdkKey != null) {
|
|
||||||
val trimmed = sdkKey.trim()
|
|
||||||
val intVal = trimmed.toIntOrNull()
|
|
||||||
if (intVal == null || intVal !in 1..100) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key slot must be an integer between 1 and 100"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
intVal.toString()
|
|
||||||
} else {
|
|
||||||
"app_ir_$canonicalId"
|
|
||||||
}
|
|
||||||
|
|
||||||
val now = clock.now()
|
|
||||||
return DomainResult.Success(
|
|
||||||
SavedInfraredKey(
|
|
||||||
id = id,
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
displayName = normalizedName,
|
|
||||||
sdkKey = finalSdkKey,
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = now,
|
|
||||||
updatedAtEpochMillis = now,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun canonicalizeId(value: String): String? = when {
|
|
||||||
ASCII_ALPHANUMERIC_ID.matches(value) -> value.lowercase()
|
|
||||||
STANDARD_UUID.matches(value) -> value.replace("-", "").lowercase()
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val ASCII_ALPHANUMERIC_ID = Regex("^[A-Za-z0-9]{1,32}$")
|
|
||||||
val STANDARD_UUID = Regex(
|
|
||||||
"^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
value class InfraredKeyId(val value: String)
|
|
||||||
|
|
||||||
data class InfraredKeyRef(
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val keyId: InfraredKeyId,
|
|
||||||
)
|
|
||||||
|
|
||||||
enum class InfraredKeyStatus {
|
|
||||||
LEARNING,
|
|
||||||
READY,
|
|
||||||
FAILED,
|
|
||||||
}
|
|
||||||
|
|
||||||
const val INTERRUPTED_LEARNING_ERROR_DETAIL =
|
|
||||||
"Interrupted learning result is unknown; retry required"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persisted infrared key state. [lastErrorDetail] may contain only fixed, domain-owned,
|
|
||||||
* sanitized text; raw SDK maps, tokens, codes, and vendor payloads must never be stored there.
|
|
||||||
*/
|
|
||||||
data class SavedInfraredKey(
|
|
||||||
val id: InfraredKeyId,
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val deviceName: DeviceName,
|
|
||||||
val displayName: String,
|
|
||||||
val sdkKey: String,
|
|
||||||
val status: InfraredKeyStatus,
|
|
||||||
val lastErrorCode: String?,
|
|
||||||
val lastErrorDetail: String?,
|
|
||||||
val createdAtEpochMillis: Long,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
)
|
|
||||||
-53
@@ -1,53 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface InfraredKeyRepository {
|
|
||||||
fun observeKeys(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<SavedInfraredKey>>>
|
|
||||||
|
|
||||||
suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?>
|
|
||||||
|
|
||||||
suspend fun insert(key: SavedInfraredKey): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun transitionStatus(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
expectedStatuses: Set<InfraredKeyStatus>,
|
|
||||||
targetStatus: InfraredKeyStatus,
|
|
||||||
lastErrorCode: String?,
|
|
||||||
lastErrorDetail: String?,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun rename(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
displayName: String,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Atomically marks this profile's interrupted LEARNING keys created on or before [cutoffEpochMillis]
|
|
||||||
* as FAILED, clears the vendor error code, stores [INTERRUPTED_LEARNING_ERROR_DETAIL], and updates their timestamp.
|
|
||||||
*/
|
|
||||||
suspend fun failInterruptedLearning(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
cutoffEpochMillis: Long = Long.MAX_VALUE,
|
|
||||||
updatedAtEpochMillis: Long = cutoffEpochMillis,
|
|
||||||
): DomainResult<Int>
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InfraredDeviceConfigurationRepository {
|
|
||||||
fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>>
|
|
||||||
|
|
||||||
suspend fun setDeviceName(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.NonCancellable
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
class LearnInfraredKey(
|
|
||||||
private val repository: InfraredKeyRepository,
|
|
||||||
private val gateway: InfraredGateway,
|
|
||||||
private val factory: InfraredKeyFactory,
|
|
||||||
private val clock: EpochMillisClock,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
displayName: String,
|
|
||||||
sdkKey: String? = null,
|
|
||||||
): DomainResult<InfraredKeyId> {
|
|
||||||
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
|
|
||||||
is DomainResult.Success -> result.value
|
|
||||||
is DomainResult.Failure -> return result
|
|
||||||
}
|
|
||||||
|
|
||||||
when (val result = repository.insert(key)) {
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
is DomainResult.Failure -> return result
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = try {
|
|
||||||
gateway.learnKey(
|
|
||||||
profileId = key.profileId,
|
|
||||||
deviceName = key.deviceName,
|
|
||||||
key = key.sdkKey,
|
|
||||||
)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
repository.transitionStatus(
|
|
||||||
ref = key.ref(),
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = "Cancelled",
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (result) {
|
|
||||||
is DomainResult.Success -> when (
|
|
||||||
val transition = repository.transitionStatus(
|
|
||||||
ref = key.ref(),
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
is DomainResult.Success -> DomainResult.Success(key.id)
|
|
||||||
is DomainResult.Failure -> transition
|
|
||||||
}
|
|
||||||
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
repository.transitionStatus(
|
|
||||||
ref = key.ref(),
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = result.error.externalCode,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RetryInfraredKeyLearning(
|
|
||||||
private val repository: InfraredKeyRepository,
|
|
||||||
private val gateway: InfraredGateway,
|
|
||||||
private val clock: EpochMillisClock,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
|
|
||||||
val key = when (val result = repository.getKey(ref)) {
|
|
||||||
is DomainResult.Success -> result.value
|
|
||||||
?: return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key not found"),
|
|
||||||
)
|
|
||||||
|
|
||||||
is DomainResult.Failure -> return result
|
|
||||||
}
|
|
||||||
if (key.status == InfraredKeyStatus.LEARNING) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key is already learning"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (
|
|
||||||
val result = repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(
|
|
||||||
InfraredKeyStatus.FAILED,
|
|
||||||
),
|
|
||||||
targetStatus = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
is DomainResult.Failure -> return result
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = try {
|
|
||||||
gateway.learnKey(
|
|
||||||
profileId = key.profileId,
|
|
||||||
deviceName = key.deviceName,
|
|
||||||
key = key.sdkKey,
|
|
||||||
)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = "Cancelled",
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (result) {
|
|
||||||
is DomainResult.Success -> repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = result.error.externalCode,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RelearnInfraredKey(
|
|
||||||
private val repository: InfraredKeyRepository,
|
|
||||||
private val gateway: InfraredGateway,
|
|
||||||
private val clock: EpochMillisClock,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
|
|
||||||
val key = when (val result = repository.getKey(ref)) {
|
|
||||||
is DomainResult.Success -> result.value
|
|
||||||
?: return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key not found"),
|
|
||||||
)
|
|
||||||
|
|
||||||
is DomainResult.Failure -> return result
|
|
||||||
}
|
|
||||||
|
|
||||||
when (
|
|
||||||
val result = repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(
|
|
||||||
InfraredKeyStatus.READY,
|
|
||||||
InfraredKeyStatus.FAILED,
|
|
||||||
InfraredKeyStatus.LEARNING,
|
|
||||||
),
|
|
||||||
targetStatus = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
is DomainResult.Failure -> return result
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = try {
|
|
||||||
gateway.learnKey(
|
|
||||||
profileId = key.profileId,
|
|
||||||
deviceName = key.deviceName,
|
|
||||||
key = key.sdkKey,
|
|
||||||
)
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
withContext(NonCancellable) {
|
|
||||||
repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = "Cancelled",
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (result) {
|
|
||||||
is DomainResult.Success -> repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = result.error.externalCode,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
updatedAtEpochMillis = clock.now(),
|
|
||||||
)
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SavedInfraredKey.ref() = InfraredKeyRef(
|
|
||||||
profileId = profileId,
|
|
||||||
keyId = id,
|
|
||||||
)
|
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
class LearnVirtualRemoteKey(
|
|
||||||
private val repository: VirtualRemoteRepository,
|
|
||||||
private val gateway: InfraredGateway,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
buttonId: String,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
|
|
||||||
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
}
|
|
||||||
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
|
|
||||||
is DomainResult.Success -> res.value
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
}
|
|
||||||
val binding = bindings.find { it.buttonId == buttonId }
|
|
||||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button not found"))
|
|
||||||
|
|
||||||
val targetSlot = binding.assignedSlot ?: run {
|
|
||||||
val allBindings = (repository.getKeyBindings(remoteId) as? DomainResult.Success)?.value.orEmpty()
|
|
||||||
val usedSlots = allBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
|
||||||
val nextSlot = (1..99).firstOrNull { it !in usedSlots } ?: 1
|
|
||||||
nextSlot.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set status to LEARNING while hardware listens
|
|
||||||
repository.updateKeyBinding(
|
|
||||||
binding.copy(
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
assignedSlot = targetSlot,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return when (val learnResult = gateway.learnKey(profileId, remote.deviceName, targetSlot)) {
|
|
||||||
is DomainResult.Success -> {
|
|
||||||
repository.updateKeyBinding(
|
|
||||||
binding.copy(
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
assignedSlot = targetSlot,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
is DomainResult.Failure -> {
|
|
||||||
repository.updateKeyBinding(
|
|
||||||
binding.copy(
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
assignedSlot = targetSlot,
|
|
||||||
lastErrorCode = learnResult.error.externalCode,
|
|
||||||
lastErrorDetail = learnResult.error.detail,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
learnResult
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
class SendInfraredKey(
|
|
||||||
private val repository: InfraredKeyRepository,
|
|
||||||
private val gateway: InfraredGateway,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> {
|
|
||||||
val key = when (val result = repository.getKey(ref)) {
|
|
||||||
is DomainResult.Success -> result.value
|
|
||||||
?: return DomainResult.Failure(
|
|
||||||
DomainError.NoData(detail = "Infrared key not found"),
|
|
||||||
)
|
|
||||||
|
|
||||||
is DomainResult.Failure -> return result
|
|
||||||
}
|
|
||||||
if (key.status != InfraredKeyStatus.READY) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key is not ready"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return gateway.sendKey(
|
|
||||||
profileId = key.profileId,
|
|
||||||
deviceName = key.deviceName,
|
|
||||||
key = key.sdkKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RenameInfraredKey(
|
|
||||||
private val repository: InfraredKeyRepository,
|
|
||||||
private val clock: EpochMillisClock,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
displayName: String,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val trimmed = displayName.trim()
|
|
||||||
if (trimmed.isEmpty()) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key display name is required"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return repository.rename(ref, trimmed, clock.now())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class DeleteInfraredKey(
|
|
||||||
private val repository: InfraredKeyRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(ref: InfraredKeyRef): DomainResult<Unit> =
|
|
||||||
repository.deleteIfNotLearning(ref)
|
|
||||||
}
|
|
||||||
|
|
||||||
class RecoverInterruptedInfraredLearning(
|
|
||||||
private val repository: InfraredKeyRepository,
|
|
||||||
private val clock: EpochMillisClock,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(profileId: ProviderProfileId): DomainResult<Int> =
|
|
||||||
repository.failInterruptedLearning(profileId, clock.now())
|
|
||||||
}
|
|
||||||
-101
@@ -1,101 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
data class PresetBrandOption(
|
|
||||||
val id: String,
|
|
||||||
val displayName: String,
|
|
||||||
val deviceType: RemoteDeviceType,
|
|
||||||
)
|
|
||||||
|
|
||||||
object PresetCodeLibrary {
|
|
||||||
|
|
||||||
val availablePresets: List<PresetBrandOption> = listOf(
|
|
||||||
// 电视预设
|
|
||||||
PresetBrandOption("generic_tv", "通用电视 (Generic TV)", RemoteDeviceType.TV),
|
|
||||||
PresetBrandOption("xiaomi_tv", "小米电视 (Xiaomi TV)", RemoteDeviceType.TV),
|
|
||||||
PresetBrandOption("sony_tv", "索尼电视 (Sony TV)", RemoteDeviceType.TV),
|
|
||||||
PresetBrandOption("samsung_tv", "三星电视 (Samsung TV)", RemoteDeviceType.TV),
|
|
||||||
|
|
||||||
// 空调预设
|
|
||||||
PresetBrandOption("gree_ac", "格力空调 (Gree AC)", RemoteDeviceType.AIR_CONDITIONER),
|
|
||||||
PresetBrandOption("midea_ac", "美的空调 (Midea AC)", RemoteDeviceType.AIR_CONDITIONER),
|
|
||||||
PresetBrandOption("haier_ac", "海尔空调 (Haier AC)", RemoteDeviceType.AIR_CONDITIONER),
|
|
||||||
PresetBrandOption("aux_ac", "奥克斯空调 (AUX AC)", RemoteDeviceType.AIR_CONDITIONER),
|
|
||||||
|
|
||||||
// 风扇预设
|
|
||||||
PresetBrandOption("midea_fan", "美的电风扇 (Midea Fan)", RemoteDeviceType.FAN),
|
|
||||||
PresetBrandOption("generic_fan", "通用风扇 (Generic Fan)", RemoteDeviceType.FAN),
|
|
||||||
|
|
||||||
// 自定义
|
|
||||||
PresetBrandOption("custom_blank", "自定义遥控器 (Custom)", RemoteDeviceType.CUSTOM),
|
|
||||||
)
|
|
||||||
|
|
||||||
data class PresetButtonTemplate(
|
|
||||||
val buttonId: String,
|
|
||||||
val displayName: String,
|
|
||||||
val defaultHexCode: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getTemplatesForType(
|
|
||||||
type: RemoteDeviceType,
|
|
||||||
presetId: String? = null,
|
|
||||||
layoutStyle: PanelLayoutStyle? = null,
|
|
||||||
): List<PresetButtonTemplate> = when (type) {
|
|
||||||
RemoteDeviceType.TV -> getTvTemplates(hasHexCode = true)
|
|
||||||
RemoteDeviceType.AIR_CONDITIONER -> listOf(
|
|
||||||
PresetButtonTemplate("AC_POWER", "电源"),
|
|
||||||
PresetButtonTemplate("AC_TEMP_UP", "升温"),
|
|
||||||
PresetButtonTemplate("AC_TEMP_DOWN", "降温"),
|
|
||||||
PresetButtonTemplate("AC_MODE", "模式"),
|
|
||||||
PresetButtonTemplate("AC_FAN", "风速"),
|
|
||||||
PresetButtonTemplate("AC_SWING_V", "上下扫风"),
|
|
||||||
PresetButtonTemplate("AC_SWING_H", "左右扫风"),
|
|
||||||
PresetButtonTemplate("AC_ECO", "节能"),
|
|
||||||
)
|
|
||||||
RemoteDeviceType.FAN -> listOf(
|
|
||||||
PresetButtonTemplate("FAN_POWER", "电源", "0x01FE02FD"),
|
|
||||||
PresetButtonTemplate("FAN_SPEED", "风速", "0x01FE04FB"),
|
|
||||||
PresetButtonTemplate("FAN_SWING", "摇头", "0x01FE06F9"),
|
|
||||||
PresetButtonTemplate("FAN_TIMER", "定时", "0x01FE08F7"),
|
|
||||||
PresetButtonTemplate("FAN_MODE", "模式", "0x01FE0AF5"),
|
|
||||||
)
|
|
||||||
RemoteDeviceType.CUSTOM -> when (layoutStyle) {
|
|
||||||
PanelLayoutStyle.TV_NAV -> getTvTemplates(hasHexCode = false)
|
|
||||||
PanelLayoutStyle.MEDIA_PLAYER -> getMediaPlayerTemplates(hasHexCode = false)
|
|
||||||
PanelLayoutStyle.AC_COMPACT -> emptyList()
|
|
||||||
PanelLayoutStyle.GRID, null -> listOf(
|
|
||||||
PresetButtonTemplate("CUSTOM_POWER", "电源"),
|
|
||||||
PresetButtonTemplate("CUSTOM_KEY_1", "按键 1"),
|
|
||||||
PresetButtonTemplate("CUSTOM_KEY_2", "按键 2"),
|
|
||||||
PresetButtonTemplate("CUSTOM_KEY_3", "按键 3"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getTvTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
|
|
||||||
PresetButtonTemplate("TV_POWER", "电源", if (hasHexCode) "0x00FF02FD" else null),
|
|
||||||
PresetButtonTemplate("TV_MUTE", "静音", if (hasHexCode) "0x00FF04FB" else null),
|
|
||||||
PresetButtonTemplate("TV_SOURCE", "信号源", if (hasHexCode) "0x00FF06F9" else null),
|
|
||||||
PresetButtonTemplate("TV_NAV_UP", "上", if (hasHexCode) "0x00FF08F7" else null),
|
|
||||||
PresetButtonTemplate("TV_NAV_DOWN", "下", if (hasHexCode) "0x00FF0AF5" else null),
|
|
||||||
PresetButtonTemplate("TV_NAV_LEFT", "左", if (hasHexCode) "0x00FF0CF3" else null),
|
|
||||||
PresetButtonTemplate("TV_NAV_RIGHT", "右", if (hasHexCode) "0x00FF0EF1" else null),
|
|
||||||
PresetButtonTemplate("TV_OK", "确定", if (hasHexCode) "0x00FF10EF" else null),
|
|
||||||
PresetButtonTemplate("TV_HOME", "主页", if (hasHexCode) "0x00FF12ED" else null),
|
|
||||||
PresetButtonTemplate("TV_BACK", "返回", if (hasHexCode) "0x00FF14EB" else null),
|
|
||||||
PresetButtonTemplate("TV_MENU", "菜单", if (hasHexCode) "0x00FF16E9" else null),
|
|
||||||
PresetButtonTemplate("TV_VOL_UP", "音量 +", if (hasHexCode) "0x00FF18E7" else null),
|
|
||||||
PresetButtonTemplate("TV_VOL_DOWN", "音量 -", if (hasHexCode) "0x00FF1AE5" else null),
|
|
||||||
PresetButtonTemplate("TV_CH_UP", "频道 +", if (hasHexCode) "0x00FF1CE3" else null),
|
|
||||||
PresetButtonTemplate("TV_CH_DOWN", "频道 -", if (hasHexCode) "0x00FF1EE1" else null),
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun getMediaPlayerTemplates(hasHexCode: Boolean): List<PresetButtonTemplate> = listOf(
|
|
||||||
PresetButtonTemplate("MEDIA_POWER", "电源", if (hasHexCode) "0x02FD02FD" else null),
|
|
||||||
PresetButtonTemplate("MEDIA_MUTE", "静音", if (hasHexCode) "0x02FD04FB" else null),
|
|
||||||
PresetButtonTemplate("MEDIA_PLAY_PAUSE", "播放/暂停", if (hasHexCode) "0x02FD06F9" else null),
|
|
||||||
PresetButtonTemplate("MEDIA_PREV", "上一曲", if (hasHexCode) "0x02FD08F7" else null),
|
|
||||||
PresetButtonTemplate("MEDIA_NEXT", "下一曲", if (hasHexCode) "0x02FD0AF5" else null),
|
|
||||||
PresetButtonTemplate("MEDIA_REWIND", "快退", if (hasHexCode) "0x02FD0CF3" else null),
|
|
||||||
PresetButtonTemplate("MEDIA_FF", "快进", if (hasHexCode) "0x02FD0EF1" else null),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-90
@@ -1,90 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.InfraredCode
|
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
class SendVirtualRemoteAction(
|
|
||||||
private val repository: VirtualRemoteRepository,
|
|
||||||
private val gateway: InfraredGateway,
|
|
||||||
) {
|
|
||||||
suspend fun sendButton(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
buttonId: String,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
|
|
||||||
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
}
|
|
||||||
val bindings = when (val res = repository.getKeyBindings(remoteId)) {
|
|
||||||
is DomainResult.Success -> res.value
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
}
|
|
||||||
val binding = bindings.find { it.buttonId == buttonId }
|
|
||||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button binding not found"))
|
|
||||||
|
|
||||||
if (binding.status != InfraredKeyStatus.READY) {
|
|
||||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Button is not ready to send"))
|
|
||||||
}
|
|
||||||
|
|
||||||
return when (remote.transmissionMode) {
|
|
||||||
TransmissionMode.STATIC_SLOTS -> {
|
|
||||||
val slot = binding.assignedSlot
|
|
||||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "No slot assigned for static mode"))
|
|
||||||
gateway.sendKey(profileId, remote.deviceName, slot)
|
|
||||||
}
|
|
||||||
TransmissionMode.DYNAMIC_SCRATCH -> {
|
|
||||||
if (binding.rawHexCode != null) {
|
|
||||||
// 1. Download to scratch slot
|
|
||||||
val scratchSlot = remote.dynamicScratchSlot
|
|
||||||
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, binding.rawHexCode)))) {
|
|
||||||
is DomainResult.Failure -> return dlResult
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
}
|
|
||||||
// 2. Send scratch slot
|
|
||||||
gateway.sendKey(profileId, remote.deviceName, scratchSlot)
|
|
||||||
} else if (binding.assignedSlot != null) {
|
|
||||||
// Fallback to sending learned hardware slot
|
|
||||||
gateway.sendKey(profileId, remote.deviceName, binding.assignedSlot)
|
|
||||||
} else {
|
|
||||||
DomainResult.Failure(DomainError.InvalidRequest(detail = "No hex code or slot available for transmission"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun sendAcState(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
state: AcStateSnapshot,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
|
|
||||||
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
}
|
|
||||||
if (remote.type != RemoteDeviceType.AIR_CONDITIONER) {
|
|
||||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Target is not an air conditioner"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Update state snapshot in repository
|
|
||||||
when (val updateRes = repository.updateAcState(profileId, remoteId, state)) {
|
|
||||||
is DomainResult.Failure -> return updateRes
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Generate full state hex frame
|
|
||||||
val hexCode = AcCodeGenerator.generateHex(state, remote.brandModel)
|
|
||||||
|
|
||||||
// 3. Write to scratch slot and transmit
|
|
||||||
val scratchSlot = remote.dynamicScratchSlot
|
|
||||||
when (val dlResult = gateway.downloadCodes(profileId, listOf(InfraredCode(remote.deviceName, scratchSlot, hexCode)))) {
|
|
||||||
is DomainResult.Failure -> return dlResult
|
|
||||||
is DomainResult.Success -> Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
return gateway.sendKey(profileId, remote.deviceName, scratchSlot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-25
@@ -1,25 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
class UpdateVirtualRemoteLayout(
|
|
||||||
private val repository: VirtualRemoteRepository,
|
|
||||||
) {
|
|
||||||
suspend operator fun invoke(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
layoutStyle: PanelLayoutStyle,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val remote = when (val res = repository.getRemote(profileId, remoteId)) {
|
|
||||||
is DomainResult.Success -> res.value ?: return DomainResult.Failure(DomainError.NoData(detail = "Remote not found"))
|
|
||||||
is DomainResult.Failure -> return res
|
|
||||||
}
|
|
||||||
val updated = remote.copy(
|
|
||||||
layoutStyle = layoutStyle,
|
|
||||||
updatedAtEpochMillis = System.currentTimeMillis(),
|
|
||||||
)
|
|
||||||
return repository.updateRemote(updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-108
@@ -1,108 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
|
|
||||||
@JvmInline
|
|
||||||
value class VirtualRemoteId(val value: String)
|
|
||||||
|
|
||||||
enum class RemoteDeviceType {
|
|
||||||
TV,
|
|
||||||
AIR_CONDITIONER,
|
|
||||||
FAN,
|
|
||||||
CUSTOM,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class TransmissionMode {
|
|
||||||
STATIC_SLOTS,
|
|
||||||
DYNAMIC_SCRATCH,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class PanelLayoutStyle {
|
|
||||||
TV_NAV,
|
|
||||||
GRID,
|
|
||||||
MEDIA_PLAYER,
|
|
||||||
AC_COMPACT,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class AcMode {
|
|
||||||
COOL,
|
|
||||||
HEAT,
|
|
||||||
AUTO,
|
|
||||||
DRY,
|
|
||||||
FAN,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class AcFanSpeed {
|
|
||||||
AUTO,
|
|
||||||
LOW,
|
|
||||||
MEDIUM,
|
|
||||||
HIGH,
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AcStateSnapshot(
|
|
||||||
val power: Boolean = true,
|
|
||||||
val mode: AcMode = AcMode.COOL,
|
|
||||||
val temperature: Int = 26,
|
|
||||||
val fanSpeed: AcFanSpeed = AcFanSpeed.AUTO,
|
|
||||||
val verticalSwing: Boolean = true,
|
|
||||||
val horizontalSwing: Boolean = false,
|
|
||||||
val eco: Boolean = false,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class VirtualRemote(
|
|
||||||
val id: VirtualRemoteId,
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val deviceName: DeviceName,
|
|
||||||
val displayName: String,
|
|
||||||
val type: RemoteDeviceType,
|
|
||||||
val brandModel: String? = null,
|
|
||||||
val layoutStyle: PanelLayoutStyle = defaultLayoutFor(type),
|
|
||||||
val transmissionMode: TransmissionMode,
|
|
||||||
val dynamicScratchSlot: String = DEFAULT_DYNAMIC_SCRATCH_SLOT,
|
|
||||||
val acState: AcStateSnapshot? = null,
|
|
||||||
val createdAtEpochMillis: Long,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
const val DEFAULT_DYNAMIC_SCRATCH_SLOT = "100"
|
|
||||||
|
|
||||||
fun defaultLayoutFor(type: RemoteDeviceType): PanelLayoutStyle = when (type) {
|
|
||||||
RemoteDeviceType.TV -> PanelLayoutStyle.TV_NAV
|
|
||||||
RemoteDeviceType.AIR_CONDITIONER -> PanelLayoutStyle.AC_COMPACT
|
|
||||||
RemoteDeviceType.FAN -> PanelLayoutStyle.GRID
|
|
||||||
RemoteDeviceType.CUSTOM -> PanelLayoutStyle.GRID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class RemoteKeyBinding(
|
|
||||||
val id: String,
|
|
||||||
val remoteId: VirtualRemoteId,
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val buttonId: String,
|
|
||||||
val displayName: String,
|
|
||||||
val assignedSlot: String? = null,
|
|
||||||
val rawHexCode: String? = null,
|
|
||||||
val status: InfraredKeyStatus = InfraredKeyStatus.READY,
|
|
||||||
val lastErrorCode: String? = null,
|
|
||||||
val lastErrorDetail: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SlotQuotaSummary(
|
|
||||||
val totalCapacity: Int = 100,
|
|
||||||
val dynamicScratchSlot: Int = 100,
|
|
||||||
val assignedStaticSlots: Set<Int> = emptySet(),
|
|
||||||
) {
|
|
||||||
val availableStaticSlots: List<Int>
|
|
||||||
get() = (1 until dynamicScratchSlot).filter { it !in assignedStaticSlots }
|
|
||||||
|
|
||||||
val usedCount: Int
|
|
||||||
get() = assignedStaticSlots.size + 1 // assigned static slots + 1 dynamic scratch slot
|
|
||||||
|
|
||||||
val remainingStaticCount: Int
|
|
||||||
get() = availableStaticSlots.size
|
|
||||||
|
|
||||||
val isStaticFull: Boolean
|
|
||||||
get() = availableStaticSlots.isEmpty()
|
|
||||||
}
|
|
||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
|
|
||||||
interface VirtualRemoteRepository {
|
|
||||||
fun observeRemotes(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<VirtualRemote>>>
|
|
||||||
|
|
||||||
fun observeRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<VirtualRemote?>>
|
|
||||||
|
|
||||||
fun observeKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<List<RemoteKeyBinding>>>
|
|
||||||
|
|
||||||
fun observeSlotQuota(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<SlotQuotaSummary>>
|
|
||||||
|
|
||||||
suspend fun getRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<VirtualRemote?>
|
|
||||||
|
|
||||||
suspend fun getKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<List<RemoteKeyBinding>>
|
|
||||||
|
|
||||||
suspend fun createRemote(
|
|
||||||
remote: VirtualRemote,
|
|
||||||
initialBindings: List<RemoteKeyBinding>,
|
|
||||||
): DomainResult<VirtualRemoteId>
|
|
||||||
|
|
||||||
suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun deleteRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun deleteKeyBinding(remoteId: VirtualRemoteId, buttonId: String): DomainResult<Unit>
|
|
||||||
|
|
||||||
suspend fun updateAcState(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
state: AcStateSnapshot,
|
|
||||||
): DomainResult<Unit>
|
|
||||||
}
|
|
||||||
-345
@@ -1,345 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.InfraredCode
|
|
||||||
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
|
|
||||||
import com.flagship.abox.manager.domain.InfraredGateway
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
data class InfraredLearningCall(
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val deviceName: DeviceName,
|
|
||||||
val key: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class InfraredStatusTransitionCall(
|
|
||||||
val ref: InfraredKeyRef,
|
|
||||||
val expectedStatuses: Set<InfraredKeyStatus>,
|
|
||||||
val targetStatus: InfraredKeyStatus,
|
|
||||||
val lastErrorCode: String?,
|
|
||||||
val lastErrorDetail: String?,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class InfraredSendCall(
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val deviceName: DeviceName,
|
|
||||||
val key: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class InfraredRenameCall(
|
|
||||||
val ref: InfraredKeyRef,
|
|
||||||
val displayName: String,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class InfraredRecoveryCall(
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val updatedAtEpochMillis: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
class InMemoryInfraredKeyRepository : InfraredKeyRepository {
|
|
||||||
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
|
|
||||||
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
|
|
||||||
|
|
||||||
var getError: DomainError? = null
|
|
||||||
var insertError: DomainError? = null
|
|
||||||
var renameError: DomainError? = null
|
|
||||||
var deleteError: DomainError? = null
|
|
||||||
var recoveryError: DomainError? = null
|
|
||||||
val transitionErrors = mutableMapOf<InfraredKeyStatus, DomainError>()
|
|
||||||
|
|
||||||
var onInsert: ((SavedInfraredKey) -> Unit)? = null
|
|
||||||
var onTransition: ((InfraredStatusTransitionCall) -> Unit)? = null
|
|
||||||
|
|
||||||
val insertedKeys = mutableListOf<SavedInfraredKey>()
|
|
||||||
val getCalls = mutableListOf<InfraredKeyRef>()
|
|
||||||
val transitionCalls = mutableListOf<InfraredStatusTransitionCall>()
|
|
||||||
val renameCalls = mutableListOf<InfraredRenameCall>()
|
|
||||||
val deleteCalls = mutableListOf<InfraredKeyRef>()
|
|
||||||
val recoveryCalls = mutableListOf<InfraredRecoveryCall>()
|
|
||||||
|
|
||||||
override fun observeKeys(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
|
|
||||||
DomainResult.Success(
|
|
||||||
values.filter { it.profileId == profileId && it.deviceName == deviceName },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
|
|
||||||
getCalls += ref
|
|
||||||
getError?.let { return DomainResult.Failure(it) }
|
|
||||||
return DomainResult.Success(keys[ref])
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
|
|
||||||
insertedKeys += key
|
|
||||||
onInsert?.invoke(key)
|
|
||||||
insertError?.let { return DomainResult.Failure(it) }
|
|
||||||
keys[key.ref()] = key
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun transitionStatus(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
expectedStatuses: Set<InfraredKeyStatus>,
|
|
||||||
targetStatus: InfraredKeyStatus,
|
|
||||||
lastErrorCode: String?,
|
|
||||||
lastErrorDetail: String?,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val call = InfraredStatusTransitionCall(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = expectedStatuses,
|
|
||||||
targetStatus = targetStatus,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
transitionCalls += call
|
|
||||||
onTransition?.invoke(call)
|
|
||||||
transitionErrors[targetStatus]?.let { return DomainResult.Failure(it) }
|
|
||||||
|
|
||||||
val existing = keys[ref]
|
|
||||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
|
||||||
if (existing.status !in expectedStatuses) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared key status changed"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
keys[ref] = existing.copy(
|
|
||||||
status = targetStatus,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun rename(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
displayName: String,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
renameCalls += InfraredRenameCall(ref, displayName, updatedAtEpochMillis)
|
|
||||||
renameError?.let { return DomainResult.Failure(it) }
|
|
||||||
val existing = keys[ref]
|
|
||||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
|
||||||
keys[ref] = existing.copy(
|
|
||||||
displayName = displayName,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
|
|
||||||
deleteCalls += ref
|
|
||||||
deleteError?.let { return DomainResult.Failure(it) }
|
|
||||||
val existing = keys[ref]
|
|
||||||
?: return DomainResult.Failure(DomainError.InvalidRequest(detail = "Infrared key not found"))
|
|
||||||
if (existing.status == InfraredKeyStatus.LEARNING) {
|
|
||||||
return DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Cannot delete a learning infrared key"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
keys.remove(ref)
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun failInterruptedLearning(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
cutoffEpochMillis: Long,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Int> {
|
|
||||||
recoveryCalls += InfraredRecoveryCall(profileId, updatedAtEpochMillis)
|
|
||||||
recoveryError?.let { return DomainResult.Failure(it) }
|
|
||||||
var changed = 0
|
|
||||||
keys.replaceAll { _, key ->
|
|
||||||
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
|
|
||||||
changed += 1
|
|
||||||
key.copy(
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seed(key: SavedInfraredKey) {
|
|
||||||
keys[key.ref()] = key
|
|
||||||
publish()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onlyKey(): SavedInfraredKey = keys.values.single()
|
|
||||||
|
|
||||||
fun allKeys(): List<SavedInfraredKey> = keys.values.toList()
|
|
||||||
|
|
||||||
private fun publish() {
|
|
||||||
state.value = keys.values.toList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class RecordingInfraredGateway : InfraredGateway {
|
|
||||||
var learnResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
|
||||||
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
|
||||||
var onLearn: ((ProviderProfileId, DeviceName, String) -> DomainResult<Unit>)? = null
|
|
||||||
val learnedKeys = mutableListOf<InfraredLearningCall>()
|
|
||||||
val sentKeys = mutableListOf<InfraredSendCall>()
|
|
||||||
val downloadedCodes = mutableListOf<InfraredCode>()
|
|
||||||
|
|
||||||
override suspend fun learnKey(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
key: String,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
learnedKeys += InfraredLearningCall(profileId, deviceName, key)
|
|
||||||
return onLearn?.invoke(profileId, deviceName, key) ?: learnResult
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun sendKey(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
key: String,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
sentKeys += InfraredSendCall(profileId, deviceName, key)
|
|
||||||
return sendResult
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun downloadCodes(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
codes: List<InfraredCode>,
|
|
||||||
): DomainResult<List<InfraredCodeDownloadResult>> {
|
|
||||||
downloadedCodes += codes
|
|
||||||
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class InMemoryVirtualRemoteRepository : VirtualRemoteRepository {
|
|
||||||
private val remotes = mutableMapOf<VirtualRemoteId, VirtualRemote>()
|
|
||||||
private val bindings = mutableMapOf<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
|
|
||||||
private val state = MutableStateFlow<List<VirtualRemote>>(emptyList())
|
|
||||||
|
|
||||||
override fun observeRemotes(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<VirtualRemote>>> = state.map { values ->
|
|
||||||
DomainResult.Success(values.filter { it.profileId == profileId && it.deviceName == deviceName })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<VirtualRemote?>> = state.map { values ->
|
|
||||||
DomainResult.Success(values.find { it.id == remoteId && it.profileId == profileId })
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<List<RemoteKeyBinding>>> = state.map {
|
|
||||||
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeSlotQuota(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<SlotQuotaSummary>> = state.map { values ->
|
|
||||||
val relevant = values.filter { it.profileId == profileId && it.deviceName == deviceName }
|
|
||||||
val used = relevant.flatMap { bindings[it.id].orEmpty() }
|
|
||||||
.mapNotNull { it.assignedSlot?.toIntOrNull() }
|
|
||||||
.toSet()
|
|
||||||
DomainResult.Success(SlotQuotaSummary(assignedStaticSlots = used))
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<VirtualRemote?> = DomainResult.Success(remotes[remoteId]?.takeIf { it.profileId == profileId })
|
|
||||||
|
|
||||||
override suspend fun getKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<List<RemoteKeyBinding>> = DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
|
|
||||||
|
|
||||||
override suspend fun createRemote(
|
|
||||||
remote: VirtualRemote,
|
|
||||||
initialBindings: List<RemoteKeyBinding>,
|
|
||||||
): DomainResult<VirtualRemoteId> {
|
|
||||||
remotes[remote.id] = remote
|
|
||||||
bindings[remote.id] = initialBindings.toMutableList()
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(remote.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
|
|
||||||
remotes[remote.id] = remote
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
remotes.remove(remoteId)
|
|
||||||
bindings.remove(remoteId)
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
|
|
||||||
val list = bindings[binding.remoteId] ?: mutableListOf()
|
|
||||||
val idx = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
|
|
||||||
if (idx >= 0) list[idx] = binding else list.add(binding)
|
|
||||||
bindings[binding.remoteId] = list
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteKeyBinding(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
buttonId: String,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
|
|
||||||
list.removeAll { it.buttonId == buttonId }
|
|
||||||
bindings[remoteId] = list
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateAcState(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
state: AcStateSnapshot,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
|
|
||||||
remotes[remoteId] = existing.copy(acState = state)
|
|
||||||
publish()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun publish() {
|
|
||||||
state.value = remotes.values.toList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun SavedInfraredKey.ref() = InfraredKeyRef(
|
|
||||||
profileId = profileId,
|
|
||||||
keyId = id,
|
|
||||||
)
|
|
||||||
|
|
||||||
-89
@@ -1,89 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class InfraredKeyFactoryTest {
|
|
||||||
private val id = InfraredKeyId("01J5MOLERABC123")
|
|
||||||
private val factory = InfraredKeyFactory(
|
|
||||||
idGenerator = InfraredKeyIdGenerator { id },
|
|
||||||
clock = EpochMillisClock { 1_724_435_200_000L },
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun createsLearningKeyWithStableAsciiSdkKey() {
|
|
||||||
val result = factory.create(
|
|
||||||
ProviderProfileId("abox"),
|
|
||||||
DeviceName("ir-1"),
|
|
||||||
"空调开关",
|
|
||||||
) as DomainResult.Success
|
|
||||||
|
|
||||||
assertEquals(id, result.value.id)
|
|
||||||
assertEquals("app_ir_01j5molerabc123", result.value.sdkKey)
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, result.value.status)
|
|
||||||
assertEquals(null, result.value.lastErrorCode)
|
|
||||||
assertEquals(null, result.value.lastErrorDetail)
|
|
||||||
assertEquals("空调开关", result.value.displayName)
|
|
||||||
assertEquals(1_724_435_200_000L, result.value.createdAtEpochMillis)
|
|
||||||
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun createsLearningKeyWithCustomNumericSlot() {
|
|
||||||
val result = factory.create(
|
|
||||||
ProviderProfileId("abox"),
|
|
||||||
DeviceName("ir-1"),
|
|
||||||
"空调开",
|
|
||||||
sdkKey = "1",
|
|
||||||
) as DomainResult.Success
|
|
||||||
|
|
||||||
assertEquals("1", result.value.sdkKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsInvalidNumericSlots() {
|
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "0") is DomainResult.Failure)
|
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "101") is DomainResult.Failure)
|
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), "Power", sdkKey = "abc") is DomainResult.Failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsBlankDeviceOrDisplayName() {
|
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1"), " ") is DomainResult.Failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsIdentifiersWithSurroundingWhitespace() {
|
|
||||||
assertTrue(factory.create(ProviderProfileId(" abox"), DeviceName("ir-1"), "Power") is DomainResult.Failure)
|
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName("ir-1 "), "Power") is DomainResult.Failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun rejectsLossyOrOversizedIds() {
|
|
||||||
assertTrue(factoryWithId("ab-c").createValidKey() is DomainResult.Failure)
|
|
||||||
assertTrue(factoryWithId("a".repeat(33)).createValidKey() is DomainResult.Failure)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun canonicalizesStandardUuidId() {
|
|
||||||
val result = factoryWithId("550E8400-E29B-41D4-A716-446655440000").createValidKey() as DomainResult.Success
|
|
||||||
|
|
||||||
assertEquals("app_ir_550e8400e29b41d4a716446655440000", result.value.sdkKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun factoryWithId(value: String) = InfraredKeyFactory(
|
|
||||||
idGenerator = InfraredKeyIdGenerator { InfraredKeyId(value) },
|
|
||||||
clock = EpochMillisClock { 1_724_435_200_000L },
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun InfraredKeyFactory.createValidKey() = create(
|
|
||||||
ProviderProfileId("abox"),
|
|
||||||
DeviceName("ir-1"),
|
|
||||||
"Power",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
-343
@@ -1,343 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class LearnInfraredKeyTest {
|
|
||||||
private val profile = ProviderProfileId("abox")
|
|
||||||
private val device = DeviceName("ir-1")
|
|
||||||
private val keyId = InfraredKeyId("key1")
|
|
||||||
private val clock = SteppingClock(100L)
|
|
||||||
private val factory = InfraredKeyFactory(
|
|
||||||
idGenerator = InfraredKeyIdGenerator { keyId },
|
|
||||||
clock = clock,
|
|
||||||
)
|
|
||||||
private val repository = InMemoryInfraredKeyRepository()
|
|
||||||
private val gateway = RecordingInfraredGateway()
|
|
||||||
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
|
|
||||||
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
|
|
||||||
private val relearn = RelearnInfraredKey(repository, gateway, clock)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun persistsLearningBeforeCallingHardwareThenMarksReady() = runTest {
|
|
||||||
val events = mutableListOf<String>()
|
|
||||||
repository.onInsert = { events += "insert:${it.status}" }
|
|
||||||
gateway.onLearn = { _, _, _ ->
|
|
||||||
events += "hardware"
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
repository.onTransition = { events += "transition:${it.targetStatus}" }
|
|
||||||
|
|
||||||
val result = learn(profile, device, "Power")
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(keyId), result)
|
|
||||||
assertEquals(listOf("insert:LEARNING", "hardware", "transition:READY"), events)
|
|
||||||
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorCode)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun hardwareFailureMarksFailedAndReturnsOriginalError() = runTest {
|
|
||||||
val gatewayError = DomainError.DeviceOffline("20206")
|
|
||||||
gateway.learnResult = DomainResult.Failure(gatewayError)
|
|
||||||
|
|
||||||
val result = learn(profile, device, "Power")
|
|
||||||
|
|
||||||
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
|
||||||
assertEquals("20206", repository.onlyKey().lastErrorCode)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun insertFailureNeverCallsHardware() = runTest {
|
|
||||||
val insertError = DomainError.Unknown(detail = "database unavailable")
|
|
||||||
repository.insertError = insertError
|
|
||||||
|
|
||||||
val result = learn(profile, device, "Power")
|
|
||||||
|
|
||||||
assertEquals(insertError, (result as DomainResult.Failure).error)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun factoryFailureNeverTouchesRepositoryOrHardware() = runTest {
|
|
||||||
val result = learn(profile, device, " ")
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
assertTrue(repository.insertedKeys.isEmpty())
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun readyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
|
|
||||||
val transitionError = DomainError.Unknown(detail = "write conflict")
|
|
||||||
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
|
|
||||||
|
|
||||||
val result = learn(profile, device, "Power")
|
|
||||||
|
|
||||||
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
|
|
||||||
val gatewayError = DomainError.DeviceOffline("20206")
|
|
||||||
gateway.learnResult = DomainResult.Failure(gatewayError)
|
|
||||||
repository.transitionErrors[InfraredKeyStatus.FAILED] =
|
|
||||||
DomainError.Unknown(detail = "write conflict")
|
|
||||||
|
|
||||||
val result = learn(profile, device, "Power")
|
|
||||||
|
|
||||||
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
|
||||||
assertEquals("20206", repository.transitionCalls.single().lastErrorCode)
|
|
||||||
assertEquals(null, repository.transitionCalls.single().lastErrorDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun newLearningUsesExpectedLearningTransitionsAndFreshTimestamps() = runTest {
|
|
||||||
learn(profile, device, "Power")
|
|
||||||
|
|
||||||
val inserted = repository.insertedKeys.single()
|
|
||||||
val transition = repository.transitionCalls.single()
|
|
||||||
assertEquals(setOf(InfraredKeyStatus.LEARNING), transition.expectedStatuses)
|
|
||||||
assertEquals(inserted.ref(), transition.ref)
|
|
||||||
assertEquals(100L, inserted.createdAtEpochMillis)
|
|
||||||
assertEquals(100L, inserted.updatedAtEpochMillis)
|
|
||||||
assertEquals(101L, transition.updatedAtEpochMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryReusesStableIdAndSdkKey() = runTest {
|
|
||||||
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1")
|
|
||||||
repository.seed(original)
|
|
||||||
|
|
||||||
val result = retry(original.ref())
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(Unit), result)
|
|
||||||
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
|
|
||||||
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
|
|
||||||
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
|
||||||
assertEquals(0, repository.insertedKeys.size)
|
|
||||||
assertEquals(2, repository.transitionCalls.size)
|
|
||||||
assertEquals(
|
|
||||||
setOf(InfraredKeyStatus.FAILED),
|
|
||||||
repository.transitionCalls.first().expectedStatuses,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
repository.transitionCalls.last().expectedStatuses,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun relearnReusesStableIdAndSdkKeyForReadyKey() = runTest {
|
|
||||||
val original = failedKey(id = InfraredKeyId("key1"), sdkKey = "app_ir_key1").copy(
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
)
|
|
||||||
repository.seed(original)
|
|
||||||
|
|
||||||
val result = relearn(original.ref())
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(Unit), result)
|
|
||||||
assertEquals("app_ir_key1", gateway.learnedKeys.single().key)
|
|
||||||
assertEquals(InfraredKeyId("key1"), repository.onlyKey().id)
|
|
||||||
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
|
||||||
assertEquals(0, repository.insertedKeys.size)
|
|
||||||
assertEquals(2, repository.transitionCalls.size)
|
|
||||||
assertEquals(
|
|
||||||
setOf(InfraredKeyStatus.READY, InfraredKeyStatus.FAILED, InfraredKeyStatus.LEARNING),
|
|
||||||
repository.transitionCalls.first().expectedStatuses,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
repository.transitionCalls.last().expectedStatuses,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryRejectsAlreadyLearningKey() = runTest {
|
|
||||||
repository.seed(learningKey())
|
|
||||||
|
|
||||||
val result = retry(repository.onlyKey().ref())
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryRejectsMissingKey() = runTest {
|
|
||||||
val result = retry(InfraredKeyRef(profile, InfraredKeyId("missing")))
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryReturnsGetFailureWithoutCallingHardware() = runTest {
|
|
||||||
val getError = DomainError.Unknown(detail = "database unavailable")
|
|
||||||
repository.getError = getError
|
|
||||||
|
|
||||||
val result = retry(InfraredKeyRef(profile, keyId))
|
|
||||||
|
|
||||||
assertEquals(getError, (result as DomainResult.Failure).error)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryDoesNotCallHardwareWhenLearningTransitionFails() = runTest {
|
|
||||||
val original = failedKey()
|
|
||||||
repository.seed(original)
|
|
||||||
val transitionError = DomainError.Unknown(detail = "write conflict")
|
|
||||||
repository.transitionErrors[InfraredKeyStatus.LEARNING] = transitionError
|
|
||||||
|
|
||||||
val result = retry(original.ref())
|
|
||||||
|
|
||||||
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryExpectedStatusFailurePreventsHardwareAfterConcurrentStatusChange() = runTest {
|
|
||||||
val original = failedKey()
|
|
||||||
repository.seed(original)
|
|
||||||
repository.onTransition = { call ->
|
|
||||||
if (call.targetStatus == InfraredKeyStatus.LEARNING) {
|
|
||||||
repository.seed(original.copy(status = InfraredKeyStatus.LEARNING))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val result = retry(original.ref())
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryUsesSeparateTimestampsAndClearsErrorsBeforeHardware() = runTest {
|
|
||||||
val original = failedKey()
|
|
||||||
repository.seed(original)
|
|
||||||
gateway.onLearn = { _, _, _ ->
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorCode)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
|
||||||
assertEquals(100L, repository.onlyKey().updatedAtEpochMillis)
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(Unit), retry(original.ref()))
|
|
||||||
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorCode)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
|
||||||
assertEquals(101L, repository.onlyKey().updatedAtEpochMillis)
|
|
||||||
assertEquals(listOf(100L, 101L), repository.transitionCalls.map { it.updatedAtEpochMillis })
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryGatewayFailureMarksFailedWithExternalCodeAndReturnsOriginalError() = runTest {
|
|
||||||
val original = failedKey()
|
|
||||||
repository.seed(original)
|
|
||||||
val gatewayError = DomainError.DeviceOffline("20206")
|
|
||||||
gateway.learnResult = DomainResult.Failure(gatewayError)
|
|
||||||
|
|
||||||
val result = retry(original.ref())
|
|
||||||
|
|
||||||
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
|
||||||
assertEquals("20206", repository.onlyKey().lastErrorCode)
|
|
||||||
assertEquals(null, repository.onlyKey().lastErrorDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryFailedTransitionErrorDoesNotReplaceGatewayFailure() = runTest {
|
|
||||||
val original = failedKey()
|
|
||||||
repository.seed(original)
|
|
||||||
val gatewayError = DomainError.DeviceOffline("20206")
|
|
||||||
gateway.learnResult = DomainResult.Failure(gatewayError)
|
|
||||||
repository.transitionErrors[InfraredKeyStatus.FAILED] =
|
|
||||||
DomainError.Unknown(detail = "write conflict")
|
|
||||||
|
|
||||||
val result = retry(original.ref())
|
|
||||||
|
|
||||||
assertEquals(gatewayError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
|
||||||
assertEquals("20206", repository.transitionCalls.last().lastErrorCode)
|
|
||||||
assertEquals(null, repository.transitionCalls.last().lastErrorDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun retryReadyTransitionFailureIsReturnedAfterHardwareSuccess() = runTest {
|
|
||||||
val original = failedKey()
|
|
||||||
repository.seed(original)
|
|
||||||
val transitionError = DomainError.Unknown(detail = "write conflict")
|
|
||||||
repository.transitionErrors[InfraredKeyStatus.READY] = transitionError
|
|
||||||
|
|
||||||
val result = retry(original.ref())
|
|
||||||
|
|
||||||
assertEquals(transitionError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.onlyKey().status)
|
|
||||||
assertEquals(1, gateway.learnedKeys.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun learningCancellationTransitionsStatusToFailed() = runTest {
|
|
||||||
gateway.onLearn = { _, _, _ ->
|
|
||||||
throw kotlinx.coroutines.CancellationException("User cancelled")
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
learn(profile, device, "Power")
|
|
||||||
} catch (_: kotlinx.coroutines.CancellationException) {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, repository.onlyKey().status)
|
|
||||||
assertEquals("Cancelled", repository.onlyKey().lastErrorDetail)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun failedKey(
|
|
||||||
id: InfraredKeyId = keyId,
|
|
||||||
sdkKey: String = "app_ir_key1",
|
|
||||||
) = SavedInfraredKey(
|
|
||||||
id = id,
|
|
||||||
profileId = profile,
|
|
||||||
deviceName = device,
|
|
||||||
displayName = "Power",
|
|
||||||
sdkKey = sdkKey,
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "old-error",
|
|
||||||
lastErrorDetail = "old-detail",
|
|
||||||
createdAtEpochMillis = 1L,
|
|
||||||
updatedAtEpochMillis = 2L,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun learningKey() = failedKey().copy(
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SteppingClock(start: Long) : EpochMillisClock {
|
|
||||||
private var next = start
|
|
||||||
|
|
||||||
override fun now(): Long = next++
|
|
||||||
}
|
|
||||||
-251
@@ -1,251 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class ManageInfraredKeyTest {
|
|
||||||
private val profile = ProviderProfileId("abox")
|
|
||||||
private val otherProfile = ProviderProfileId("other")
|
|
||||||
private val repository = InMemoryInfraredKeyRepository()
|
|
||||||
private val gateway = RecordingInfraredGateway()
|
|
||||||
private val clock = SteppingManagementClock(500L)
|
|
||||||
private val send = SendInfraredKey(repository, gateway)
|
|
||||||
private val rename = RenameInfraredKey(repository, clock)
|
|
||||||
private val delete = DeleteInfraredKey(repository)
|
|
||||||
private val recover = RecoverInterruptedInfraredLearning(repository, clock)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sendsReadyKeyUsingStoredDeviceAndSdkKeyWithoutRepositoryWrites() = runTest {
|
|
||||||
val key = readyKey(deviceName = DeviceName("ir-living"), sdkKey = "app_ir_power")
|
|
||||||
repository.seed(key)
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(Unit), send(key.ref()))
|
|
||||||
assertEquals(
|
|
||||||
InfraredSendCall(profile, DeviceName("ir-living"), "app_ir_power"),
|
|
||||||
gateway.sentKeys.single(),
|
|
||||||
)
|
|
||||||
assertEquals(listOf(key.ref()), repository.getCalls)
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
assertTrue(repository.renameCalls.isEmpty())
|
|
||||||
assertTrue(repository.deleteCalls.isEmpty())
|
|
||||||
assertTrue(repository.recoveryCalls.isEmpty())
|
|
||||||
assertEquals(key, repository.onlyKey())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sendFailureDoesNotDemoteReadyKey() = runTest {
|
|
||||||
val key = readyKey()
|
|
||||||
repository.seed(key)
|
|
||||||
val gatewayError = DomainError.OperationFailed("20205")
|
|
||||||
gateway.sendResult = DomainResult.Failure(gatewayError)
|
|
||||||
|
|
||||||
assertEquals(gatewayError, (send(key.ref()) as DomainResult.Failure).error)
|
|
||||||
assertEquals(
|
|
||||||
listOf(InfraredSendCall(profile, DeviceName("ir-1"), "app_ir_key1")),
|
|
||||||
gateway.sentKeys,
|
|
||||||
)
|
|
||||||
assertEquals(InfraredKeyStatus.READY, repository.onlyKey().status)
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun missingSendReturnsNoDataWithoutHardwareCall() = runTest {
|
|
||||||
val result = send(InfraredKeyRef(profile, InfraredKeyId("missing")))
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.NoData)
|
|
||||||
assertEquals(
|
|
||||||
listOf(InfraredKeyRef(profile, InfraredKeyId("missing"))),
|
|
||||||
repository.getCalls,
|
|
||||||
)
|
|
||||||
assertTrue(gateway.sentKeys.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun nonReadySendReturnsInvalidRequestWithoutHardwareCall() = runTest {
|
|
||||||
repository.seed(failedKey())
|
|
||||||
|
|
||||||
val result = send(repository.onlyKey().ref())
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
assertTrue(gateway.sentKeys.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sendReturnsRepositoryFailureWithoutHardwareCall() = runTest {
|
|
||||||
val repositoryError = DomainError.Unknown(detail = "database unavailable")
|
|
||||||
repository.getError = repositoryError
|
|
||||||
|
|
||||||
val result = send(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
|
||||||
|
|
||||||
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
|
||||||
assertTrue(gateway.sentKeys.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renameTrimsDisplayNameAndPreservesStableIdentityAndErrors() = runTest {
|
|
||||||
val before = readyKey(displayName = "Power", sdkKey = "app_ir_power").copy(
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "20206",
|
|
||||||
lastErrorDetail = "fixed-detail",
|
|
||||||
)
|
|
||||||
repository.seed(before)
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(Unit), rename(before.ref(), " 空调开关 "))
|
|
||||||
|
|
||||||
val after = repository.onlyKey()
|
|
||||||
assertEquals("空调开关", after.displayName)
|
|
||||||
assertEquals(before.sdkKey, after.sdkKey)
|
|
||||||
assertEquals(before.id, after.id)
|
|
||||||
assertEquals(before.lastErrorCode, after.lastErrorCode)
|
|
||||||
assertEquals(before.lastErrorDetail, after.lastErrorDetail)
|
|
||||||
assertEquals(before.createdAtEpochMillis, after.createdAtEpochMillis)
|
|
||||||
assertEquals(500L, after.updatedAtEpochMillis)
|
|
||||||
assertEquals(
|
|
||||||
InfraredRenameCall(before.ref(), "空调开关", 500L),
|
|
||||||
repository.renameCalls.single(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renameRejectsBlankDisplayNameWithoutRepositoryCall() = runTest {
|
|
||||||
val key = readyKey()
|
|
||||||
repository.seed(key)
|
|
||||||
|
|
||||||
val result = rename(key.ref(), " \t ")
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
assertTrue(repository.renameCalls.isEmpty())
|
|
||||||
assertEquals(key, repository.onlyKey())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renameReturnsRepositoryFailure() = runTest {
|
|
||||||
val key = readyKey()
|
|
||||||
repository.seed(key)
|
|
||||||
val repositoryError = DomainError.Unknown(detail = "write failed")
|
|
||||||
repository.renameError = repositoryError
|
|
||||||
|
|
||||||
val result = rename(key.ref(), "New name")
|
|
||||||
|
|
||||||
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals("Power", repository.onlyKey().displayName)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteDelegatesAtomicLearningGuardToRepository() = runTest {
|
|
||||||
val key = learningKey()
|
|
||||||
repository.seed(key)
|
|
||||||
|
|
||||||
val result = delete(key.ref())
|
|
||||||
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
assertEquals(listOf(key.ref()), repository.deleteCalls)
|
|
||||||
assertTrue(repository.getCalls.isEmpty())
|
|
||||||
assertTrue(repository.allKeys().isNotEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteRemovesReadyKey() = runTest {
|
|
||||||
val key = readyKey()
|
|
||||||
repository.seed(key)
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(Unit), delete(key.ref()))
|
|
||||||
assertEquals(listOf(key.ref()), repository.deleteCalls)
|
|
||||||
assertTrue(repository.getCalls.isEmpty())
|
|
||||||
assertTrue(repository.allKeys().isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoveryDelegatesOnceAndMarksOnlyProfileLearningKeysFailed() = runTest {
|
|
||||||
val learningOne = learningKey(id = InfraredKeyId("one"), lastErrorCode = "old")
|
|
||||||
val learningTwo = learningKey(id = InfraredKeyId("two"))
|
|
||||||
val ready = readyKey(id = InfraredKeyId("ready"))
|
|
||||||
val other = learningKey(id = InfraredKeyId("other"), profileId = otherProfile)
|
|
||||||
repository.seed(learningOne)
|
|
||||||
repository.seed(learningTwo)
|
|
||||||
repository.seed(ready)
|
|
||||||
repository.seed(other)
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(2), recover(profile))
|
|
||||||
assertEquals(
|
|
||||||
listOf(InfraredRecoveryCall(profile, 500L)),
|
|
||||||
repository.recoveryCalls,
|
|
||||||
)
|
|
||||||
assertTrue(repository.getCalls.isEmpty())
|
|
||||||
assertTrue(repository.transitionCalls.isEmpty())
|
|
||||||
val recovered = repository.allKeys().filter { it.profileId == profile && it.id.value in setOf("one", "two") }
|
|
||||||
assertTrue(recovered.all { it.status == InfraredKeyStatus.FAILED })
|
|
||||||
assertTrue(recovered.all { it.lastErrorCode == null })
|
|
||||||
assertTrue(
|
|
||||||
recovered.all {
|
|
||||||
it.lastErrorDetail == "Interrupted learning result is unknown; retry required"
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assertTrue(recovered.all { it.updatedAtEpochMillis == 500L })
|
|
||||||
assertEquals(InfraredKeyStatus.READY, repository.allKeys().single { it.id.value == "ready" }.status)
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.allKeys().single { it.profileId == otherProfile }.status)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertTrue(gateway.sentKeys.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun recoveryReturnsRepositoryFailureWithoutHardwareCall() = runTest {
|
|
||||||
val repositoryError = DomainError.Unknown(detail = "write failed")
|
|
||||||
repository.recoveryError = repositoryError
|
|
||||||
|
|
||||||
val result = recover(profile)
|
|
||||||
|
|
||||||
assertEquals(repositoryError, (result as DomainResult.Failure).error)
|
|
||||||
assertEquals(listOf(InfraredRecoveryCall(profile, 500L)), repository.recoveryCalls)
|
|
||||||
assertTrue(gateway.learnedKeys.isEmpty())
|
|
||||||
assertTrue(gateway.sentKeys.isEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun readyKey(
|
|
||||||
id: InfraredKeyId = InfraredKeyId("key1"),
|
|
||||||
profileId: ProviderProfileId = profile,
|
|
||||||
deviceName: DeviceName = DeviceName("ir-1"),
|
|
||||||
displayName: String = "Power",
|
|
||||||
sdkKey: String = "app_ir_key1",
|
|
||||||
) = SavedInfraredKey(
|
|
||||||
id = id,
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
displayName = displayName,
|
|
||||||
sdkKey = sdkKey,
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = null,
|
|
||||||
createdAtEpochMillis = 1L,
|
|
||||||
updatedAtEpochMillis = 2L,
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun failedKey() = readyKey().copy(
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "old-error",
|
|
||||||
lastErrorDetail = "old-detail",
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun learningKey(
|
|
||||||
id: InfraredKeyId = InfraredKeyId("key1"),
|
|
||||||
profileId: ProviderProfileId = profile,
|
|
||||||
lastErrorCode: String? = null,
|
|
||||||
lastErrorDetail: String? = null,
|
|
||||||
) = readyKey(id = id, profileId = profileId).copy(
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private class SteppingManagementClock(start: Long) : EpochMillisClock {
|
|
||||||
private var next = start
|
|
||||||
|
|
||||||
override fun now(): Long = next++
|
|
||||||
}
|
|
||||||
-119
@@ -1,119 +0,0 @@
|
|||||||
package com.flagship.abox.manager.domain.infrared
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class VirtualRemoteUseCaseTest {
|
|
||||||
|
|
||||||
private val profileId = ProviderProfileId("profile1")
|
|
||||||
private val deviceName = DeviceName("ir-1")
|
|
||||||
private val clock = EpochMillisClock { 10_000L }
|
|
||||||
|
|
||||||
private lateinit var repository: InMemoryVirtualRemoteRepository
|
|
||||||
private lateinit var gateway: RecordingInfraredGateway
|
|
||||||
private lateinit var createRemote: CreateVirtualRemote
|
|
||||||
private lateinit var sendAction: SendVirtualRemoteAction
|
|
||||||
private lateinit var deleteRemote: DeleteVirtualRemote
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setup() {
|
|
||||||
repository = InMemoryVirtualRemoteRepository()
|
|
||||||
gateway = RecordingInfraredGateway()
|
|
||||||
createRemote = CreateVirtualRemote(repository, gateway, clock)
|
|
||||||
sendAction = SendVirtualRemoteAction(repository, gateway)
|
|
||||||
deleteRemote = DeleteVirtualRemote(repository)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun createVirtualRemoteWithStaticSlotsDownloadsPresetCodesToHardware() = runTest {
|
|
||||||
val result = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
displayName = "Living Room TV",
|
|
||||||
type = RemoteDeviceType.TV,
|
|
||||||
brandModel = "generic_tv",
|
|
||||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(result is DomainResult.Success)
|
|
||||||
val remoteId = (result as DomainResult.Success).value
|
|
||||||
|
|
||||||
// Check remote created in repository
|
|
||||||
val remote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
|
|
||||||
assertNotNull(remote)
|
|
||||||
assertEquals("Living Room TV", remote?.displayName)
|
|
||||||
assertEquals(TransmissionMode.STATIC_SLOTS, remote?.transmissionMode)
|
|
||||||
|
|
||||||
// Check preset codes downloaded to hardware gateway
|
|
||||||
assertTrue(gateway.downloadedCodes.isNotEmpty())
|
|
||||||
|
|
||||||
// Send a button in static mode -> directly triggers sendKey with assigned slot
|
|
||||||
val sendRes = sendAction.sendButton(profileId, remoteId, "TV_POWER")
|
|
||||||
assertTrue(sendRes is DomainResult.Success)
|
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
assertEquals("1", gateway.sentKeys.first().key)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun sendVirtualRemoteActionForAcUpdatesStateAndDownloadsToScratchSlot() = runTest {
|
|
||||||
val createResult = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
displayName = "Bedroom AC",
|
|
||||||
type = RemoteDeviceType.AIR_CONDITIONER,
|
|
||||||
brandModel = "gree_ac",
|
|
||||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
)
|
|
||||||
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
|
|
||||||
|
|
||||||
val newAcState = AcStateSnapshot(
|
|
||||||
power = true,
|
|
||||||
mode = AcMode.COOL,
|
|
||||||
temperature = 27,
|
|
||||||
fanSpeed = AcFanSpeed.HIGH,
|
|
||||||
verticalSwing = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
val sendRes = sendAction.sendAcState(profileId, remoteId, newAcState)
|
|
||||||
assertTrue(sendRes is DomainResult.Success)
|
|
||||||
|
|
||||||
// 1. Downloaded to scratch slot 100
|
|
||||||
assertEquals(1, gateway.downloadedCodes.size)
|
|
||||||
val downloaded = gateway.downloadedCodes.first()
|
|
||||||
assertEquals("100", downloaded.key)
|
|
||||||
assertTrue(downloaded.code.startsWith("0x"))
|
|
||||||
|
|
||||||
// 2. Transmitted via sendKey with slot 100
|
|
||||||
assertEquals(1, gateway.sentKeys.size)
|
|
||||||
assertEquals("100", gateway.sentKeys.first().key)
|
|
||||||
|
|
||||||
// 3. State machine updated in repository
|
|
||||||
val updatedRemote = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
|
|
||||||
assertEquals(27, updatedRemote?.acState?.temperature)
|
|
||||||
assertEquals(AcFanSpeed.HIGH, updatedRemote?.acState?.fanSpeed)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteVirtualRemoteRemovesFromRepository() = runTest {
|
|
||||||
val createResult = createRemote(
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
displayName = "Fan",
|
|
||||||
type = RemoteDeviceType.FAN,
|
|
||||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
|
||||||
)
|
|
||||||
val remoteId = (createResult as DomainResult.Success<VirtualRemoteId>).value
|
|
||||||
|
|
||||||
deleteRemote(profileId, remoteId)
|
|
||||||
|
|
||||||
val fetched = (repository.getRemote(profileId, remoteId) as DomainResult.Success<VirtualRemote?>).value
|
|
||||||
assertEquals(null, fetched)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,4 +3,3 @@ org.gradle.configuration-cache=false
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
android.overridePathCheck=true
|
|
||||||
|
|||||||
@@ -35,8 +35,6 @@ hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt"
|
|||||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||||
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||||
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" }
|
||||||
room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
|
||||||
room-testing = { module = "androidx.room:room-testing", version.ref = "room" }
|
|
||||||
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
androidx-junit = { module = "androidx.test.ext:junit", version.ref = "androidxJunit" }
|
||||||
|
|||||||
@@ -28,5 +28,4 @@ include(":absdk-bridge")
|
|||||||
include(":testing:fakes")
|
include(":testing:fakes")
|
||||||
include(":android:core:designsystem")
|
include(":android:core:designsystem")
|
||||||
include(":android:core:session")
|
include(":android:core:session")
|
||||||
include(":android:feature:infrared")
|
|
||||||
include(":android:app")
|
include(":android:app")
|
||||||
|
|||||||
+9
-40
@@ -12,8 +12,8 @@ import com.flagship.abox.manager.domain.SocketPowerState
|
|||||||
import com.flagship.abox.manager.domain.SocketStatus
|
import com.flagship.abox.manager.domain.SocketStatus
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
import com.flagship.abox.manager.domain.TemperatureHumidityGateway
|
||||||
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
||||||
|
import java.util.Collections
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
|
||||||
|
|
||||||
class FakeSocketGateway : SocketGateway {
|
class FakeSocketGateway : SocketGateway {
|
||||||
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
private val states = ConcurrentHashMap<DeviceKey, SocketPowerState>()
|
||||||
@@ -78,11 +78,10 @@ class FakeTemperatureHumidityGateway : TemperatureHumidityGateway {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class FakeInfraredGateway : InfraredGateway {
|
class FakeInfraredGateway : InfraredGateway {
|
||||||
val sentKeys = CopyOnWriteArrayList<InfraredCall>()
|
val sentKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||||
val learnedKeys = CopyOnWriteArrayList<InfraredCall>()
|
val learnedKeys: MutableList<InfraredCall> = Collections.synchronizedList(mutableListOf())
|
||||||
val downloadCalls = CopyOnWriteArrayList<InfraredDownloadCall>()
|
val downloadCalls: MutableList<InfraredDownloadCall> =
|
||||||
private val sendResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
Collections.synchronizedList(mutableListOf())
|
||||||
private val learnResults = ConcurrentHashMap<InfraredOperationKey, DomainResult<Unit>>()
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
var sendResult: DomainResult<Unit> = DomainResult.Success(Unit)
|
||||||
@@ -99,7 +98,7 @@ class FakeInfraredGateway : InfraredGateway {
|
|||||||
key: String,
|
key: String,
|
||||||
): DomainResult<Unit> {
|
): DomainResult<Unit> {
|
||||||
sentKeys += InfraredCall(profileId, deviceName, key)
|
sentKeys += InfraredCall(profileId, deviceName, key)
|
||||||
return sendResults[InfraredOperationKey(profileId, deviceName, key)] ?: sendResult
|
return sendResult
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun learnKey(
|
override suspend fun learnKey(
|
||||||
@@ -108,40 +107,16 @@ class FakeInfraredGateway : InfraredGateway {
|
|||||||
key: String,
|
key: String,
|
||||||
): DomainResult<Unit> {
|
): DomainResult<Unit> {
|
||||||
learnedKeys += InfraredCall(profileId, deviceName, key)
|
learnedKeys += InfraredCall(profileId, deviceName, key)
|
||||||
return learnResults[InfraredOperationKey(profileId, deviceName, key)] ?: learnResult
|
return learnResult
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSendResult(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
key: String,
|
|
||||||
result: DomainResult<Unit>,
|
|
||||||
) {
|
|
||||||
sendResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setLearnResult(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
key: String,
|
|
||||||
result: DomainResult<Unit>,
|
|
||||||
) {
|
|
||||||
learnResults[InfraredOperationKey(profileId, deviceName, key)] = result
|
|
||||||
}
|
|
||||||
|
|
||||||
val downloadedCodes: List<InfraredCode>
|
|
||||||
get() = downloadCalls.flatMap { it.codes }
|
|
||||||
|
|
||||||
override suspend fun downloadCodes(
|
override suspend fun downloadCodes(
|
||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
codes: List<InfraredCode>,
|
codes: List<InfraredCode>,
|
||||||
): DomainResult<List<InfraredCodeDownloadResult>> {
|
): DomainResult<List<InfraredCodeDownloadResult>> {
|
||||||
val codesSnapshot = codes.toList()
|
|
||||||
downloadError?.let { return DomainResult.Failure(it) }
|
downloadError?.let { return DomainResult.Failure(it) }
|
||||||
downloadCalls += InfraredDownloadCall(profileId, codesSnapshot)
|
downloadCalls += InfraredDownloadCall(profileId, codes)
|
||||||
return DomainResult.Success(
|
return DomainResult.Success(codes.map { InfraredCodeDownloadResult(it, successful = true) })
|
||||||
codesSnapshot.map { InfraredCodeDownloadResult(it, successful = true) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,12 +131,6 @@ data class InfraredDownloadCall(
|
|||||||
val codes: List<InfraredCode>,
|
val codes: List<InfraredCode>,
|
||||||
)
|
)
|
||||||
|
|
||||||
private data class InfraredOperationKey(
|
|
||||||
val profileId: ProviderProfileId,
|
|
||||||
val deviceName: DeviceName,
|
|
||||||
val key: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private data class DeviceKey(
|
private data class DeviceKey(
|
||||||
val profileId: ProviderProfileId,
|
val profileId: ProviderProfileId,
|
||||||
val deviceName: DeviceName,
|
val deviceName: DeviceName,
|
||||||
|
|||||||
-165
@@ -1,165 +0,0 @@
|
|||||||
package com.flagship.abox.manager.testing.fakes
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
|
|
||||||
class FakeInfraredKeyRepository : InfraredKeyRepository {
|
|
||||||
private val lock = Any()
|
|
||||||
private val keys = linkedMapOf<InfraredKeyRef, SavedInfraredKey>()
|
|
||||||
private val state = MutableStateFlow<List<SavedInfraredKey>>(emptyList())
|
|
||||||
|
|
||||||
override fun observeKeys(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<SavedInfraredKey>>> = state.map { values ->
|
|
||||||
DomainResult.Success(
|
|
||||||
values.filter { it.profileId == profileId && it.deviceName == deviceName },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> =
|
|
||||||
synchronized(lock) {
|
|
||||||
DomainResult.Success(keys[ref])
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> = synchronized(lock) {
|
|
||||||
val ref = key.ref()
|
|
||||||
if (keys.keys.any { it.keyId == key.id }) {
|
|
||||||
return@synchronized invalidRequest("Infrared key id already exists")
|
|
||||||
}
|
|
||||||
if (keys.values.any {
|
|
||||||
it.profileId == key.profileId &&
|
|
||||||
it.deviceName == key.deviceName &&
|
|
||||||
it.sdkKey == key.sdkKey
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
return@synchronized invalidRequest("Infrared key binding already exists")
|
|
||||||
}
|
|
||||||
keys[ref] = key
|
|
||||||
publishLocked()
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun transitionStatus(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
expectedStatuses: Set<InfraredKeyStatus>,
|
|
||||||
targetStatus: InfraredKeyStatus,
|
|
||||||
lastErrorCode: String?,
|
|
||||||
lastErrorDetail: String?,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit> = synchronized(lock) {
|
|
||||||
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
|
||||||
if (existing.status !in expectedStatuses) {
|
|
||||||
return@synchronized invalidRequest("Infrared key status changed")
|
|
||||||
}
|
|
||||||
keys[ref] = existing.copy(
|
|
||||||
status = targetStatus,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
publishLocked()
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun rename(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
displayName: String,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Unit> = synchronized(lock) {
|
|
||||||
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
|
||||||
keys[ref] = existing.copy(
|
|
||||||
displayName = displayName,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
publishLocked()
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> =
|
|
||||||
synchronized(lock) {
|
|
||||||
val existing = keys[ref] ?: return@synchronized keyNotFound()
|
|
||||||
if (existing.status == InfraredKeyStatus.LEARNING) {
|
|
||||||
return@synchronized invalidRequest("Cannot delete a learning infrared key")
|
|
||||||
}
|
|
||||||
keys.remove(ref)
|
|
||||||
publishLocked()
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun failInterruptedLearning(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
cutoffEpochMillis: Long,
|
|
||||||
updatedAtEpochMillis: Long,
|
|
||||||
): DomainResult<Int> = synchronized(lock) {
|
|
||||||
var changed = 0
|
|
||||||
keys.replaceAll { _, key ->
|
|
||||||
if (key.profileId == profileId && key.status == InfraredKeyStatus.LEARNING && key.createdAtEpochMillis <= cutoffEpochMillis) {
|
|
||||||
changed += 1
|
|
||||||
key.copy(
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = null,
|
|
||||||
lastErrorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed > 0) publishLocked()
|
|
||||||
DomainResult.Success(changed)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun publishLocked() {
|
|
||||||
state.value = keys.values.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun keyNotFound(): DomainResult.Failure =
|
|
||||||
invalidRequest("Infrared key not found")
|
|
||||||
|
|
||||||
private fun invalidRequest(detail: String): DomainResult.Failure =
|
|
||||||
DomainResult.Failure(DomainError.InvalidRequest(detail = detail))
|
|
||||||
}
|
|
||||||
|
|
||||||
class FakeInfraredDeviceConfigurationRepository : InfraredDeviceConfigurationRepository {
|
|
||||||
private val lock = Any()
|
|
||||||
private val deviceNames = MutableStateFlow<Map<ProviderProfileId, DeviceName>>(emptyMap())
|
|
||||||
var observeError: DomainError? = null
|
|
||||||
|
|
||||||
override fun observeDeviceName(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
): Flow<DomainResult<DeviceName?>> = deviceNames.map { values ->
|
|
||||||
observeError?.let { return@map DomainResult.Failure(it) }
|
|
||||||
DomainResult.Success(values[profileId])
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun setDeviceName(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): DomainResult<Unit> = synchronized(lock) {
|
|
||||||
val value = deviceName.value
|
|
||||||
if (value.isBlank() || value != value.trim()) {
|
|
||||||
return@synchronized DomainResult.Failure(
|
|
||||||
DomainError.InvalidRequest(detail = "Infrared device name must be trimmed and non-blank"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
deviceNames.value = deviceNames.value + (profileId to deviceName)
|
|
||||||
DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SavedInfraredKey.ref() = InfraredKeyRef(
|
|
||||||
profileId = profileId,
|
|
||||||
keyId = id,
|
|
||||||
)
|
|
||||||
-137
@@ -1,137 +0,0 @@
|
|||||||
package com.flagship.abox.manager.testing.fakes
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
|
||||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
class FakeVirtualRemoteRepository : VirtualRemoteRepository {
|
|
||||||
|
|
||||||
private val remotes = ConcurrentHashMap<VirtualRemoteId, VirtualRemote>()
|
|
||||||
private val bindings = ConcurrentHashMap<VirtualRemoteId, MutableList<RemoteKeyBinding>>()
|
|
||||||
private val changeTrigger = MutableStateFlow(0L)
|
|
||||||
|
|
||||||
override fun observeRemotes(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<List<VirtualRemote>>> = changeTrigger.map {
|
|
||||||
DomainResult.Success(
|
|
||||||
remotes.values.filter { it.profileId == profileId && it.deviceName == deviceName }
|
|
||||||
.sortedByDescending { it.createdAtEpochMillis },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<VirtualRemote?>> = changeTrigger.map {
|
|
||||||
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
|
|
||||||
DomainResult.Success(remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): Flow<DomainResult<List<RemoteKeyBinding>>> = changeTrigger.map {
|
|
||||||
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun observeSlotQuota(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
deviceName: DeviceName,
|
|
||||||
): Flow<DomainResult<SlotQuotaSummary>> = changeTrigger.map {
|
|
||||||
val relevantBindings = remotes.values
|
|
||||||
.filter { it.profileId == profileId && it.deviceName == deviceName }
|
|
||||||
.flatMap { bindings[it.id].orEmpty() }
|
|
||||||
val usedSlots = relevantBindings.mapNotNull { it.assignedSlot?.toIntOrNull() }.toSet()
|
|
||||||
DomainResult.Success(
|
|
||||||
SlotQuotaSummary(
|
|
||||||
totalCapacity = 100,
|
|
||||||
dynamicScratchSlot = 100,
|
|
||||||
assignedStaticSlots = usedSlots,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<VirtualRemote?> {
|
|
||||||
val remote = remotes[remoteId]?.takeIf { it.profileId == profileId }
|
|
||||||
return DomainResult.Success(remote)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getKeyBindings(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<List<RemoteKeyBinding>> =
|
|
||||||
DomainResult.Success(bindings[remoteId]?.toList().orEmpty())
|
|
||||||
|
|
||||||
override suspend fun createRemote(
|
|
||||||
remote: VirtualRemote,
|
|
||||||
initialBindings: List<RemoteKeyBinding>,
|
|
||||||
): DomainResult<VirtualRemoteId> {
|
|
||||||
remotes[remote.id] = remote
|
|
||||||
bindings[remote.id] = initialBindings.toMutableList()
|
|
||||||
changeTrigger.value = System.currentTimeMillis()
|
|
||||||
return DomainResult.Success(remote.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> {
|
|
||||||
remotes[remote.id] = remote
|
|
||||||
changeTrigger.value = System.currentTimeMillis()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteRemote(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
remotes.remove(remoteId)
|
|
||||||
bindings.remove(remoteId)
|
|
||||||
changeTrigger.value = System.currentTimeMillis()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> {
|
|
||||||
val list = bindings[binding.remoteId] ?: mutableListOf()
|
|
||||||
val index = list.indexOfFirst { it.id == binding.id || it.buttonId == binding.buttonId }
|
|
||||||
if (index >= 0) {
|
|
||||||
list[index] = binding
|
|
||||||
} else {
|
|
||||||
list.add(binding)
|
|
||||||
}
|
|
||||||
bindings[binding.remoteId] = list
|
|
||||||
changeTrigger.value = System.currentTimeMillis()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun deleteKeyBinding(
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
buttonId: String,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val list = bindings[remoteId] ?: return DomainResult.Success(Unit)
|
|
||||||
list.removeAll { it.buttonId == buttonId }
|
|
||||||
bindings[remoteId] = list
|
|
||||||
changeTrigger.value = System.currentTimeMillis()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun updateAcState(
|
|
||||||
profileId: ProviderProfileId,
|
|
||||||
remoteId: VirtualRemoteId,
|
|
||||||
state: AcStateSnapshot,
|
|
||||||
): DomainResult<Unit> {
|
|
||||||
val existing = remotes[remoteId] ?: return DomainResult.Success(Unit)
|
|
||||||
remotes[remoteId] = existing.copy(acState = state)
|
|
||||||
changeTrigger.value = System.currentTimeMillis()
|
|
||||||
return DomainResult.Success(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-121
@@ -4,14 +4,8 @@ import com.flagship.abox.manager.domain.DeviceName
|
|||||||
import com.flagship.abox.manager.domain.DomainError
|
import com.flagship.abox.manager.domain.DomainError
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
import com.flagship.abox.manager.domain.DomainResult
|
||||||
import com.flagship.abox.manager.domain.InfraredCode
|
import com.flagship.abox.manager.domain.InfraredCode
|
||||||
import com.flagship.abox.manager.domain.InfraredCodeDownloadResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
import com.flagship.abox.manager.domain.SocketPowerState
|
import com.flagship.abox.manager.domain.SocketPowerState
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue
|
|
||||||
import java.util.concurrent.CountDownLatch
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.joinAll
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
@@ -60,119 +54,4 @@ class FakeDeviceGatewaysTest {
|
|||||||
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
|
assertEquals(InfraredDownloadCall(profileOne, listOf(code)), gateway.downloadCalls.single())
|
||||||
assertTrue(download is DomainResult.Success)
|
assertTrue(download is DomainResult.Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredDownloadRecordsAndReturnsAnInputSnapshot() = runTest {
|
|
||||||
val gateway = FakeInfraredGateway()
|
|
||||||
val code = InfraredCode(DeviceName("ir-1"), "power", "code-value")
|
|
||||||
val mutableCodes = mutableListOf(code)
|
|
||||||
|
|
||||||
val result = gateway.downloadCodes(profileOne, mutableCodes)
|
|
||||||
mutableCodes.clear()
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
InfraredDownloadCall(profileOne, listOf(code)),
|
|
||||||
gateway.downloadCalls.single(),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(listOf(InfraredCodeDownloadResult(code, successful = true))),
|
|
||||||
result,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredCallLogCanBeIteratedSafelyAroundConcurrentWrites() = runTest {
|
|
||||||
val gateway = FakeInfraredGateway()
|
|
||||||
val deviceName = DeviceName("ir-1")
|
|
||||||
gateway.sendKey(profileOne, deviceName, "initial")
|
|
||||||
val staleIterators = List(10) { gateway.sentKeys.iterator() }
|
|
||||||
val start = CountDownLatch(1)
|
|
||||||
val failures = ConcurrentLinkedQueue<Throwable>()
|
|
||||||
val writerCount = 4
|
|
||||||
val callsPerWriter = 100
|
|
||||||
|
|
||||||
val writers = List(writerCount) { writerIndex ->
|
|
||||||
launch(Dispatchers.Default) {
|
|
||||||
start.await()
|
|
||||||
repeat(callsPerWriter) { callIndex ->
|
|
||||||
gateway.sendKey(profileOne, deviceName, "$writerIndex-$callIndex")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val reader = launch(Dispatchers.Default) {
|
|
||||||
start.await()
|
|
||||||
repeat(100) {
|
|
||||||
try {
|
|
||||||
gateway.sentKeys.forEach { call -> call.key.length }
|
|
||||||
} catch (failure: Throwable) {
|
|
||||||
failures += failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start.countDown()
|
|
||||||
(writers + reader).joinAll()
|
|
||||||
staleIterators.forEach { iterator ->
|
|
||||||
try {
|
|
||||||
iterator.forEachRemaining { call -> call.key.length }
|
|
||||||
} catch (failure: Throwable) {
|
|
||||||
failures += failure
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
assertTrue(failures.toList().joinToString(), failures.isEmpty())
|
|
||||||
assertEquals(1 + writerCount * callsPerWriter, gateway.sentKeys.size)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredLearnResultsCanDifferByProfileDeviceAndKey() = runTest {
|
|
||||||
val gateway = FakeInfraredGateway()
|
|
||||||
val infraredDevice = DeviceName("ir-1")
|
|
||||||
gateway.setLearnResult(
|
|
||||||
profileOne,
|
|
||||||
infraredDevice,
|
|
||||||
"power",
|
|
||||||
DomainResult.Failure(DomainError.DeviceOffline("20206")),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(gateway.learnKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
gateway.learnKey(profileTwo, infraredDevice, "power"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
gateway.learnKey(profileOne, infraredDevice, "volume-up"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
gateway.learnKey(profileOne, DeviceName("ir-2"), "power"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun infraredSendResultsCanDifferByProfileDeviceAndKey() = runTest {
|
|
||||||
val gateway = FakeInfraredGateway()
|
|
||||||
val infraredDevice = DeviceName("ir-1")
|
|
||||||
gateway.setSendResult(
|
|
||||||
profileOne,
|
|
||||||
infraredDevice,
|
|
||||||
"power",
|
|
||||||
DomainResult.Failure(DomainError.DeviceOffline("20206")),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertTrue(gateway.sendKey(profileOne, infraredDevice, "power") is DomainResult.Failure)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
gateway.sendKey(profileTwo, infraredDevice, "power"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
gateway.sendKey(profileOne, infraredDevice, "volume-up"),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
gateway.sendKey(profileOne, DeviceName("ir-2"), "power"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-299
@@ -1,299 +0,0 @@
|
|||||||
package com.flagship.abox.manager.testing.fakes
|
|
||||||
|
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
|
||||||
import com.flagship.abox.manager.domain.DomainError
|
|
||||||
import com.flagship.abox.manager.domain.DomainResult
|
|
||||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
|
||||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|
||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
|
||||||
import kotlinx.coroutines.CoroutineStart
|
|
||||||
import kotlinx.coroutines.async
|
|
||||||
import kotlinx.coroutines.flow.first
|
|
||||||
import kotlinx.coroutines.flow.take
|
|
||||||
import kotlinx.coroutines.flow.toList
|
|
||||||
import kotlinx.coroutines.test.runTest
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
|
|
||||||
class FakeInfraredKeyRepositoryTest {
|
|
||||||
private val profileOne = ProviderProfileId("one")
|
|
||||||
private val profileTwo = ProviderProfileId("two")
|
|
||||||
private val deviceOne = DeviceName("ir-1")
|
|
||||||
private val deviceTwo = DeviceName("ir-2")
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun observeKeysEmitsChangesAndFiltersByProfileAndDevice() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
val emissions = backgroundScope.async(start = CoroutineStart.UNDISPATCHED) {
|
|
||||||
repository.observeKeys(profileOne, deviceOne).take(2).toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
repository.insert(savedKey(id = "matching"))
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
listOf(
|
|
||||||
DomainResult.Success(emptyList<SavedInfraredKey>()),
|
|
||||||
DomainResult.Success(listOf(savedKey(id = "matching"))),
|
|
||||||
),
|
|
||||||
emissions.await(),
|
|
||||||
)
|
|
||||||
|
|
||||||
repository.insert(savedKey(id = "other-profile", profileId = profileTwo))
|
|
||||||
repository.insert(savedKey(id = "other-device", deviceName = deviceTwo))
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(listOf(savedKey(id = "matching"))),
|
|
||||||
repository.observeKeys(profileOne, deviceOne).first(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun getKeyReturnsOnlyTheMatchingProfileAndId() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
repository.insert(savedKey(id = "power"))
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(savedKey(id = "power")),
|
|
||||||
repository.getKey(InfraredKeyRef(profileOne, InfraredKeyId("power"))),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(null),
|
|
||||||
repository.getKey(InfraredKeyRef(profileTwo, InfraredKeyId("power"))),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun insertRejectsDuplicateIdAndDuplicateProviderDeviceSdkBinding() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
assertEquals(DomainResult.Success(Unit), repository.insert(savedKey(id = "power")))
|
|
||||||
|
|
||||||
val duplicateId = repository.insert(
|
|
||||||
savedKey(
|
|
||||||
id = "power",
|
|
||||||
profileId = profileTwo,
|
|
||||||
deviceName = deviceTwo,
|
|
||||||
sdkKey = "other-sdk-key",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val duplicateBinding = repository.insert(
|
|
||||||
savedKey(id = "volume", displayName = "Volume"),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertInvalidRequest(duplicateId)
|
|
||||||
assertInvalidRequest(duplicateBinding)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(listOf(savedKey(id = "power"))),
|
|
||||||
repository.observeKeys(profileOne, deviceOne).first(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun transitionStatusRequiresExpectedStatusAndUpdatesFailureContextAtomically() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
|
|
||||||
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.LEARNING))
|
|
||||||
|
|
||||||
val rejected = repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.READY),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "20206",
|
|
||||||
lastErrorDetail = "offline",
|
|
||||||
updatedAtEpochMillis = 20L,
|
|
||||||
)
|
|
||||||
assertInvalidRequest(rejected)
|
|
||||||
assertEquals(InfraredKeyStatus.LEARNING, repository.successfulKey(ref)?.status)
|
|
||||||
assertNull(repository.successfulKey(ref)?.lastErrorCode)
|
|
||||||
assertNull(repository.successfulKey(ref)?.lastErrorDetail)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
repository.transitionStatus(
|
|
||||||
ref = ref,
|
|
||||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
|
||||||
targetStatus = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "20206",
|
|
||||||
lastErrorDetail = "offline",
|
|
||||||
updatedAtEpochMillis = 20L,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
savedKey(
|
|
||||||
id = "power",
|
|
||||||
status = InfraredKeyStatus.FAILED,
|
|
||||||
lastErrorCode = "20206",
|
|
||||||
lastErrorDetail = "offline",
|
|
||||||
updatedAtEpochMillis = 20L,
|
|
||||||
),
|
|
||||||
repository.successfulKey(ref),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun renameUpdatesDisplayNameAndTimestamp() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
val ref = InfraredKeyRef(profileOne, InfraredKeyId("power"))
|
|
||||||
repository.insert(savedKey(id = "power", status = InfraredKeyStatus.READY))
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
repository.rename(ref, displayName = "Main Power", updatedAtEpochMillis = 30L),
|
|
||||||
)
|
|
||||||
assertEquals("Main Power", repository.successfulKey(ref)?.displayName)
|
|
||||||
assertEquals(30L, repository.successfulKey(ref)?.updatedAtEpochMillis)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deleteRejectsLearningKeysAndRemovesOtherStatuses() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
val learningRef = InfraredKeyRef(profileOne, InfraredKeyId("learning"))
|
|
||||||
val readyRef = InfraredKeyRef(profileOne, InfraredKeyId("ready"))
|
|
||||||
repository.insert(savedKey(id = "learning", sdkKey = "learning", status = InfraredKeyStatus.LEARNING))
|
|
||||||
repository.insert(savedKey(id = "ready", sdkKey = "ready", status = InfraredKeyStatus.READY))
|
|
||||||
|
|
||||||
assertInvalidRequest(repository.deleteIfNotLearning(learningRef))
|
|
||||||
assertEquals(DomainResult.Success(Unit), repository.deleteIfNotLearning(readyRef))
|
|
||||||
assertTrue(repository.successfulKey(learningRef) != null)
|
|
||||||
assertNull(repository.successfulKey(readyRef))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failInterruptedLearningRecoversOnlyLearningKeysForOneProfileWithFixedDetail() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
repository.insert(savedKey(id = "one-learning", sdkKey = "one-learning"))
|
|
||||||
repository.insert(
|
|
||||||
savedKey(
|
|
||||||
id = "one-ready",
|
|
||||||
sdkKey = "one-ready",
|
|
||||||
status = InfraredKeyStatus.READY,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
repository.insert(
|
|
||||||
savedKey(
|
|
||||||
id = "two-learning",
|
|
||||||
profileId = profileTwo,
|
|
||||||
sdkKey = "two-learning",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(1),
|
|
||||||
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 40L),
|
|
||||||
)
|
|
||||||
|
|
||||||
val recovered = repository.successfulKey(
|
|
||||||
InfraredKeyRef(profileOne, InfraredKeyId("one-learning")),
|
|
||||||
)
|
|
||||||
assertEquals(InfraredKeyStatus.FAILED, recovered?.status)
|
|
||||||
assertNull(recovered?.lastErrorCode)
|
|
||||||
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, recovered?.lastErrorDetail)
|
|
||||||
assertEquals(40L, recovered?.updatedAtEpochMillis)
|
|
||||||
assertEquals(
|
|
||||||
InfraredKeyStatus.READY,
|
|
||||||
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("one-ready")))?.status,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
InfraredKeyStatus.LEARNING,
|
|
||||||
repository.successfulKey(InfraredKeyRef(profileTwo, InfraredKeyId("two-learning")))?.status,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(0),
|
|
||||||
repository.failInterruptedLearning(profileOne, updatedAtEpochMillis = 50L),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
|
|
||||||
val repository = FakeInfraredKeyRepository()
|
|
||||||
repository.insert(
|
|
||||||
savedKey(
|
|
||||||
id = "old-learning",
|
|
||||||
sdkKey = "old-learning",
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
).copy(createdAtEpochMillis = 100L),
|
|
||||||
)
|
|
||||||
repository.insert(
|
|
||||||
savedKey(
|
|
||||||
id = "new-learning",
|
|
||||||
sdkKey = "new-learning",
|
|
||||||
status = InfraredKeyStatus.LEARNING,
|
|
||||||
).copy(createdAtEpochMillis = 200L),
|
|
||||||
)
|
|
||||||
|
|
||||||
val result = repository.failInterruptedLearning(
|
|
||||||
profileId = profileOne,
|
|
||||||
cutoffEpochMillis = 150L,
|
|
||||||
updatedAtEpochMillis = 150L,
|
|
||||||
)
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(1), result)
|
|
||||||
assertEquals(
|
|
||||||
InfraredKeyStatus.FAILED,
|
|
||||||
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("old-learning")))?.status,
|
|
||||||
)
|
|
||||||
assertEquals(
|
|
||||||
InfraredKeyStatus.LEARNING,
|
|
||||||
repository.successfulKey(InfraredKeyRef(profileOne, InfraredKeyId("new-learning")))?.status,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deviceConfigurationIsIsolatedByProfile() = runTest {
|
|
||||||
val repository = FakeInfraredDeviceConfigurationRepository()
|
|
||||||
|
|
||||||
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
|
|
||||||
assertEquals(
|
|
||||||
DomainResult.Success(Unit),
|
|
||||||
repository.setDeviceName(profileOne, deviceOne),
|
|
||||||
)
|
|
||||||
assertEquals(DomainResult.Success(deviceOne), repository.observeDeviceName(profileOne).first())
|
|
||||||
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileTwo).first())
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun deviceConfigurationRejectsBlankOrSurroundingWhitespace() = runTest {
|
|
||||||
val repository = FakeInfraredDeviceConfigurationRepository()
|
|
||||||
|
|
||||||
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ")))
|
|
||||||
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName(" ir-1")))
|
|
||||||
assertInvalidRequest(repository.setDeviceName(profileOne, DeviceName("ir-1 ")))
|
|
||||||
assertEquals(DomainResult.Success(null), repository.observeDeviceName(profileOne).first())
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun savedKey(
|
|
||||||
id: String,
|
|
||||||
profileId: ProviderProfileId = profileOne,
|
|
||||||
deviceName: DeviceName = deviceOne,
|
|
||||||
displayName: String = "Power",
|
|
||||||
sdkKey: String = "power",
|
|
||||||
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
|
|
||||||
lastErrorCode: String? = null,
|
|
||||||
lastErrorDetail: String? = null,
|
|
||||||
updatedAtEpochMillis: Long = 10L,
|
|
||||||
) = SavedInfraredKey(
|
|
||||||
id = InfraredKeyId(id),
|
|
||||||
profileId = profileId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
displayName = displayName,
|
|
||||||
sdkKey = sdkKey,
|
|
||||||
status = status,
|
|
||||||
lastErrorCode = lastErrorCode,
|
|
||||||
lastErrorDetail = lastErrorDetail,
|
|
||||||
createdAtEpochMillis = 10L,
|
|
||||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
|
||||||
)
|
|
||||||
|
|
||||||
private suspend fun FakeInfraredKeyRepository.successfulKey(
|
|
||||||
ref: InfraredKeyRef,
|
|
||||||
): SavedInfraredKey? = (getKey(ref) as DomainResult.Success<SavedInfraredKey?>).value
|
|
||||||
|
|
||||||
private fun assertInvalidRequest(result: DomainResult<*>) {
|
|
||||||
assertTrue(result is DomainResult.Failure)
|
|
||||||
assertTrue((result as DomainResult.Failure).error is DomainError.InvalidRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user