feat: support 1-100 numeric infrared key slots and gateway login entry
This commit is contained in:
@@ -24,7 +24,9 @@ import javax.inject.Singleton
|
|||||||
object FoundationModule {
|
object FoundationModule {
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideSessionGateway(): SessionGateway = FakeSessionGateway()
|
fun provideSessionGateway(
|
||||||
|
@OptIn(CleartextAbsdkTransport::class) real: AbsdkBridge,
|
||||||
|
): SessionGateway = real
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -2,9 +2,19 @@ package com.flagship.abox.manager.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -14,8 +24,12 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation.NavType
|
import androidx.navigation.NavType
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
@@ -84,34 +98,139 @@ private fun FoundationScreen(
|
|||||||
sessionInvalidMessage: String?,
|
sessionInvalidMessage: String?,
|
||||||
onContinue: () -> Unit,
|
onContinue: () -> Unit,
|
||||||
onOpenInfrared: () -> Unit,
|
onOpenInfrared: () -> Unit,
|
||||||
|
viewModel: FoundationViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
|
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(32.dp),
|
.padding(24.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
) {
|
) {
|
||||||
MessageScreen(
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
title = stringResource(R.string.foundation_title),
|
Text(
|
||||||
description = sessionInvalidMessage ?: stringResource(R.string.foundation_description),
|
text = stringResource(R.string.foundation_title),
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
ABoxButton(
|
Text(
|
||||||
onClick = onContinue,
|
text = sessionInvalidMessage ?: stringResource(R.string.foundation_description),
|
||||||
modifier = Modifier
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
.fillMaxWidth()
|
color = if (sessionInvalidMessage != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
.padding(top = 24.dp),
|
textAlign = TextAlign.Center,
|
||||||
|
)
|
||||||
|
|
||||||
|
// 临时网关登录卡片
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.continue_action))
|
Column(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "ABox 网关登录 (Real 模式前置)",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
if (state.session != null) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = viewModel::logout,
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
) {
|
||||||
|
Text("退出")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.session == null) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.usernameInput,
|
||||||
|
onValueChange = viewModel::onUsernameChanged,
|
||||||
|
label = { Text("账号 (默认 a)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.passwordInput,
|
||||||
|
onValueChange = viewModel::onPasswordChanged,
|
||||||
|
label = { Text("密码 (默认 a)") },
|
||||||
|
visualTransformation = PasswordVisualTransformation(),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
|
ABoxButton(
|
||||||
|
onClick = viewModel::login,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = !state.isLoading,
|
||||||
|
) {
|
||||||
|
if (state.isLoading) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text("连接并登录网关")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text(
|
||||||
|
text = "✅ 已登录 ABox (账号: ${state.session?.username?.value})",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "网关会话已建立,现在可以在红外控制中使用真实 ABSDK 模式。",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.errorMessage?.let { error ->
|
||||||
|
Text(
|
||||||
|
text = "⚠️ $error",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
state.successMessage?.let { success ->
|
||||||
|
Text(
|
||||||
|
text = "ℹ️ $success",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
ABoxButton(
|
ABoxButton(
|
||||||
onClick = onOpenInfrared,
|
onClick = onOpenInfrared,
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(top = 12.dp),
|
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.infrared_control_action))
|
Text(stringResource(R.string.infrared_control_action))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onContinue,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(stringResource(R.string.continue_action))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,12 +246,12 @@ private fun MessageScreen(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = androidx.compose.material3.MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.flagship.abox.manager.ui
|
||||||
|
|
||||||
|
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.LoginCredentials
|
||||||
|
import com.flagship.abox.manager.domain.Password
|
||||||
|
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||||
|
import com.flagship.abox.manager.domain.Session
|
||||||
|
import com.flagship.abox.manager.domain.SessionGateway
|
||||||
|
import com.flagship.abox.manager.domain.Username
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
data class FoundationUiState(
|
||||||
|
val usernameInput: String = "a",
|
||||||
|
val passwordInput: String = "a",
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val session: Session? = null,
|
||||||
|
val errorMessage: String? = null,
|
||||||
|
val successMessage: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class FoundationViewModel @Inject constructor(
|
||||||
|
private val sessionGateway: SessionGateway,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val profileId = ProviderProfileId("abox")
|
||||||
|
private val _uiState = MutableStateFlow(FoundationUiState())
|
||||||
|
val uiState: StateFlow<FoundationUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionGateway.observeSession(profileId).collect { session ->
|
||||||
|
_uiState.value = _uiState.value.copy(session = session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onUsernameChanged(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(usernameInput = value, errorMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onPasswordChanged(value: String) {
|
||||||
|
_uiState.value = _uiState.value.copy(passwordInput = value, errorMessage = null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login() {
|
||||||
|
val username = _uiState.value.usernameInput.trim()
|
||||||
|
val password = _uiState.value.passwordInput.trim()
|
||||||
|
if (username.isBlank() || password.isBlank()) {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = "用户名和密码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.value = _uiState.value.copy(isLoading = true, errorMessage = null, successMessage = null)
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = sessionGateway.login(
|
||||||
|
profileId = profileId,
|
||||||
|
credentials = LoginCredentials(Username(username), Password(password)),
|
||||||
|
)
|
||||||
|
when (result) {
|
||||||
|
is DomainResult.Success -> {
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
session = result.value,
|
||||||
|
successMessage = "登录成功!已获取 ABox 会话 Token",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is DomainResult.Failure -> {
|
||||||
|
val detail = when (val error = result.error) {
|
||||||
|
is DomainError.Authentication -> "认证失败 (错误码: ${error.externalCode ?: "20004"}),请检查账号密码"
|
||||||
|
is DomainError.Network -> "网络连接失败: ${error.detail ?: "请确认已连接网关 WiFi"}"
|
||||||
|
is DomainError.TokenInvalid -> "Token 无效"
|
||||||
|
else -> error.detail ?: "登录失败 (${error.externalCode ?: "unknown"})"
|
||||||
|
}
|
||||||
|
_uiState.value = _uiState.value.copy(
|
||||||
|
isLoading = false,
|
||||||
|
errorMessage = detail,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun logout() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionGateway.logout(profileId)
|
||||||
|
_uiState.value = _uiState.value.copy(session = null, successMessage = "已退出登录")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearMessage() {
|
||||||
|
_uiState.value = _uiState.value.copy(errorMessage = null, successMessage = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-6
@@ -3,7 +3,6 @@ package com.flagship.abox.manager.infrared.ui
|
|||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.flagship.abox.manager.domain.DeviceName
|
import com.flagship.abox.manager.domain.DeviceName
|
||||||
@@ -13,7 +12,6 @@ import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
|||||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||||
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
|
import com.flagship.abox.manager.infrared.runtime.InfraredRuntimeMode
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -36,11 +34,12 @@ class InfraredControlScreenTest {
|
|||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
InfraredControlScreen(
|
InfraredControlScreen(
|
||||||
state = state,
|
state = state,
|
||||||
|
nextSlot = 1,
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSessionInvalid = {},
|
onSessionInvalid = {},
|
||||||
onDeviceNameChanged = {},
|
onDeviceNameChanged = {},
|
||||||
onSaveDeviceName = {},
|
onSaveDeviceName = {},
|
||||||
onLearn = {},
|
onLearn = { _, _ -> },
|
||||||
onRetry = {},
|
onRetry = {},
|
||||||
onSend = {},
|
onSend = {},
|
||||||
onRename = { _, _ -> },
|
onRename = { _, _ -> },
|
||||||
@@ -63,7 +62,7 @@ class InfraredControlScreenTest {
|
|||||||
profileId = ProviderProfileId("abox"),
|
profileId = ProviderProfileId("abox"),
|
||||||
deviceName = DeviceName("ir-1"),
|
deviceName = DeviceName("ir-1"),
|
||||||
displayName = "Power Key",
|
displayName = "Power Key",
|
||||||
sdkKey = "app_ir_kready",
|
sdkKey = "1",
|
||||||
status = InfraredKeyStatus.READY,
|
status = InfraredKeyStatus.READY,
|
||||||
lastErrorCode = null,
|
lastErrorCode = null,
|
||||||
lastErrorDetail = null,
|
lastErrorDetail = null,
|
||||||
@@ -75,7 +74,7 @@ class InfraredControlScreenTest {
|
|||||||
profileId = ProviderProfileId("abox"),
|
profileId = ProviderProfileId("abox"),
|
||||||
deviceName = DeviceName("ir-1"),
|
deviceName = DeviceName("ir-1"),
|
||||||
displayName = "Volume Up",
|
displayName = "Volume Up",
|
||||||
sdkKey = "app_ir_kfailed",
|
sdkKey = "2",
|
||||||
status = InfraredKeyStatus.FAILED,
|
status = InfraredKeyStatus.FAILED,
|
||||||
lastErrorCode = "20206",
|
lastErrorCode = "20206",
|
||||||
lastErrorDetail = null,
|
lastErrorDetail = null,
|
||||||
@@ -96,11 +95,12 @@ class InfraredControlScreenTest {
|
|||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
InfraredControlScreen(
|
InfraredControlScreen(
|
||||||
state = state,
|
state = state,
|
||||||
|
nextSlot = 3,
|
||||||
onBack = {},
|
onBack = {},
|
||||||
onSessionInvalid = {},
|
onSessionInvalid = {},
|
||||||
onDeviceNameChanged = {},
|
onDeviceNameChanged = {},
|
||||||
onSaveDeviceName = {},
|
onSaveDeviceName = {},
|
||||||
onLearn = {},
|
onLearn = { _, _ -> },
|
||||||
onRetry = { retriedKeyId = it },
|
onRetry = { retriedKeyId = it },
|
||||||
onSend = { sentKeyId = it },
|
onSend = { sentKeyId = it },
|
||||||
onRename = { _, _ -> },
|
onRename = { _, _ -> },
|
||||||
|
|||||||
+7
-2
@@ -13,9 +13,14 @@ class InfraredLearningCoordinator(
|
|||||||
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
|
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
|
||||||
private val externalScope: CoroutineScope,
|
private val externalScope: CoroutineScope,
|
||||||
) {
|
) {
|
||||||
fun learn(profileId: ProviderProfileId, deviceName: DeviceName, displayName: String) {
|
fun learn(
|
||||||
|
profileId: ProviderProfileId,
|
||||||
|
deviceName: DeviceName,
|
||||||
|
displayName: String,
|
||||||
|
sdkKey: String? = null,
|
||||||
|
) {
|
||||||
externalScope.launch {
|
externalScope.launch {
|
||||||
learnInfraredKey(profileId, deviceName, displayName)
|
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-5
@@ -1,6 +1,5 @@
|
|||||||
package com.flagship.abox.manager.infrared.ui
|
package com.flagship.abox.manager.infrared.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -58,6 +57,7 @@ fun InfraredControlRoute(
|
|||||||
|
|
||||||
InfraredControlScreen(
|
InfraredControlScreen(
|
||||||
state = state,
|
state = state,
|
||||||
|
nextSlot = viewModel.nextAvailableKeySlot(),
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSessionInvalid = onSessionInvalid,
|
onSessionInvalid = onSessionInvalid,
|
||||||
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
||||||
@@ -76,11 +76,12 @@ fun InfraredControlRoute(
|
|||||||
@Composable
|
@Composable
|
||||||
internal fun InfraredControlScreen(
|
internal fun InfraredControlScreen(
|
||||||
state: InfraredControlUiState,
|
state: InfraredControlUiState,
|
||||||
|
nextSlot: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSessionInvalid: () -> Unit,
|
onSessionInvalid: () -> Unit,
|
||||||
onDeviceNameChanged: (String) -> Unit,
|
onDeviceNameChanged: (String) -> Unit,
|
||||||
onSaveDeviceName: () -> Unit,
|
onSaveDeviceName: () -> Unit,
|
||||||
onLearn: (String) -> Unit,
|
onLearn: (String, String?) -> Unit,
|
||||||
onRetry: (InfraredKeyId) -> Unit,
|
onRetry: (InfraredKeyId) -> Unit,
|
||||||
onSend: (InfraredKeyId) -> Unit,
|
onSend: (InfraredKeyId) -> Unit,
|
||||||
onRename: (InfraredKeyId, String) -> Unit,
|
onRename: (InfraredKeyId, String) -> Unit,
|
||||||
@@ -265,6 +266,8 @@ internal fun InfraredControlScreen(
|
|||||||
|
|
||||||
if (showLearnDialog) {
|
if (showLearnDialog) {
|
||||||
var keyName by remember { mutableStateOf("") }
|
var keyName by remember { mutableStateOf("") }
|
||||||
|
var keySlot by remember { mutableStateOf(nextSlot.toString()) }
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showLearnDialog = false },
|
onDismissRequest = { showLearnDialog = false },
|
||||||
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
|
title = { Text(stringResource(R.string.infrared_learn_dialog_title)) },
|
||||||
@@ -278,15 +281,22 @@ internal fun InfraredControlScreen(
|
|||||||
singleLine = true,
|
singleLine = true,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
)
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = keySlot,
|
||||||
|
onValueChange = { keySlot = it },
|
||||||
|
label = { Text(stringResource(R.string.infrared_learn_dialog_slot_label)) },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
onLearn(keyName)
|
onLearn(keyName, keySlot)
|
||||||
showLearnDialog = false
|
showLearnDialog = false
|
||||||
},
|
},
|
||||||
enabled = keyName.isNotBlank(),
|
enabled = keyName.isNotBlank() && keySlot.isNotBlank(),
|
||||||
) {
|
) {
|
||||||
Text(stringResource(R.string.infrared_learn_dialog_start))
|
Text(stringResource(R.string.infrared_learn_dialog_start))
|
||||||
}
|
}
|
||||||
@@ -387,7 +397,7 @@ private fun InfraredKeyRow(
|
|||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "SDK: ${key.sdkKey}",
|
text = stringResource(R.string.infrared_key_slot_label, key.sdkKey),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
|
|||||||
+17
-2
@@ -192,7 +192,12 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun learnKey(displayName: String) {
|
fun nextAvailableKeySlot(): Int {
|
||||||
|
val usedSlots = state.value.keys.mapNotNull { it.sdkKey.toIntOrNull() }.toSet()
|
||||||
|
return (1..100).firstOrNull { it !in usedSlots } ?: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fun learnKey(displayName: String, keySlotInput: String? = null) {
|
||||||
val currentProfile = state.value.effectiveProfileId ?: return
|
val currentProfile = state.value.effectiveProfileId ?: return
|
||||||
val savedDevice = state.value.savedDeviceName
|
val savedDevice = state.value.savedDeviceName
|
||||||
if (savedDevice == null) {
|
if (savedDevice == null) {
|
||||||
@@ -204,7 +209,17 @@ class InfraredControlViewModel @Inject constructor(
|
|||||||
messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank")
|
messageFlow.value = InfraredUiMessage.Error("Key name cannot be blank")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
learningCoordinator.learn(currentProfile, savedDevice, trimmedName)
|
val slotString = if (keySlotInput != null && keySlotInput.isNotBlank()) {
|
||||||
|
val slotInt = keySlotInput.trim().toIntOrNull()
|
||||||
|
if (slotInt == null || slotInt !in 1..100) {
|
||||||
|
messageFlow.value = InfraredUiMessage.Error("Key slot must be an integer between 1 and 100")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slotInt.toString()
|
||||||
|
} else {
|
||||||
|
nextAvailableKeySlot().toString()
|
||||||
|
}
|
||||||
|
learningCoordinator.learn(currentProfile, savedDevice, trimmedName, slotString)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun retryLearning(keyId: InfraredKeyId) {
|
fun retryLearning(keyId: InfraredKeyId) {
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
<string name="infrared_learn_dialog_title">学习红外按键</string>
|
<string name="infrared_learn_dialog_title">学习红外按键</string>
|
||||||
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string>
|
<string name="infrared_learn_dialog_prompt">请准备好实体遥控器。点击开始后,请将遥控器对准红外控制器并按下目标按键。</string>
|
||||||
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
|
<string name="infrared_learn_dialog_input_label">按键名称(如:电源)</string>
|
||||||
|
<string name="infrared_learn_dialog_slot_label">按键编号(1-100)</string>
|
||||||
|
<string name="infrared_key_slot_label">按键编号:%1$s</string>
|
||||||
<string name="infrared_learn_dialog_start">开始学习</string>
|
<string name="infrared_learn_dialog_start">开始学习</string>
|
||||||
<string name="infrared_rename_dialog_title">重命名按键</string>
|
<string name="infrared_rename_dialog_title">重命名按键</string>
|
||||||
<string name="infrared_rename_dialog_input_label">新按键名称</string>
|
<string name="infrared_rename_dialog_input_label">新按键名称</string>
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
<string name="infrared_learn_dialog_title">Learn Infrared Key</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_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_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_learn_dialog_start">Start Learning</string>
|
||||||
<string name="infrared_rename_dialog_title">Rename Key</string>
|
<string name="infrared_rename_dialog_title">Rename Key</string>
|
||||||
<string name="infrared_rename_dialog_input_label">New Display Name</string>
|
<string name="infrared_rename_dialog_input_label">New Display Name</string>
|
||||||
|
|||||||
+16
-1
@@ -21,6 +21,7 @@ class InfraredKeyFactory(
|
|||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
|
sdkKey: String? = null,
|
||||||
): DomainResult<SavedInfraredKey> {
|
): DomainResult<SavedInfraredKey> {
|
||||||
val normalizedName = displayName.trim()
|
val normalizedName = displayName.trim()
|
||||||
if (
|
if (
|
||||||
@@ -39,6 +40,20 @@ class InfraredKeyFactory(
|
|||||||
?: return DomainResult.Failure(
|
?: return DomainResult.Failure(
|
||||||
DomainError.InvalidRequest(detail = "Infrared key id is invalid"),
|
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()
|
val now = clock.now()
|
||||||
return DomainResult.Success(
|
return DomainResult.Success(
|
||||||
SavedInfraredKey(
|
SavedInfraredKey(
|
||||||
@@ -46,7 +61,7 @@ class InfraredKeyFactory(
|
|||||||
profileId = profileId,
|
profileId = profileId,
|
||||||
deviceName = deviceName,
|
deviceName = deviceName,
|
||||||
displayName = normalizedName,
|
displayName = normalizedName,
|
||||||
sdkKey = "app_ir_$canonicalId",
|
sdkKey = finalSdkKey,
|
||||||
status = InfraredKeyStatus.LEARNING,
|
status = InfraredKeyStatus.LEARNING,
|
||||||
lastErrorCode = null,
|
lastErrorCode = null,
|
||||||
lastErrorDetail = null,
|
lastErrorDetail = null,
|
||||||
|
|||||||
+2
-1
@@ -16,8 +16,9 @@ class LearnInfraredKey(
|
|||||||
profileId: ProviderProfileId,
|
profileId: ProviderProfileId,
|
||||||
deviceName: DeviceName,
|
deviceName: DeviceName,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
|
sdkKey: String? = null,
|
||||||
): DomainResult<InfraredKeyId> {
|
): DomainResult<InfraredKeyId> {
|
||||||
val key = when (val result = factory.create(profileId, deviceName, displayName)) {
|
val key = when (val result = factory.create(profileId, deviceName, displayName, sdkKey)) {
|
||||||
is DomainResult.Success -> result.value
|
is DomainResult.Success -> result.value
|
||||||
is DomainResult.Failure -> return result
|
is DomainResult.Failure -> return result
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -32,6 +32,25 @@ class InfraredKeyFactoryTest {
|
|||||||
assertEquals(result.value.createdAtEpochMillis, result.value.updatedAtEpochMillis)
|
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
|
@Test
|
||||||
fun rejectsBlankDeviceOrDisplayName() {
|
fun rejectsBlankDeviceOrDisplayName() {
|
||||||
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
assertTrue(factory.create(ProviderProfileId("abox"), DeviceName(""), "Power") is DomainResult.Failure)
|
||||||
|
|||||||
Reference in New Issue
Block a user