Merge remote-tracking branch 'origin/main' into feat/scene-editor
This commit is contained in:
@@ -52,6 +52,7 @@ android {
|
||||
excludes += "/META-INF/DEPENDENCIES"
|
||||
excludes += "/META-INF/LICENSE*"
|
||||
excludes += "/META-INF/NOTICE*"
|
||||
excludes += "/META-INF/INDEX.LIST"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,6 +65,7 @@ dependencies {
|
||||
implementation(project(":domain"))
|
||||
implementation(project(":android:core:designsystem"))
|
||||
implementation(project(":android:core:session"))
|
||||
implementation(project(":android:feature:infrared"))
|
||||
implementation(project(":absdk-bridge"))
|
||||
implementation(project(":runtime:simulation"))
|
||||
debugImplementation(project(":testing:fakes"))
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package com.flagship.abox.manager.ui.devicecontrollist
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class DeviceListScreenTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun deviceListRendersAllDeviceCategoryButtonsAndHandlesClicks() {
|
||||
var socketClicks = 0
|
||||
var tempClicks = 0
|
||||
var infraredClicks = 0
|
||||
|
||||
val context = composeRule.activity
|
||||
val socketTitle = context.getString(R.string.device_socket_title)
|
||||
val tempTitle = context.getString(R.string.device_temperature_humidity_title)
|
||||
val infraredTitle = context.getString(R.string.device_infrared_title)
|
||||
val listTitle = context.getString(R.string.device_list_title)
|
||||
|
||||
composeRule.setContent {
|
||||
ABoxTheme {
|
||||
DeviceListScreen(
|
||||
onSocketClick = { socketClicks++ },
|
||||
onTemperatureHumidityClick = { tempClicks++ },
|
||||
onInfraredClick = { infraredClicks++ },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText(listTitle).assertIsDisplayed()
|
||||
|
||||
composeRule.onNodeWithText(socketTitle).assertIsDisplayed().performClick()
|
||||
assertEquals(1, socketClicks)
|
||||
|
||||
composeRule.onNodeWithText(tempTitle).assertIsDisplayed().performClick()
|
||||
assertEquals(1, tempClicks)
|
||||
|
||||
composeRule.onNodeWithText(infraredTitle).assertIsDisplayed().performClick()
|
||||
assertEquals(1, infraredClicks)
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,26 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||
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.login.LoginRoute
|
||||
import com.flagship.abox.manager.ui.login.LoginViewModel
|
||||
import com.flagship.abox.manager.ui.navigation.deviceDetailGraph
|
||||
import com.flagship.abox.manager.ui.navigation.navigateToSocket
|
||||
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
|
||||
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
|
||||
import com.flagship.abox.manager.ui.scene.SceneNavHost
|
||||
import com.flagship.abox.manager.ui.session.AppSessionUiState
|
||||
@@ -58,21 +73,111 @@ fun ABoxManagerApp(
|
||||
onSwitchProvider = sessionActionsViewModel::switchProvider,
|
||||
viewModel = loginViewModel,
|
||||
)
|
||||
is AppSessionUiState.Authenticated -> MainAppShell(
|
||||
userName = state.username.value,
|
||||
providerName = state.profile.displayName,
|
||||
onLogout = sessionActionsViewModel::logout,
|
||||
is AppSessionUiState.Authenticated -> AuthenticatedNavHost(
|
||||
username = state.username,
|
||||
profile = state.profile,
|
||||
actionInProgress = actionState.inProgress,
|
||||
errorMessage = actionState.errorMessage,
|
||||
scenesSlot = {
|
||||
SceneNavHost()
|
||||
},
|
||||
onLogout = sessionActionsViewModel::logout,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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")
|
||||
},
|
||||
)
|
||||
},
|
||||
scenesSlot = {
|
||||
SceneNavHost()
|
||||
},
|
||||
)
|
||||
}
|
||||
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) {
|
||||
AppSessionUiState.Loading -> "loading"
|
||||
AppSessionUiState.ProviderSelection -> "providers"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.flagship.abox.manager.ui.common
|
||||
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
|
||||
object DomainErrorMapper {
|
||||
fun map(error: DomainError): UiText = when (error) {
|
||||
is DomainError.Authentication -> UiText.StringResource(R.string.error_authentication)
|
||||
is DomainError.TokenInvalid -> UiText.StringResource(R.string.error_token_invalid)
|
||||
is DomainError.Network -> UiText.StringResource(R.string.error_network)
|
||||
is DomainError.DeviceNotFound -> UiText.StringResource(R.string.error_device_not_found)
|
||||
is DomainError.DeviceOffline -> UiText.StringResource(R.string.error_device_offline)
|
||||
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
|
||||
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
|
||||
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
|
||||
is DomainError.Unknown -> {
|
||||
val detail = error.detail
|
||||
if (!detail.isNullOrBlank()) {
|
||||
UiText.DynamicString(detail)
|
||||
} else {
|
||||
UiText.StringResource(R.string.error_unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.flagship.abox.manager.ui.common
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
||||
sealed interface UiText {
|
||||
data class DynamicString(val value: String) : UiText
|
||||
|
||||
data class StringResource(
|
||||
@StringRes val resId: Int,
|
||||
val args: List<Any> = emptyList(),
|
||||
) : UiText {
|
||||
constructor(@StringRes resId: Int, vararg args: Any) : this(resId, args.toList())
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun asString(): String = when (this) {
|
||||
is DynamicString -> value
|
||||
is StringResource -> if (args.isEmpty()) {
|
||||
stringResource(resId)
|
||||
} else {
|
||||
stringResource(resId, *args.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
fun asString(context: Context): String = when (this) {
|
||||
is DynamicString -> value
|
||||
is StringResource -> if (args.isEmpty()) {
|
||||
context.getString(resId)
|
||||
} else {
|
||||
context.getString(resId, *args.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.flagship.abox.manager.ui.devicecontrollist
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxTheme
|
||||
|
||||
@Composable
|
||||
fun DeviceListScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
onSocketClick: () -> Unit = {},
|
||||
onTemperatureHumidityClick: () -> Unit = {},
|
||||
onInfraredClick: () -> Unit = {},
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.device_list_title),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.device_list_description),
|
||||
modifier = Modifier.padding(top = 12.dp, bottom = 32.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
DeviceEntryButton(
|
||||
text = stringResource(R.string.device_socket_title),
|
||||
description = stringResource(R.string.device_socket_description),
|
||||
onClick = onSocketClick,
|
||||
)
|
||||
DeviceEntryButton(
|
||||
text = stringResource(R.string.device_temperature_humidity_title),
|
||||
description = stringResource(R.string.device_temperature_humidity_description),
|
||||
onClick = onTemperatureHumidityClick,
|
||||
)
|
||||
DeviceEntryButton(
|
||||
text = stringResource(R.string.device_infrared_title),
|
||||
description = stringResource(R.string.device_infrared_description),
|
||||
onClick = onInfraredClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceEntryButton(
|
||||
text: String,
|
||||
description: String,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
ABoxButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(text = text, style = MaterialTheme.typography.titleMedium)
|
||||
Text(text = description, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(showBackground = true)
|
||||
@Composable
|
||||
private fun DeviceListScreenPreview() {
|
||||
ABoxTheme {
|
||||
DeviceListScreen()
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.flagship.abox.manager.ui.navigation
|
||||
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.NavGraphBuilder
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.navArgument
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.ui.socket.SocketRoute
|
||||
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityRoute
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object DeviceDetailRoutes {
|
||||
const val SOCKET_PATTERN = "device/socket/{profileId}/{deviceName}"
|
||||
const val TH_PATTERN = "device/th/{profileId}/{deviceName}"
|
||||
|
||||
fun socketRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
|
||||
"device/socket/${encode(profileId.value)}/${encode(deviceName.value)}"
|
||||
|
||||
fun temperatureHumidityRoute(profileId: ProviderProfileId, deviceName: DeviceName): String =
|
||||
"device/th/${encode(profileId.value)}/${encode(deviceName.value)}"
|
||||
|
||||
private fun encode(value: String): String =
|
||||
URLEncoder.encode(value, StandardCharsets.UTF_8.name())
|
||||
}
|
||||
|
||||
fun NavController.navigateToSocket(profileId: ProviderProfileId, deviceName: DeviceName) {
|
||||
navigate(DeviceDetailRoutes.socketRoute(profileId, deviceName))
|
||||
}
|
||||
|
||||
fun NavController.navigateToTemperatureHumidity(profileId: ProviderProfileId, deviceName: DeviceName) {
|
||||
navigate(DeviceDetailRoutes.temperatureHumidityRoute(profileId, deviceName))
|
||||
}
|
||||
|
||||
fun NavGraphBuilder.deviceDetailGraph(
|
||||
onBackClick: () -> Unit,
|
||||
) {
|
||||
composable(
|
||||
route = DeviceDetailRoutes.SOCKET_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument("profileId") { type = NavType.StringType },
|
||||
navArgument("deviceName") { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
SocketRoute(onBackClick = onBackClick)
|
||||
}
|
||||
|
||||
composable(
|
||||
route = DeviceDetailRoutes.TH_PATTERN,
|
||||
arguments = listOf(
|
||||
navArgument("profileId") { type = NavType.StringType },
|
||||
navArgument("deviceName") { type = NavType.StringType },
|
||||
),
|
||||
) {
|
||||
TemperatureHumidityRoute(onBackClick = onBackClick)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBar
|
||||
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
|
||||
import com.flagship.abox.manager.designsystem.ABoxTopPillBar
|
||||
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
|
||||
|
||||
/**
|
||||
* Main authenticated application shell.
|
||||
@@ -50,7 +51,7 @@ fun MainAppShell(
|
||||
onAddClick: (() -> Unit)? = null,
|
||||
actionInProgress: Boolean = false,
|
||||
errorMessage: String? = null,
|
||||
devicesSlot: @Composable () -> Unit = { DefaultDevicesSlot() },
|
||||
devicesSlot: @Composable () -> Unit = { DeviceListScreen() },
|
||||
scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
|
||||
activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
|
||||
) {
|
||||
@@ -133,14 +134,6 @@ fun MainAppShell(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultDevicesSlot() {
|
||||
SlotPlaceholder(
|
||||
title = stringResource(R.string.devices_placeholder_title),
|
||||
description = stringResource(R.string.devices_placeholder_description),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DefaultScenesSlot() {
|
||||
SlotPlaceholder(
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package com.flagship.abox.manager.ui.socket
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
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.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun SocketRoute(
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: SocketViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
SocketScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
onBackClick = onBackClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun SocketScreen(
|
||||
state: SocketUiState,
|
||||
onEvent: (SocketUiEvent) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.deviceName?.value ?: stringResource(R.string.socket_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { showEditNameDialog = true },
|
||||
enabled = !state.isLoading && !state.isOperating,
|
||||
) {
|
||||
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { onEvent(SocketUiEvent.Refresh) },
|
||||
enabled = !state.isLoading && !state.isOperating,
|
||||
) {
|
||||
Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (state.isOffline) {
|
||||
OfflineBanner(
|
||||
onRetry = { onEvent(SocketUiEvent.Refresh) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage?.let { errorText ->
|
||||
ErrorBanner(
|
||||
message = errorText.asString(),
|
||||
onDismiss = { onEvent(SocketUiEvent.ClearError) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// Central Status Card
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
PowerIndicatorBadge(
|
||||
powerState = state.powerState,
|
||||
isOffline = state.isOffline,
|
||||
isLoading = state.isLoading,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.socket_status_label),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = when {
|
||||
state.isOffline -> stringResource(R.string.socket_status_offline)
|
||||
state.powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on)
|
||||
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
|
||||
else -> stringResource(R.string.socket_status_unknown)
|
||||
},
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = when {
|
||||
state.isOffline -> MaterialTheme.colorScheme.error
|
||||
state.powerState == SocketPowerState.ON -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
|
||||
state.lastConfirmedTimeMillis?.let { timeMillis ->
|
||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
||||
Text(
|
||||
text = stringResource(R.string.socket_confirmed_at, formattedTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Main Toggle Action Button
|
||||
ABoxButton(
|
||||
onClick = { onEvent(SocketUiEvent.TogglePower) },
|
||||
enabled = state.isActionEnabled,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp),
|
||||
) {
|
||||
if (state.isOperating) {
|
||||
ABoxLoadingIndicator(modifier = Modifier.size(24.dp))
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(stringResource(R.string.socket_operating_label))
|
||||
} else {
|
||||
Text(
|
||||
text = if (state.isPowerOn) {
|
||||
stringResource(R.string.socket_action_turn_off)
|
||||
} else {
|
||||
stringResource(R.string.socket_action_turn_on)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// Dedicated Segmented Action Buttons
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) },
|
||||
enabled = state.isActionEnabled && state.powerState != SocketPowerState.ON,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.socket_action_turn_on))
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF)) },
|
||||
enabled = state.isActionEnabled && state.powerState != SocketPowerState.OFF,
|
||||
modifier = Modifier.weight(1f).height(48.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.socket_action_turn_off))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditNameDialog) {
|
||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEditNameDialog = false },
|
||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = inputName,
|
||||
onValueChange = { inputName = it },
|
||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val trimmed = inputName.trim()
|
||||
if (trimmed.isNotBlank()) {
|
||||
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
||||
}
|
||||
showEditNameDialog = false
|
||||
},
|
||||
enabled = inputName.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEditNameDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PowerIndicatorBadge(
|
||||
powerState: SocketPowerState?,
|
||||
isOffline: Boolean,
|
||||
isLoading: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val indicatorColor = when {
|
||||
isOffline -> MaterialTheme.colorScheme.error
|
||||
powerState == SocketPowerState.ON -> MaterialTheme.colorScheme.primary
|
||||
powerState == SocketPowerState.OFF -> MaterialTheme.colorScheme.outline
|
||||
else -> MaterialTheme.colorScheme.outlineVariant
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(96.dp)
|
||||
.clip(CircleShape)
|
||||
.background(indicatorColor.copy(alpha = 0.15f))
|
||||
.border(3.dp, indicatorColor, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isLoading) {
|
||||
ABoxLoadingIndicator(modifier = Modifier.size(36.dp))
|
||||
} else {
|
||||
Text(
|
||||
text = if (powerState == SocketPowerState.ON) "⚡" else "○",
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
color = indicatorColor,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineBanner(
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.status_device_offline_banner),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(stringResource(R.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.85f),
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("✕")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.flagship.abox.manager.ui.socket
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.ui.common.UiText
|
||||
|
||||
data class SocketUiState(
|
||||
val profileId: ProviderProfileId? = null,
|
||||
val deviceName: DeviceName? = null,
|
||||
val powerState: SocketPowerState? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isOperating: Boolean = false,
|
||||
val isOffline: Boolean = false,
|
||||
val errorMessage: UiText? = null,
|
||||
val lastConfirmedTimeMillis: Long? = null,
|
||||
) {
|
||||
val isActionEnabled: Boolean
|
||||
get() = !isLoading && !isOperating && !isOffline && profileId != null && deviceName != null
|
||||
|
||||
val isPowerOn: Boolean
|
||||
get() = powerState == SocketPowerState.ON
|
||||
}
|
||||
|
||||
sealed interface SocketUiEvent {
|
||||
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : SocketUiEvent
|
||||
data class ChangeDeviceName(val deviceName: DeviceName) : SocketUiEvent
|
||||
data object Refresh : SocketUiEvent
|
||||
data object TogglePower : SocketUiEvent
|
||||
data class SetPower(val targetState: SocketPowerState) : SocketUiEvent
|
||||
data object ClearError : SocketUiEvent
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.flagship.abox.manager.ui.socket
|
||||
|
||||
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.SocketGateway
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class SocketViewModel @Inject constructor(
|
||||
private val socketGateway: SocketGateway,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(SocketUiState())
|
||||
val state: StateFlow<SocketUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
|
||||
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
|
||||
|
||||
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
|
||||
onEvent(
|
||||
SocketUiEvent.Initialize(
|
||||
profileId = ProviderProfileId(profileIdString),
|
||||
deviceName = DeviceName(deviceNameString),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(value: String): String = try {
|
||||
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
|
||||
} catch (_: Exception) {
|
||||
value
|
||||
}
|
||||
|
||||
fun onEvent(event: SocketUiEvent) {
|
||||
when (event) {
|
||||
is SocketUiEvent.Initialize -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
profileId = event.profileId,
|
||||
deviceName = event.deviceName,
|
||||
)
|
||||
}
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
is SocketUiEvent.ChangeDeviceName -> {
|
||||
val trimmed = event.deviceName.value.trim()
|
||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceName = DeviceName(trimmed),
|
||||
powerState = null,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = null,
|
||||
)
|
||||
}
|
||||
loadStatus()
|
||||
}
|
||||
}
|
||||
|
||||
SocketUiEvent.Refresh -> {
|
||||
loadStatus()
|
||||
}
|
||||
|
||||
SocketUiEvent.TogglePower -> {
|
||||
val currentState = _state.value
|
||||
val targetState = if (currentState.isPowerOn) {
|
||||
SocketPowerState.OFF
|
||||
} else {
|
||||
SocketPowerState.ON
|
||||
}
|
||||
setPower(targetState)
|
||||
}
|
||||
|
||||
is SocketUiEvent.SetPower -> {
|
||||
setPower(event.targetState)
|
||||
}
|
||||
|
||||
SocketUiEvent.ClearError -> {
|
||||
_state.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadStatus() {
|
||||
val profileId = _state.value.profileId ?: return
|
||||
val deviceName = _state.value.deviceName ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
when (val result = socketGateway.getStatus(profileId, deviceName)) {
|
||||
is DomainResult.Success -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
powerState = result.value.powerState,
|
||||
isLoading = false,
|
||||
isOffline = false,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DomainResult.Failure -> {
|
||||
val isOffline = result.error is DomainError.DeviceOffline
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isOffline = isOffline,
|
||||
errorMessage = DomainErrorMapper.map(result.error),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setPower(targetState: SocketPowerState) {
|
||||
val current = _state.value
|
||||
val profileId = current.profileId ?: return
|
||||
val deviceName = current.deviceName ?: return
|
||||
|
||||
// Prevent duplicate action if currently operating, loading or offline
|
||||
if (current.isOperating || current.isLoading || current.isOffline) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isOperating = true, errorMessage = null) }
|
||||
when (val result = socketGateway.setPower(profileId, deviceName, targetState)) {
|
||||
is DomainResult.Success -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
powerState = result.value.powerState,
|
||||
isOperating = false,
|
||||
isOffline = false,
|
||||
errorMessage = null,
|
||||
lastConfirmedTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DomainResult.Failure -> {
|
||||
val isOffline = result.error is DomainError.DeviceOffline
|
||||
_state.update {
|
||||
it.copy(
|
||||
isOperating = false,
|
||||
isOffline = isOffline,
|
||||
errorMessage = DomainErrorMapper.map(result.error),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
package com.flagship.abox.manager.ui.temperaturehumidity
|
||||
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.R
|
||||
import com.flagship.abox.manager.designsystem.ABoxButton
|
||||
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
|
||||
@Composable
|
||||
fun TemperatureHumidityRoute(
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: TemperatureHumidityViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
TemperatureHumidityScreen(
|
||||
state = state,
|
||||
onEvent = viewModel::onEvent,
|
||||
onBackClick = onBackClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TemperatureHumidityScreen(
|
||||
state: TemperatureHumidityUiState,
|
||||
onEvent: (TemperatureHumidityUiEvent) -> Unit,
|
||||
onBackClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scrollState = rememberScrollState()
|
||||
var showEditNameDialog by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = state.deviceName?.value ?: stringResource(R.string.th_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBackClick) {
|
||||
Text("<", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = { showEditNameDialog = true },
|
||||
enabled = !state.isLoading && !state.isRefreshing,
|
||||
) {
|
||||
Text("✎", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
|
||||
enabled = !state.isLoading && !state.isRefreshing,
|
||||
) {
|
||||
Text("↻", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
.verticalScroll(scrollState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
if (state.isOffline) {
|
||||
OfflineBanner(
|
||||
onRetry = { onEvent(TemperatureHumidityUiEvent.Refresh) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage?.let { errorText ->
|
||||
ErrorBanner(
|
||||
message = errorText.asString(),
|
||||
onDismiss = { onEvent(TemperatureHumidityUiEvent.ClearError) },
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
|
||||
if (state.isLoading && !state.hasValidData) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ABoxLoadingIndicator(modifier = Modifier.size(40.dp))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.status_loading),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (state.hasNoData) {
|
||||
NoDataCard(
|
||||
onRefresh = { onEvent(TemperatureHumidityUiEvent.Refresh) },
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
)
|
||||
} else {
|
||||
// Telemetry Cards Row / Column
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
TelemetryMetricCard(
|
||||
title = stringResource(R.string.th_temperature_label),
|
||||
value = state.temperature ?: "--",
|
||||
unit = stringResource(R.string.th_unit_celsius),
|
||||
icon = "🌡️",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
|
||||
TelemetryMetricCard(
|
||||
title = stringResource(R.string.th_humidity_label),
|
||||
value = state.humidity ?: "--",
|
||||
unit = stringResource(R.string.th_unit_percent),
|
||||
icon = "💧",
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
state.lastUpdatedTimeMillis?.let { timeMillis ->
|
||||
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
|
||||
Text(
|
||||
text = stringResource(R.string.th_last_updated, formattedTime),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Read-Only Informational Card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.th_read_only_notice),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.th_read_only_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// Refresh Button
|
||||
ABoxButton(
|
||||
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
|
||||
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(52.dp),
|
||||
) {
|
||||
if (state.isRefreshing || state.isLoading) {
|
||||
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Text(stringResource(R.string.status_loading))
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.action_refresh),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showEditNameDialog) {
|
||||
var inputName by remember(state.deviceName) { mutableStateOf(state.deviceName?.value.orEmpty()) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEditNameDialog = false },
|
||||
title = { Text(stringResource(R.string.dialog_edit_device_name_title)) },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = inputName,
|
||||
onValueChange = { inputName = it },
|
||||
label = { Text(stringResource(R.string.dialog_edit_device_name_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
val trimmed = inputName.trim()
|
||||
if (trimmed.isNotBlank()) {
|
||||
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(trimmed)))
|
||||
}
|
||||
showEditNameDialog = false
|
||||
},
|
||||
enabled = inputName.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.action_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEditNameDialog = false }) {
|
||||
Text(stringResource(R.string.action_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TelemetryMetricCard(
|
||||
title: String,
|
||||
value: String,
|
||||
unit: String,
|
||||
icon: String,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = icon,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
text = value,
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
if (value != "--") {
|
||||
Text(
|
||||
text = " $unit",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NoDataCard(
|
||||
onRefresh: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(28.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text("📭", style = MaterialTheme.typography.displaySmall)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.th_no_data_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.th_no_data_description),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp, bottom = 16.dp),
|
||||
)
|
||||
TextButton(onClick = onRefresh) {
|
||||
Text(stringResource(R.string.action_refresh))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OfflineBanner(
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.status_device_offline_banner),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onRetry) {
|
||||
Text(stringResource(R.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(
|
||||
message: String,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.85f),
|
||||
contentColor = MaterialTheme.colorScheme.onErrorContainer,
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = message,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text("✕")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.flagship.abox.manager.ui.temperaturehumidity
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.ui.common.UiText
|
||||
|
||||
data class TemperatureHumidityUiState(
|
||||
val profileId: ProviderProfileId? = null,
|
||||
val deviceName: DeviceName? = null,
|
||||
val temperature: String? = null,
|
||||
val humidity: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isRefreshing: Boolean = false,
|
||||
val isOffline: Boolean = false,
|
||||
val hasNoData: Boolean = false,
|
||||
val errorMessage: UiText? = null,
|
||||
val lastUpdatedTimeMillis: Long? = null,
|
||||
) {
|
||||
val formattedTemperature: String
|
||||
get() = if (temperature.isNullOrBlank()) "--" else "$temperature °C"
|
||||
|
||||
val formattedHumidity: String
|
||||
get() = if (humidity.isNullOrBlank()) "--" else "$humidity %"
|
||||
|
||||
val hasValidData: Boolean
|
||||
get() = !temperature.isNullOrBlank() || !humidity.isNullOrBlank()
|
||||
}
|
||||
|
||||
sealed interface TemperatureHumidityUiEvent {
|
||||
data class Initialize(val profileId: ProviderProfileId, val deviceName: DeviceName) : TemperatureHumidityUiEvent
|
||||
data class ChangeDeviceName(val deviceName: DeviceName) : TemperatureHumidityUiEvent
|
||||
data object Refresh : TemperatureHumidityUiEvent
|
||||
data object ClearError : TemperatureHumidityUiEvent
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package com.flagship.abox.manager.ui.temperaturehumidity
|
||||
|
||||
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.TemperatureHumidityGateway
|
||||
import com.flagship.abox.manager.ui.common.DomainErrorMapper
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import java.net.URLDecoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class TemperatureHumidityViewModel @Inject constructor(
|
||||
private val thGateway: TemperatureHumidityGateway,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(TemperatureHumidityUiState())
|
||||
val state: StateFlow<TemperatureHumidityUiState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
val profileIdString: String? = savedStateHandle.get<String>("profileId")?.let { decode(it) }
|
||||
val deviceNameString: String? = savedStateHandle.get<String>("deviceName")?.let { decode(it) }
|
||||
|
||||
if (!profileIdString.isNullOrBlank() && !deviceNameString.isNullOrBlank()) {
|
||||
onEvent(
|
||||
TemperatureHumidityUiEvent.Initialize(
|
||||
profileId = ProviderProfileId(profileIdString),
|
||||
deviceName = DeviceName(deviceNameString),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(value: String): String = try {
|
||||
URLDecoder.decode(value, StandardCharsets.UTF_8.name())
|
||||
} catch (_: Exception) {
|
||||
value
|
||||
}
|
||||
|
||||
fun onEvent(event: TemperatureHumidityUiEvent) {
|
||||
when (event) {
|
||||
is TemperatureHumidityUiEvent.Initialize -> {
|
||||
_state.update {
|
||||
it.copy(
|
||||
profileId = event.profileId,
|
||||
deviceName = event.deviceName,
|
||||
)
|
||||
}
|
||||
loadReading(isRefresh = false)
|
||||
}
|
||||
|
||||
is TemperatureHumidityUiEvent.ChangeDeviceName -> {
|
||||
val trimmed = event.deviceName.value.trim()
|
||||
if (trimmed.isNotBlank() && trimmed != _state.value.deviceName?.value) {
|
||||
_state.update {
|
||||
it.copy(
|
||||
deviceName = DeviceName(trimmed),
|
||||
temperature = null,
|
||||
humidity = null,
|
||||
hasNoData = false,
|
||||
errorMessage = null,
|
||||
lastUpdatedTimeMillis = null,
|
||||
)
|
||||
}
|
||||
loadReading(isRefresh = false)
|
||||
}
|
||||
}
|
||||
|
||||
TemperatureHumidityUiEvent.Refresh -> {
|
||||
loadReading(isRefresh = true)
|
||||
}
|
||||
|
||||
TemperatureHumidityUiEvent.ClearError -> {
|
||||
_state.update { it.copy(errorMessage = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadReading(isRefresh: Boolean) {
|
||||
val profileId = _state.value.profileId ?: return
|
||||
val deviceName = _state.value.deviceName ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
if (isRefresh) {
|
||||
_state.update { it.copy(isRefreshing = true, errorMessage = null) }
|
||||
} else {
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
}
|
||||
|
||||
when (val result = thGateway.getReading(profileId, deviceName)) {
|
||||
is DomainResult.Success -> {
|
||||
val reading = result.value
|
||||
val isBlankData = reading.temperature.isBlank() && reading.humidity.isBlank()
|
||||
_state.update {
|
||||
it.copy(
|
||||
temperature = reading.temperature.takeIf { t -> t.isNotBlank() },
|
||||
humidity = reading.humidity.takeIf { h -> h.isNotBlank() },
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
isOffline = false,
|
||||
hasNoData = isBlankData,
|
||||
errorMessage = null,
|
||||
lastUpdatedTimeMillis = System.currentTimeMillis(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
is DomainResult.Failure -> {
|
||||
val isOffline = result.error is DomainError.DeviceOffline
|
||||
val hasNoData = result.error is DomainError.NoData
|
||||
_state.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isRefreshing = false,
|
||||
isOffline = isOffline,
|
||||
hasNoData = hasNoData,
|
||||
errorMessage = DomainErrorMapper.map(result.error),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,6 @@
|
||||
<string name="action_switch_provider">切换提供方</string>
|
||||
<string name="authenticated_title">已连接到 %1$s</string>
|
||||
<string name="authenticated_description">登录后入口已经就绪,设备导航将由对应模块负责人接入。</string>
|
||||
|
||||
<!-- 认证后壳层 -->
|
||||
<string name="tab_devices">设备</string>
|
||||
<string name="tab_scenes">情景</string>
|
||||
@@ -81,4 +80,61 @@
|
||||
<string name="scene_execution_running">正在执行 %1$s…</string>
|
||||
<string name="scene_execution_summary">%3$d 个动作中成功 %1$d 个,失败 %2$d 个</string>
|
||||
<string name="scene_execution_done">完成</string>
|
||||
|
||||
<!-- 设备列表 -->
|
||||
<string name="device_list_title">设备列表</string>
|
||||
<string name="device_list_description">选择需要控制或查看状态的 ABox 终端设备。</string>
|
||||
<string name="device_socket_title">智能插座</string>
|
||||
<string name="device_socket_description">进入智能插座相关功能。</string>
|
||||
<string name="device_temperature_humidity_title">温湿度传感器</string>
|
||||
<string name="device_temperature_humidity_description">进入温湿度状态相关功能。</string>
|
||||
<string name="device_infrared_title">红外控制器</string>
|
||||
<string name="device_infrared_description">进入红外控制器相关功能。</string>
|
||||
<string name="infrared_control_action">红外控制</string>
|
||||
<string name="infrared_session_invalid_message">ABox 会话未认证,请先登录。</string>
|
||||
<!-- Socket strings -->
|
||||
<string name="socket_title">智能插座</string>
|
||||
<string name="socket_status_label">电源状态</string>
|
||||
<string name="socket_status_on">已开启</string>
|
||||
<string name="socket_status_off">已关闭</string>
|
||||
<string name="socket_status_offline">已离线</string>
|
||||
<string name="socket_status_unknown">未知状态</string>
|
||||
<string name="socket_action_toggle">切换电源</string>
|
||||
<string name="socket_action_turn_on">开启插座</string>
|
||||
<string name="socket_action_turn_off">关闭插座</string>
|
||||
<string name="socket_operating_label">正在切换状态…</string>
|
||||
<string name="socket_confirmed_at">已于 %1$s 确认状态</string>
|
||||
|
||||
<!-- Temperature & Humidity strings -->
|
||||
<string name="th_title">温湿度传感器</string>
|
||||
<string name="th_temperature_label">温度</string>
|
||||
<string name="th_humidity_label">湿度</string>
|
||||
<string name="th_unit_celsius">°C</string>
|
||||
<string name="th_unit_percent">%</string>
|
||||
<string name="th_read_only_notice">传感器设备(只读监控)</string>
|
||||
<string name="th_read_only_description">本设备提供实时环境温湿度遥测,不支持远程控制操作。</string>
|
||||
<string name="th_no_data_title">暂无传感器数据</string>
|
||||
<string name="th_no_data_description">尚未获取到该传感器的遥测数据,请点击刷新重新查询。</string>
|
||||
<string name="th_last_updated">更新于 %1$s</string>
|
||||
|
||||
<!-- Common action & error strings -->
|
||||
<string name="action_refresh">刷新</string>
|
||||
<string name="action_retry">重试</string>
|
||||
<string name="action_back">返回</string>
|
||||
<string name="action_edit_device_name">修改设备名称</string>
|
||||
<string name="dialog_edit_device_name_title">修改设备名称</string>
|
||||
<string name="dialog_edit_device_name_hint">请输入网关中的设备名称</string>
|
||||
<string name="action_confirm">确定</string>
|
||||
<string name="action_cancel">取消</string>
|
||||
<string name="status_loading">加载中…</string>
|
||||
<string name="status_device_offline_banner">设备已离线,无法进行通信。</string>
|
||||
<string name="error_authentication">认证失败,请重新登录。</string>
|
||||
<string name="error_token_invalid">登录已过期,请重新登录。</string>
|
||||
<string name="error_network">网络连接异常,请检查网络设置。</string>
|
||||
<string name="error_device_not_found">未找到该设备。</string>
|
||||
<string name="error_device_offline">设备当前处于离线状态。</string>
|
||||
<string name="error_invalid_request">请求参数无效。</string>
|
||||
<string name="error_operation_failed">操作执行失败,请重试。</string>
|
||||
<string name="error_no_data">暂无可用数据。</string>
|
||||
<string name="error_unknown">发生未知错误。</string>
|
||||
</resources>
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
<string name="action_switch_provider">Switch provider</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>
|
||||
|
||||
<!-- Authenticated shell -->
|
||||
<string name="tab_devices">Devices</string>
|
||||
<string name="tab_scenes">Scenes</string>
|
||||
@@ -81,4 +80,61 @@
|
||||
<string name="scene_execution_running">Running %1$s…</string>
|
||||
<string name="scene_execution_summary">%1$d succeeded, %2$d failed of %3$d actions</string>
|
||||
<string name="scene_execution_done">Done</string>
|
||||
|
||||
<!-- Device list -->
|
||||
<string name="device_list_title">Device List</string>
|
||||
<string name="device_list_description">Select an ABox terminal device to control or check status.</string>
|
||||
<string name="device_socket_title">Smart Socket</string>
|
||||
<string name="device_socket_description">Access smart socket features.</string>
|
||||
<string name="device_temperature_humidity_title">Temperature & Humidity Sensor</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_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 -->
|
||||
<string name="socket_title">Smart Socket</string>
|
||||
<string name="socket_status_label">Power Status</string>
|
||||
<string name="socket_status_on">ON</string>
|
||||
<string name="socket_status_off">OFF</string>
|
||||
<string name="socket_status_offline">Offline</string>
|
||||
<string name="socket_status_unknown">Unknown</string>
|
||||
<string name="socket_action_toggle">Toggle Power</string>
|
||||
<string name="socket_action_turn_on">Turn ON</string>
|
||||
<string name="socket_action_turn_off">Turn OFF</string>
|
||||
<string name="socket_operating_label">Updating socket state…</string>
|
||||
<string name="socket_confirmed_at">Confirmed at %1$s</string>
|
||||
|
||||
<!-- Temperature & Humidity strings -->
|
||||
<string name="th_title">Temperature & Humidity</string>
|
||||
<string name="th_temperature_label">Temperature</string>
|
||||
<string name="th_humidity_label">Humidity</string>
|
||||
<string name="th_unit_celsius">°C</string>
|
||||
<string name="th_unit_percent">%</string>
|
||||
<string name="th_read_only_notice">Sensor Device (Read-only)</string>
|
||||
<string name="th_read_only_description">This sensor provides real-time environmental telemetry and does not support remote control actions.</string>
|
||||
<string name="th_no_data_title">No Sensor Data</string>
|
||||
<string name="th_no_data_description">No telemetry data has been received from this sensor. Tap Refresh to query again.</string>
|
||||
<string name="th_last_updated">Updated at %1$s</string>
|
||||
|
||||
<!-- Common action & error strings -->
|
||||
<string name="action_refresh">Refresh</string>
|
||||
<string name="action_retry">Retry</string>
|
||||
<string name="action_back">Back</string>
|
||||
<string name="action_edit_device_name">Edit Device Name</string>
|
||||
<string name="dialog_edit_device_name_title">Edit Device Name</string>
|
||||
<string name="dialog_edit_device_name_hint">Enter gateway device name</string>
|
||||
<string name="action_confirm">Confirm</string>
|
||||
<string name="action_cancel">Cancel</string>
|
||||
<string name="status_loading">Loading…</string>
|
||||
<string name="status_device_offline_banner">Device is offline. Commands cannot be sent.</string>
|
||||
<string name="error_authentication">Authentication failed. Please log in again.</string>
|
||||
<string name="error_token_invalid">Session expired. Please log in again.</string>
|
||||
<string name="error_network">Network error. Please check your connection.</string>
|
||||
<string name="error_device_not_found">Device not found.</string>
|
||||
<string name="error_device_offline">Device is currently offline.</string>
|
||||
<string name="error_invalid_request">Invalid request parameters.</string>
|
||||
<string name="error_operation_failed">Operation failed. Please try again.</string>
|
||||
<string name="error_no_data">No sensor data available.</string>
|
||||
<string name="error_unknown">An unexpected error occurred.</string>
|
||||
</resources>
|
||||
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package com.flagship.abox.manager.ui.socket
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.SocketPowerState
|
||||
import com.flagship.abox.manager.testing.fakes.FakeSocketGateway
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class SocketViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
|
||||
|
||||
private lateinit var fakeSocketGateway: FakeSocketGateway
|
||||
private val profileId = ProviderProfileId("test-profile")
|
||||
private val deviceName = DeviceName("test-socket")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fakeSocketGateway = FakeSocketGateway()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_fromSavedStateHandle_loadsStatusSuccessfully() = runTest {
|
||||
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
|
||||
|
||||
val savedStateHandle = SavedStateHandle(
|
||||
mapOf(
|
||||
"profileId" to profileId.value,
|
||||
"deviceName" to deviceName.value,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, savedStateHandle)
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals(SocketPowerState.ON, state.powerState)
|
||||
assertTrue(state.isPowerOn)
|
||||
assertFalse(state.isLoading)
|
||||
assertFalse(state.isOffline)
|
||||
assertNull(state.errorMessage)
|
||||
assertNotNull(state.lastConfirmedTimeMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_viaEvent_loadsStatusSuccessfully() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
assertNull(viewModel.state.value.profileId)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals(SocketPowerState.OFF, state.powerState)
|
||||
assertFalse(state.isPowerOn)
|
||||
assertFalse(state.isLoading)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadStatus_whenDeviceOffline_setsOfflineState() = runTest {
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.isOffline)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadStatus_whenNetworkFails_setsErrorMessage() = runTest {
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.Network(detail = "Timeout"))
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertFalse(state.isOffline)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglePower_fromOffToOn_updatesState() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.TogglePower)
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertEquals(SocketPowerState.ON, state.powerState)
|
||||
assertTrue(state.isPowerOn)
|
||||
assertFalse(state.isOperating)
|
||||
assertNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun togglePower_fromOnToOff_updatesState() = runTest {
|
||||
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.TogglePower)
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertEquals(SocketPowerState.OFF, state.powerState)
|
||||
assertFalse(state.isPowerOn)
|
||||
assertFalse(state.isOperating)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPower_explicitState_updatesStateCorrectly() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.OFF))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setPower_whenGatewayFails_setsErrorMessageAndPreservesState() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.OperationFailed(detail = "Command failed"))
|
||||
viewModel.onEvent(SocketUiEvent.SetPower(SocketPowerState.ON))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertEquals(SocketPowerState.OFF, state.powerState)
|
||||
assertFalse(state.isOperating)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refresh_queriesUpdatedGatewayStatus() = runTest {
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
// Modify gateway backend directly
|
||||
fakeSocketGateway.setPower(profileId, deviceName, SocketPowerState.ON)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.Refresh)
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeDeviceName_updatesNameAndReloads() = runTest {
|
||||
val newDevice = DeviceName("socket-2")
|
||||
fakeSocketGateway.setPower(profileId, newDevice, SocketPowerState.ON)
|
||||
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals(SocketPowerState.OFF, viewModel.state.value.powerState)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.ChangeDeviceName(newDevice))
|
||||
assertEquals(newDevice, viewModel.state.value.deviceName)
|
||||
assertEquals(SocketPowerState.ON, viewModel.state.value.powerState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearError_resetsErrorMessage() = runTest {
|
||||
fakeSocketGateway.setError(profileId, deviceName, DomainError.InvalidRequest())
|
||||
val viewModel = SocketViewModel(fakeSocketGateway, SavedStateHandle())
|
||||
viewModel.onEvent(SocketUiEvent.Initialize(profileId, deviceName))
|
||||
assertNotNull(viewModel.state.value.errorMessage)
|
||||
|
||||
viewModel.onEvent(SocketUiEvent.ClearError)
|
||||
assertNull(viewModel.state.value.errorMessage)
|
||||
}
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
package com.flagship.abox.manager.ui.temperaturehumidity
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.TemperatureHumidityReading
|
||||
import com.flagship.abox.manager.testing.fakes.FakeTemperatureHumidityGateway
|
||||
import com.flagship.abox.manager.testing.MainDispatcherRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TemperatureHumidityViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val mainDispatcherRule = MainDispatcherRule(UnconfinedTestDispatcher())
|
||||
|
||||
private lateinit var fakeGateway: FakeTemperatureHumidityGateway
|
||||
private val profileId = ProviderProfileId("test-profile")
|
||||
private val deviceName = DeviceName("test-sensor")
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
fakeGateway = FakeTemperatureHumidityGateway()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_fromSavedStateHandle_loadsReadingSuccessfully() = runTest {
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "26.5",
|
||||
humidity = "58",
|
||||
),
|
||||
)
|
||||
|
||||
val savedStateHandle = SavedStateHandle(
|
||||
mapOf(
|
||||
"profileId" to profileId.value,
|
||||
"deviceName" to deviceName.value,
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, savedStateHandle)
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals("26.5", state.temperature)
|
||||
assertEquals("58", state.humidity)
|
||||
assertEquals("26.5 °C", state.formattedTemperature)
|
||||
assertEquals("58 %", state.formattedHumidity)
|
||||
assertTrue(state.hasValidData)
|
||||
assertFalse(state.isLoading)
|
||||
assertFalse(state.isOffline)
|
||||
assertFalse(state.hasNoData)
|
||||
assertNull(state.errorMessage)
|
||||
assertNotNull(state.lastUpdatedTimeMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialization_viaEvent_loadsReadingSuccessfully() = runTest {
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
assertNull(viewModel.state.value.profileId)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
val state = viewModel.state.value
|
||||
|
||||
assertEquals(profileId, state.profileId)
|
||||
assertEquals(deviceName, state.deviceName)
|
||||
assertEquals("23.5", state.temperature)
|
||||
assertEquals("45", state.humidity)
|
||||
assertFalse(state.isLoading)
|
||||
assertTrue(state.hasValidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadReading_whenDeviceOffline_setsOfflineState() = runTest {
|
||||
fakeGateway.setError(profileId, deviceName, DomainError.DeviceOffline())
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.isOffline)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadReading_whenNoDataError_setsNoDataState() = runTest {
|
||||
fakeGateway.setError(profileId, deviceName, DomainError.NoData())
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.hasNoData)
|
||||
assertFalse(state.isLoading)
|
||||
assertNotNull(state.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadReading_whenBlankValuesReturned_handlesAsNoData() = runTest {
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "",
|
||||
humidity = "",
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
|
||||
val state = viewModel.state.value
|
||||
assertTrue(state.hasNoData)
|
||||
assertNull(state.temperature)
|
||||
assertNull(state.humidity)
|
||||
assertEquals("--", state.formattedTemperature)
|
||||
assertEquals("--", state.formattedHumidity)
|
||||
assertFalse(state.hasValidData)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refresh_queriesUpdatedReading() = runTest {
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals("23.5", viewModel.state.value.temperature)
|
||||
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = deviceName,
|
||||
temperature = "30.2",
|
||||
humidity = "65",
|
||||
),
|
||||
)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Refresh)
|
||||
val state = viewModel.state.value
|
||||
assertEquals("30.2", state.temperature)
|
||||
assertEquals("65", state.humidity)
|
||||
assertEquals("30.2 °C", state.formattedTemperature)
|
||||
assertEquals("65 %", state.formattedHumidity)
|
||||
assertFalse(state.isRefreshing)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeDeviceName_updatesNameAndReloads() = runTest {
|
||||
val newDevice = DeviceName("th-2")
|
||||
fakeGateway.setReading(
|
||||
TemperatureHumidityReading(
|
||||
profileId = profileId,
|
||||
deviceName = newDevice,
|
||||
temperature = "26.8",
|
||||
humidity = "55",
|
||||
),
|
||||
)
|
||||
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
assertEquals("23.5", viewModel.state.value.temperature)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(newDevice))
|
||||
assertEquals(newDevice, viewModel.state.value.deviceName)
|
||||
assertEquals("26.8", viewModel.state.value.temperature)
|
||||
assertEquals("55", viewModel.state.value.humidity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearError_resetsErrorMessage() = runTest {
|
||||
fakeGateway.setError(profileId, deviceName, DomainError.Network())
|
||||
val viewModel = TemperatureHumidityViewModel(fakeGateway, SavedStateHandle())
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.Initialize(profileId, deviceName))
|
||||
assertNotNull(viewModel.state.value.errorMessage)
|
||||
|
||||
viewModel.onEvent(TemperatureHumidityUiEvent.ClearError)
|
||||
assertNull(viewModel.state.value.errorMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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
@@ -0,0 +1,136 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,194 @@
|
||||
package com.flagship.abox.manager.infrared.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class RoomInfraredKeyRepositoryTest {
|
||||
|
||||
private lateinit var database: InfraredDatabase
|
||||
private lateinit var repository: RoomInfraredKeyRepository
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(context, InfraredDatabase::class.java)
|
||||
.allowMainThreadQueries()
|
||||
.build()
|
||||
repository = RoomInfraredKeyRepository(database.infraredDao())
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
database.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observesOnlyRequestedProfileAndDevice() = runTest {
|
||||
repository.insert(testKey(profile = "one", device = "ir-1", id = "a", sdkKey = "key_a"))
|
||||
repository.insert(testKey(profile = "one", device = "ir-2", id = "b", sdkKey = "key_b"))
|
||||
repository.insert(testKey(profile = "two", device = "ir-1", id = "c", sdkKey = "key_c"))
|
||||
|
||||
val result = repository.observeKeys(
|
||||
ProviderProfileId("one"),
|
||||
DeviceName("ir-1"),
|
||||
).first() as DomainResult.Success
|
||||
|
||||
assertEquals(listOf(InfraredKeyId("a")), result.value.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsDuplicateSdkBinding() = runTest {
|
||||
val first = repository.insert(testKey(id = "a", sdkKey = "app_ir_power"))
|
||||
assertTrue(first is DomainResult.Success)
|
||||
|
||||
val duplicate = repository.insert(testKey(id = "b", sdkKey = "app_ir_power"))
|
||||
assertTrue(duplicate is DomainResult.Failure)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transitionsStatusCorrectly() = runTest {
|
||||
val key = testKey(id = "a", status = InfraredKeyStatus.LEARNING)
|
||||
repository.insert(key)
|
||||
|
||||
val transitionResult = repository.transitionStatus(
|
||||
ref = InfraredKeyRef(key.profileId, key.id),
|
||||
expectedStatuses = setOf(InfraredKeyStatus.LEARNING),
|
||||
targetStatus = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
updatedAtEpochMillis = 2_000L,
|
||||
)
|
||||
assertTrue(transitionResult is DomainResult.Success)
|
||||
|
||||
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, fetched.status)
|
||||
assertEquals(2_000L, fetched.updatedAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renamesKeyWithoutChangingSdkKey() = runTest {
|
||||
val key = testKey(id = "a", displayName = "Old Name", sdkKey = "app_ir_power")
|
||||
repository.insert(key)
|
||||
|
||||
val renameResult = repository.rename(
|
||||
ref = InfraredKeyRef(key.profileId, key.id),
|
||||
displayName = "New Name",
|
||||
updatedAtEpochMillis = 3_000L,
|
||||
)
|
||||
assertTrue(renameResult is DomainResult.Success)
|
||||
|
||||
val fetched = (repository.getKey(InfraredKeyRef(key.profileId, key.id)) as DomainResult.Success).value!!
|
||||
assertEquals("New Name", fetched.displayName)
|
||||
assertEquals("app_ir_power", fetched.sdkKey)
|
||||
assertEquals(3_000L, fetched.updatedAtEpochMillis)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRejectsLearningKeyAndRemovesReadyKey() = runTest {
|
||||
val learningKey = testKey(id = "learning", status = InfraredKeyStatus.LEARNING)
|
||||
val readyKey = testKey(id = "ready", sdkKey = "ready_sdk_key", status = InfraredKeyStatus.READY)
|
||||
repository.insert(learningKey)
|
||||
repository.insert(readyKey)
|
||||
|
||||
val deleteLearning = repository.deleteIfNotLearning(InfraredKeyRef(learningKey.profileId, learningKey.id))
|
||||
assertTrue(deleteLearning is DomainResult.Failure)
|
||||
|
||||
val deleteReady = repository.deleteIfNotLearning(InfraredKeyRef(readyKey.profileId, readyKey.id))
|
||||
assertTrue(deleteReady is DomainResult.Success)
|
||||
|
||||
val fetchedReady = (repository.getKey(InfraredKeyRef(readyKey.profileId, readyKey.id)) as DomainResult.Success).value
|
||||
assertNull(fetchedReady)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failsInterruptedLearningKeys() = runTest {
|
||||
repository.insert(testKey(id = "k1", sdkKey = "k1", status = InfraredKeyStatus.LEARNING))
|
||||
repository.insert(testKey(id = "k2", sdkKey = "k2", status = InfraredKeyStatus.LEARNING))
|
||||
repository.insert(testKey(id = "k3", sdkKey = "k3", status = InfraredKeyStatus.READY))
|
||||
|
||||
val countResult = repository.failInterruptedLearning(ProviderProfileId("profile1"), 5_000L) as DomainResult.Success
|
||||
assertEquals(2, countResult.value)
|
||||
|
||||
val k1 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.FAILED, k1.status)
|
||||
assertEquals(INTERRUPTED_LEARNING_ERROR_DETAIL, k1.lastErrorDetail)
|
||||
assertNull(k1.lastErrorCode)
|
||||
assertEquals(5_000L, k1.updatedAtEpochMillis)
|
||||
|
||||
val k3 = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("k3"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, k3.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failInterruptedLearningRespectsCutoffEpochMillis() = runTest {
|
||||
repository.insert(testKey(id = "old_k", sdkKey = "old_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 100L))
|
||||
repository.insert(testKey(id = "new_k", sdkKey = "new_k", status = InfraredKeyStatus.LEARNING).copy(createdAtEpochMillis = 200L))
|
||||
|
||||
val countResult = repository.failInterruptedLearning(
|
||||
profileId = ProviderProfileId("profile1"),
|
||||
cutoffEpochMillis = 150L,
|
||||
updatedAtEpochMillis = 150L,
|
||||
) as DomainResult.Success
|
||||
assertEquals(1, countResult.value)
|
||||
|
||||
val oldK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("old_k"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.FAILED, oldK.status)
|
||||
|
||||
val newK = (repository.getKey(InfraredKeyRef(ProviderProfileId("profile1"), InfraredKeyId("new_k"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.LEARNING, newK.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistsAndObservesDeviceName() = runTest {
|
||||
val initial = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
|
||||
assertNull(initial.value)
|
||||
|
||||
val setResult = repository.setDeviceName(ProviderProfileId("profile1"), DeviceName("ir_main"))
|
||||
assertTrue(setResult is DomainResult.Success)
|
||||
|
||||
val observed = repository.observeDeviceName(ProviderProfileId("profile1")).first() as DomainResult.Success
|
||||
assertEquals(DeviceName("ir_main"), observed.value)
|
||||
}
|
||||
|
||||
private fun testKey(
|
||||
profile: String = "profile1",
|
||||
device: String = "ir-1",
|
||||
id: String = "key-1",
|
||||
displayName: String = "Power",
|
||||
sdkKey: String = "app_ir_key1",
|
||||
status: InfraredKeyStatus = InfraredKeyStatus.LEARNING,
|
||||
): SavedInfraredKey {
|
||||
return SavedInfraredKey(
|
||||
id = InfraredKeyId(id),
|
||||
profileId = ProviderProfileId(profile),
|
||||
deviceName = DeviceName(device),
|
||||
displayName = displayName,
|
||||
sdkKey = sdkKey,
|
||||
status = status,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 1_000L,
|
||||
updatedAtEpochMillis = 1_000L,
|
||||
)
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.flagship.abox.manager.infrared.remote
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.infrared.remote.ui.InfraredRemoteScreen
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class InfraredRemoteScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun rendersAcPanelAndTvPanelCorrectly() {
|
||||
val state = InfraredRemoteUiState(
|
||||
selectedScene = RemoteScene.AIR_CONDITIONER,
|
||||
savedDeviceName = DeviceName("ir-1"),
|
||||
)
|
||||
var clickedSpec: RemoteButtonSpec? = null
|
||||
var settingsClicked = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
InfraredRemoteScreen(
|
||||
state = state,
|
||||
onBack = {},
|
||||
onOpenSettings = { settingsClicked = true },
|
||||
onSessionInvalid = {},
|
||||
onSceneSelected = {},
|
||||
onToggleLearningMode = {},
|
||||
onButtonClick = { clickedSpec = it },
|
||||
onMessageShown = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("remote_button_AC_POWER").assertIsDisplayed().performClick()
|
||||
assertEquals("AC_POWER", clickedSpec?.id)
|
||||
|
||||
composeTestRule.onNodeWithTag("remote_settings_button").assertIsDisplayed().performClick()
|
||||
assertTrue(settingsClicked)
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
package com.flagship.abox.manager.infrared.ui
|
||||
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class InfraredControlScreenTest {
|
||||
|
||||
@get:Rule
|
||||
val composeTestRule = createComposeRule()
|
||||
|
||||
@Test
|
||||
fun rendersControlScreenAndTriggersKeyOperations() {
|
||||
val testKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = ProviderProfileId("profile1"),
|
||||
deviceName = DeviceName("ir-1"),
|
||||
displayName = "Power",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
val state = InfraredControlUiState(
|
||||
profileId = ProviderProfileId("profile1"),
|
||||
providerType = ProviderType.LOCAL_EXPERIENCE,
|
||||
savedDeviceName = DeviceName("ir-1"),
|
||||
keys = listOf(testKey),
|
||||
loading = false,
|
||||
)
|
||||
|
||||
var sendClicked = false
|
||||
var deleteClicked = false
|
||||
|
||||
composeTestRule.setContent {
|
||||
InfraredControlScreen(
|
||||
state = state,
|
||||
onBack = {},
|
||||
onSessionInvalid = {},
|
||||
onDeviceNameChanged = {},
|
||||
onSaveDeviceName = {},
|
||||
onLearn = { _, _ -> },
|
||||
onRetry = {},
|
||||
onSend = { sendClicked = true },
|
||||
onRename = { _, _ -> },
|
||||
onDelete = { deleteClicked = true },
|
||||
onMessageShown = {},
|
||||
nextSlot = 2,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("infrared_key_k1").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("infrared_send_k1").assertIsDisplayed().performClick()
|
||||
assertTrue(sendClicked)
|
||||
|
||||
composeTestRule.onNodeWithTag("infrared_delete_btn_k1").assertIsDisplayed().performClick()
|
||||
composeTestRule.onNodeWithTag("infrared_delete_confirm").assertIsDisplayed().performClick()
|
||||
assertTrue(deleteClicked)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
</manifest>
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
package com.flagship.abox.manager.infrared.application
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class InfraredLearningCoordinator(
|
||||
private val learnInfraredKey: LearnInfraredKey,
|
||||
private val retryInfraredKeyLearning: RetryInfraredKeyLearning,
|
||||
private val relearnInfraredKey: RelearnInfraredKey,
|
||||
private val keyRepository: InfraredKeyRepository,
|
||||
private val externalScope: CoroutineScope,
|
||||
) {
|
||||
private val mutex = Mutex()
|
||||
private var cleanupJob: Job? = null
|
||||
|
||||
fun learn(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
displayName: String,
|
||||
sdkKey: String? = null,
|
||||
): Deferred<DomainResult<InfraredKeyId>> = externalScope.async {
|
||||
mutex.withLock {
|
||||
cleanupJob?.join()
|
||||
}
|
||||
learnInfraredKey(profileId, deviceName, displayName, sdkKey)
|
||||
}
|
||||
|
||||
fun retry(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
||||
mutex.withLock {
|
||||
cleanupJob?.join()
|
||||
}
|
||||
retryInfraredKeyLearning(ref)
|
||||
}
|
||||
|
||||
fun relearn(ref: InfraredKeyRef): Deferred<DomainResult<Unit>> = externalScope.async {
|
||||
mutex.withLock {
|
||||
cleanupJob?.join()
|
||||
}
|
||||
relearnInfraredKey(ref)
|
||||
}
|
||||
|
||||
fun failInterruptedLearning(
|
||||
profileId: ProviderProfileId,
|
||||
cutoffEpochMillis: Long = System.currentTimeMillis(),
|
||||
updatedAtEpochMillis: Long = cutoffEpochMillis,
|
||||
): Job {
|
||||
val job = externalScope.launch(NonCancellable) {
|
||||
keyRepository.failInterruptedLearning(
|
||||
profileId = profileId,
|
||||
cutoffEpochMillis = cutoffEpochMillis,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
}
|
||||
cleanupJob = job
|
||||
return job
|
||||
}
|
||||
}
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
package com.flagship.abox.manager.infrared.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface InfraredDao {
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM infrared_keys
|
||||
WHERE profileId = :profileId AND deviceName = :deviceName
|
||||
ORDER BY createdAtEpochMillis ASC
|
||||
""",
|
||||
)
|
||||
fun observeKeys(profileId: String, deviceName: String): Flow<List<InfraredKeyEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM infrared_keys
|
||||
WHERE profileId = :profileId AND id = :id
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
suspend fun getKey(profileId: String, id: String): InfraredKeyEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT)
|
||||
suspend fun insertKey(entity: InfraredKeyEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE infrared_keys
|
||||
SET status = :targetStatus,
|
||||
lastErrorCode = :lastErrorCode,
|
||||
lastErrorDetail = :lastErrorDetail,
|
||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
||||
WHERE profileId = :profileId
|
||||
AND id = :id
|
||||
AND status IN (:expectedStatuses)
|
||||
""",
|
||||
)
|
||||
suspend fun transitionStatus(
|
||||
profileId: String,
|
||||
id: String,
|
||||
expectedStatuses: List<String>,
|
||||
targetStatus: String,
|
||||
lastErrorCode: String?,
|
||||
lastErrorDetail: String?,
|
||||
updatedAtEpochMillis: Long,
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE infrared_keys
|
||||
SET displayName = :displayName,
|
||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
||||
WHERE profileId = :profileId AND id = :id
|
||||
""",
|
||||
)
|
||||
suspend fun renameKey(
|
||||
profileId: String,
|
||||
id: String,
|
||||
displayName: String,
|
||||
updatedAtEpochMillis: Long,
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM infrared_keys
|
||||
WHERE profileId = :profileId AND id = :id AND status != 'LEARNING'
|
||||
""",
|
||||
)
|
||||
suspend fun deleteKeyIfNotLearning(profileId: String, id: String): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE infrared_keys
|
||||
SET status = :targetStatus,
|
||||
lastErrorCode = NULL,
|
||||
lastErrorDetail = :errorDetail,
|
||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
||||
WHERE profileId = :profileId
|
||||
AND status = 'LEARNING'
|
||||
AND createdAtEpochMillis <= :cutoffEpochMillis
|
||||
""",
|
||||
)
|
||||
suspend fun failInterruptedLearning(
|
||||
profileId: String,
|
||||
targetStatus: String,
|
||||
errorDetail: String,
|
||||
cutoffEpochMillis: Long,
|
||||
updatedAtEpochMillis: Long,
|
||||
): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM infrared_device_configuration
|
||||
WHERE profileId = :profileId
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
fun observeDeviceName(profileId: String): Flow<InfraredDeviceConfigurationEntity?>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun setDeviceName(entity: InfraredDeviceConfigurationEntity)
|
||||
|
||||
// Virtual Remotes
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM virtual_remotes
|
||||
WHERE profileId = :profileId AND deviceName = :deviceName
|
||||
ORDER BY createdAtEpochMillis DESC
|
||||
""",
|
||||
)
|
||||
fun observeVirtualRemotes(profileId: String, deviceName: String): Flow<List<VirtualRemoteEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM virtual_remotes
|
||||
WHERE profileId = :profileId AND id = :id
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
fun observeVirtualRemote(profileId: String, id: String): Flow<VirtualRemoteEntity?>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM virtual_remotes
|
||||
WHERE profileId = :profileId AND id = :id
|
||||
LIMIT 1
|
||||
""",
|
||||
)
|
||||
suspend fun getVirtualRemote(profileId: String, id: String): VirtualRemoteEntity?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertVirtualRemote(entity: VirtualRemoteEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM virtual_remotes
|
||||
WHERE profileId = :profileId AND id = :id
|
||||
""",
|
||||
)
|
||||
suspend fun deleteVirtualRemote(profileId: String, id: String): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
UPDATE virtual_remotes
|
||||
SET acPower = :power,
|
||||
acMode = :mode,
|
||||
acTemperature = :temperature,
|
||||
acFanSpeed = :fanSpeed,
|
||||
acVerticalSwing = :verticalSwing,
|
||||
acHorizontalSwing = :horizontalSwing,
|
||||
acEco = :eco,
|
||||
updatedAtEpochMillis = :updatedAtEpochMillis
|
||||
WHERE profileId = :profileId AND id = :id
|
||||
""",
|
||||
)
|
||||
suspend fun updateAcState(
|
||||
profileId: String,
|
||||
id: String,
|
||||
power: Boolean?,
|
||||
mode: String?,
|
||||
temperature: Int?,
|
||||
fanSpeed: String?,
|
||||
verticalSwing: Boolean?,
|
||||
horizontalSwing: Boolean?,
|
||||
eco: Boolean?,
|
||||
updatedAtEpochMillis: Long,
|
||||
): Int
|
||||
|
||||
// Remote Key Bindings
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM remote_key_bindings
|
||||
WHERE remoteId = :remoteId
|
||||
""",
|
||||
)
|
||||
fun observeKeyBindings(remoteId: String): Flow<List<RemoteKeyBindingEntity>>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM remote_key_bindings
|
||||
WHERE remoteId = :remoteId
|
||||
""",
|
||||
)
|
||||
suspend fun getKeyBindings(remoteId: String): List<RemoteKeyBindingEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertKeyBindings(entities: List<RemoteKeyBindingEntity>)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insertKeyBinding(entity: RemoteKeyBindingEntity)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM remote_key_bindings
|
||||
WHERE remoteId = :remoteId
|
||||
""",
|
||||
)
|
||||
suspend fun deleteKeyBindingsByRemoteId(remoteId: String): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM remote_key_bindings
|
||||
WHERE remoteId = :remoteId AND buttonId = :buttonId
|
||||
""",
|
||||
)
|
||||
suspend fun deleteKeyBinding(remoteId: String, buttonId: String): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT b.assignedSlot FROM remote_key_bindings b
|
||||
INNER JOIN virtual_remotes r ON b.remoteId = r.id
|
||||
WHERE r.profileId = :profileId AND r.deviceName = :deviceName AND b.assignedSlot IS NOT NULL
|
||||
""",
|
||||
)
|
||||
fun observeAssignedStaticSlots(profileId: String, deviceName: String): Flow<List<String>>
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.flagship.abox.manager.infrared.data.local
|
||||
|
||||
import androidx.room.Database
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
InfraredKeyEntity::class,
|
||||
InfraredDeviceConfigurationEntity::class,
|
||||
VirtualRemoteEntity::class,
|
||||
RemoteKeyBindingEntity::class,
|
||||
],
|
||||
version = 2,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class InfraredDatabase : RoomDatabase() {
|
||||
abstract fun infraredDao(): InfraredDao
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.flagship.abox.manager.infrared.data.local
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Index
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(
|
||||
tableName = "infrared_keys",
|
||||
indices = [
|
||||
Index(value = ["profileId", "deviceName"]),
|
||||
Index(value = ["profileId", "deviceName", "sdkKey"], unique = true),
|
||||
],
|
||||
)
|
||||
data class InfraredKeyEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val profileId: String,
|
||||
val deviceName: String,
|
||||
val displayName: String,
|
||||
val sdkKey: String,
|
||||
val status: String,
|
||||
val lastErrorCode: String?,
|
||||
val lastErrorDetail: String?,
|
||||
val createdAtEpochMillis: Long,
|
||||
val updatedAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(tableName = "infrared_device_configuration")
|
||||
data class InfraredDeviceConfigurationEntity(
|
||||
@PrimaryKey
|
||||
val profileId: String,
|
||||
val deviceName: String,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "virtual_remotes",
|
||||
indices = [
|
||||
Index(value = ["profileId", "deviceName"]),
|
||||
],
|
||||
)
|
||||
data class VirtualRemoteEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val profileId: String,
|
||||
val deviceName: String,
|
||||
val displayName: String,
|
||||
val type: String,
|
||||
val brandModel: String?,
|
||||
val layoutStyle: String?,
|
||||
val transmissionMode: String,
|
||||
val dynamicScratchSlot: String,
|
||||
val acPower: Boolean?,
|
||||
val acMode: String?,
|
||||
val acTemperature: Int?,
|
||||
val acFanSpeed: String?,
|
||||
val acVerticalSwing: Boolean?,
|
||||
val acHorizontalSwing: Boolean?,
|
||||
val acEco: Boolean?,
|
||||
val createdAtEpochMillis: Long,
|
||||
val updatedAtEpochMillis: Long,
|
||||
)
|
||||
|
||||
@Entity(
|
||||
tableName = "remote_key_bindings",
|
||||
indices = [
|
||||
Index(value = ["remoteId"]),
|
||||
Index(value = ["profileId"]),
|
||||
],
|
||||
)
|
||||
data class RemoteKeyBindingEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
val remoteId: String,
|
||||
val profileId: String,
|
||||
val buttonId: String,
|
||||
val displayName: String,
|
||||
val assignedSlot: String?,
|
||||
val rawHexCode: String?,
|
||||
val status: String,
|
||||
val lastErrorCode: String?,
|
||||
val lastErrorDetail: String?,
|
||||
)
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.flagship.abox.manager.infrared.data.local
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
|
||||
fun InfraredKeyEntity.toDomain(): SavedInfraredKey {
|
||||
return SavedInfraredKey(
|
||||
id = InfraredKeyId(id),
|
||||
profileId = ProviderProfileId(profileId),
|
||||
deviceName = DeviceName(deviceName),
|
||||
displayName = displayName,
|
||||
sdkKey = sdkKey,
|
||||
status = InfraredKeyStatus.valueOf(status),
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
createdAtEpochMillis = createdAtEpochMillis,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
}
|
||||
|
||||
fun SavedInfraredKey.toEntity(): InfraredKeyEntity {
|
||||
return InfraredKeyEntity(
|
||||
id = id.value,
|
||||
profileId = profileId.value,
|
||||
deviceName = deviceName.value,
|
||||
displayName = displayName,
|
||||
sdkKey = sdkKey,
|
||||
status = status.name,
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
createdAtEpochMillis = createdAtEpochMillis,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
}
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
package com.flagship.abox.manager.infrared.data.local
|
||||
|
||||
import android.database.sqlite.SQLiteConstraintException
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.INTERRUPTED_LEARNING_ERROR_DETAIL
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class RoomInfraredKeyRepository(
|
||||
private val dao: InfraredDao,
|
||||
) : InfraredKeyRepository, InfraredDeviceConfigurationRepository {
|
||||
|
||||
override fun observeKeys(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<List<SavedInfraredKey>>> {
|
||||
return dao.observeKeys(profileId.value, deviceName.value)
|
||||
.map<List<InfraredKeyEntity>, DomainResult<List<SavedInfraredKey>>> { entities ->
|
||||
DomainResult.Success(entities.map { it.toDomain() })
|
||||
}
|
||||
.catch { throwable ->
|
||||
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
||||
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
||||
}
|
||||
}
|
||||
|
||||
override fun observeDeviceName(profileId: ProviderProfileId): Flow<DomainResult<DeviceName?>> {
|
||||
return dao.observeDeviceName(profileId.value)
|
||||
.map<InfraredDeviceConfigurationEntity?, DomainResult<DeviceName?>> { entity ->
|
||||
DomainResult.Success(entity?.deviceName?.let { DeviceName(it) })
|
||||
}
|
||||
.catch { throwable ->
|
||||
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
||||
emit(DomainResult.Failure(mapThrowableToDomainError(throwable)))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getKey(ref: InfraredKeyRef): DomainResult<SavedInfraredKey?> {
|
||||
return try {
|
||||
val entity = dao.getKey(ref.profileId.value, ref.keyId.value)
|
||||
DomainResult.Success(entity?.toDomain())
|
||||
} catch (throwable: Throwable) {
|
||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun insert(key: SavedInfraredKey): DomainResult<Unit> {
|
||||
return try {
|
||||
dao.insertKey(key.toEntity())
|
||||
DomainResult.Success(Unit)
|
||||
} catch (throwable: Throwable) {
|
||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun transitionStatus(
|
||||
ref: InfraredKeyRef,
|
||||
expectedStatuses: Set<InfraredKeyStatus>,
|
||||
targetStatus: InfraredKeyStatus,
|
||||
lastErrorCode: String?,
|
||||
lastErrorDetail: String?,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Unit> {
|
||||
return try {
|
||||
val updated = dao.transitionStatus(
|
||||
profileId = ref.profileId.value,
|
||||
id = ref.keyId.value,
|
||||
expectedStatuses = expectedStatuses.map { it.name },
|
||||
targetStatus = targetStatus.name,
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
if (updated > 0) {
|
||||
DomainResult.Success(Unit)
|
||||
} else {
|
||||
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
|
||||
if (existing == null) {
|
||||
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
|
||||
} else {
|
||||
DomainResult.Failure(
|
||||
DomainError.InvalidRequest(
|
||||
detail = "Cannot transition key from ${existing.status} to ${targetStatus.name}",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun rename(
|
||||
ref: InfraredKeyRef,
|
||||
displayName: String,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Unit> {
|
||||
val trimmed = displayName.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Display name cannot be blank"))
|
||||
}
|
||||
return try {
|
||||
val updated = dao.renameKey(
|
||||
profileId = ref.profileId.value,
|
||||
id = ref.keyId.value,
|
||||
displayName = trimmed,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
if (updated > 0) {
|
||||
DomainResult.Success(Unit)
|
||||
} else {
|
||||
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun deleteIfNotLearning(ref: InfraredKeyRef): DomainResult<Unit> {
|
||||
return try {
|
||||
val deleted = dao.deleteKeyIfNotLearning(ref.profileId.value, ref.keyId.value)
|
||||
if (deleted > 0) {
|
||||
DomainResult.Success(Unit)
|
||||
} else {
|
||||
val existing = dao.getKey(ref.profileId.value, ref.keyId.value)
|
||||
if (existing == null) {
|
||||
DomainResult.Failure(DomainError.NoData(detail = "Infrared key not found"))
|
||||
} else {
|
||||
DomainResult.Failure(
|
||||
DomainError.InvalidRequest(detail = "Cannot delete key while learning is in progress"),
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (throwable: Throwable) {
|
||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun failInterruptedLearning(
|
||||
profileId: ProviderProfileId,
|
||||
cutoffEpochMillis: Long,
|
||||
updatedAtEpochMillis: Long,
|
||||
): DomainResult<Int> {
|
||||
return try {
|
||||
val count = dao.failInterruptedLearning(
|
||||
profileId = profileId.value,
|
||||
targetStatus = InfraredKeyStatus.FAILED.name,
|
||||
errorDetail = INTERRUPTED_LEARNING_ERROR_DETAIL,
|
||||
cutoffEpochMillis = cutoffEpochMillis,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
DomainResult.Success(count)
|
||||
} catch (throwable: Throwable) {
|
||||
if (throwable is CancellationException) {
|
||||
throw throwable
|
||||
}
|
||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun setDeviceName(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): DomainResult<Unit> {
|
||||
val trimmed = deviceName.value.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
return DomainResult.Failure(DomainError.InvalidRequest(detail = "Device name cannot be blank"))
|
||||
}
|
||||
return try {
|
||||
dao.setDeviceName(
|
||||
InfraredDeviceConfigurationEntity(
|
||||
profileId = profileId.value,
|
||||
deviceName = trimmed,
|
||||
),
|
||||
)
|
||||
DomainResult.Success(Unit)
|
||||
} catch (throwable: Throwable) {
|
||||
DomainResult.Failure(mapThrowableToDomainError(throwable))
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapThrowableToDomainError(throwable: Throwable): DomainError {
|
||||
if (throwable is kotlinx.coroutines.CancellationException) {
|
||||
throw throwable
|
||||
}
|
||||
return when (throwable) {
|
||||
is SQLiteConstraintException -> DomainError.InvalidRequest(detail = "Database constraint violation")
|
||||
else -> DomainError.Unknown(detail = "Database operation failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package com.flagship.abox.manager.infrared.data.local
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import javax.inject.Inject
|
||||
|
||||
class RoomVirtualRemoteRepository @Inject constructor(
|
||||
private val dao: InfraredDao,
|
||||
) : VirtualRemoteRepository {
|
||||
|
||||
override fun observeRemotes(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<List<VirtualRemote>>> =
|
||||
dao.observeVirtualRemotes(profileId.value, deviceName.value).map { entities ->
|
||||
DomainResult.Success(entities.map { it.toDomain() })
|
||||
}
|
||||
|
||||
override fun observeRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
): Flow<DomainResult<VirtualRemote?>> =
|
||||
dao.observeVirtualRemote(profileId.value, remoteId.value).map { entity ->
|
||||
DomainResult.Success(entity?.toDomain())
|
||||
}
|
||||
|
||||
override fun observeKeyBindings(
|
||||
remoteId: VirtualRemoteId,
|
||||
): Flow<DomainResult<List<RemoteKeyBinding>>> =
|
||||
dao.observeKeyBindings(remoteId.value).map { entities ->
|
||||
DomainResult.Success(entities.map { it.toDomain() })
|
||||
}
|
||||
|
||||
override fun observeSlotQuota(
|
||||
profileId: ProviderProfileId,
|
||||
deviceName: DeviceName,
|
||||
): Flow<DomainResult<SlotQuotaSummary>> =
|
||||
dao.observeAssignedStaticSlots(profileId.value, deviceName.value).map { slots ->
|
||||
val slotInts = slots.mapNotNull { it.toIntOrNull() }.toSet()
|
||||
DomainResult.Success(
|
||||
SlotQuotaSummary(
|
||||
totalCapacity = 100,
|
||||
dynamicScratchSlot = 100,
|
||||
assignedStaticSlots = slotInts,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun getRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
): DomainResult<VirtualRemote?> = try {
|
||||
val entity = dao.getVirtualRemote(profileId.value, remoteId.value)
|
||||
DomainResult.Success(entity?.toDomain())
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
|
||||
override suspend fun getKeyBindings(
|
||||
remoteId: VirtualRemoteId,
|
||||
): DomainResult<List<RemoteKeyBinding>> = try {
|
||||
val list = dao.getKeyBindings(remoteId.value)
|
||||
DomainResult.Success(list.map { it.toDomain() })
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
|
||||
override suspend fun createRemote(
|
||||
remote: VirtualRemote,
|
||||
initialBindings: List<RemoteKeyBinding>,
|
||||
): DomainResult<VirtualRemoteId> = try {
|
||||
dao.insertVirtualRemote(remote.toEntity())
|
||||
dao.insertKeyBindings(initialBindings.map { it.toEntity() })
|
||||
DomainResult.Success(remote.id)
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
|
||||
override suspend fun updateRemote(remote: VirtualRemote): DomainResult<Unit> = try {
|
||||
dao.insertVirtualRemote(remote.toEntity())
|
||||
DomainResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
|
||||
override suspend fun deleteRemote(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
): DomainResult<Unit> = try {
|
||||
dao.deleteVirtualRemote(profileId.value, remoteId.value)
|
||||
dao.deleteKeyBindingsByRemoteId(remoteId.value)
|
||||
DomainResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
|
||||
override suspend fun updateKeyBinding(binding: RemoteKeyBinding): DomainResult<Unit> = try {
|
||||
dao.insertKeyBinding(binding.toEntity())
|
||||
DomainResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
|
||||
override suspend fun deleteKeyBinding(
|
||||
remoteId: VirtualRemoteId,
|
||||
buttonId: String,
|
||||
): DomainResult<Unit> = try {
|
||||
dao.deleteKeyBinding(remoteId.value, buttonId)
|
||||
DomainResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
|
||||
override suspend fun updateAcState(
|
||||
profileId: ProviderProfileId,
|
||||
remoteId: VirtualRemoteId,
|
||||
state: AcStateSnapshot,
|
||||
): DomainResult<Unit> = try {
|
||||
dao.updateAcState(
|
||||
profileId = profileId.value,
|
||||
id = remoteId.value,
|
||||
power = state.power,
|
||||
mode = state.mode.name,
|
||||
temperature = state.temperature,
|
||||
fanSpeed = state.fanSpeed.name,
|
||||
verticalSwing = state.verticalSwing,
|
||||
horizontalSwing = state.horizontalSwing,
|
||||
eco = state.eco,
|
||||
updatedAtEpochMillis = System.currentTimeMillis(),
|
||||
)
|
||||
DomainResult.Success(Unit)
|
||||
} catch (e: Exception) {
|
||||
DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.OperationFailed(detail = e.message))
|
||||
}
|
||||
}
|
||||
|
||||
private fun VirtualRemoteEntity.toDomain(): VirtualRemote {
|
||||
val ac = if (type == RemoteDeviceType.AIR_CONDITIONER.name) {
|
||||
AcStateSnapshot(
|
||||
power = acPower ?: true,
|
||||
mode = acMode?.let { runCatching { AcMode.valueOf(it) }.getOrNull() } ?: AcMode.COOL,
|
||||
temperature = acTemperature ?: 26,
|
||||
fanSpeed = acFanSpeed?.let { runCatching { AcFanSpeed.valueOf(it) }.getOrNull() } ?: AcFanSpeed.AUTO,
|
||||
verticalSwing = acVerticalSwing ?: true,
|
||||
horizontalSwing = acHorizontalSwing ?: false,
|
||||
eco = acEco ?: false,
|
||||
)
|
||||
} else null
|
||||
|
||||
val devType = runCatching { RemoteDeviceType.valueOf(type) }.getOrDefault(RemoteDeviceType.CUSTOM)
|
||||
val parsedLayout = layoutStyle?.let { runCatching { com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.valueOf(it) }.getOrNull() }
|
||||
?: VirtualRemote.defaultLayoutFor(devType)
|
||||
|
||||
return VirtualRemote(
|
||||
id = VirtualRemoteId(id),
|
||||
profileId = ProviderProfileId(profileId),
|
||||
deviceName = DeviceName(deviceName),
|
||||
displayName = displayName,
|
||||
type = devType,
|
||||
brandModel = brandModel,
|
||||
layoutStyle = parsedLayout,
|
||||
transmissionMode = runCatching { TransmissionMode.valueOf(transmissionMode) }.getOrDefault(TransmissionMode.DYNAMIC_SCRATCH),
|
||||
dynamicScratchSlot = dynamicScratchSlot,
|
||||
acState = ac,
|
||||
createdAtEpochMillis = createdAtEpochMillis,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
}
|
||||
|
||||
private fun VirtualRemote.toEntity(): VirtualRemoteEntity = VirtualRemoteEntity(
|
||||
id = id.value,
|
||||
profileId = profileId.value,
|
||||
deviceName = deviceName.value,
|
||||
displayName = displayName,
|
||||
type = type.name,
|
||||
brandModel = brandModel,
|
||||
layoutStyle = layoutStyle.name,
|
||||
transmissionMode = transmissionMode.name,
|
||||
dynamicScratchSlot = dynamicScratchSlot,
|
||||
acPower = acState?.power,
|
||||
acMode = acState?.mode?.name,
|
||||
acTemperature = acState?.temperature,
|
||||
acFanSpeed = acState?.fanSpeed?.name,
|
||||
acVerticalSwing = acState?.verticalSwing,
|
||||
acHorizontalSwing = acState?.horizontalSwing,
|
||||
acEco = acState?.eco,
|
||||
createdAtEpochMillis = createdAtEpochMillis,
|
||||
updatedAtEpochMillis = updatedAtEpochMillis,
|
||||
)
|
||||
|
||||
private fun RemoteKeyBindingEntity.toDomain(): RemoteKeyBinding = RemoteKeyBinding(
|
||||
id = id,
|
||||
remoteId = VirtualRemoteId(remoteId),
|
||||
profileId = ProviderProfileId(profileId),
|
||||
buttonId = buttonId,
|
||||
displayName = displayName,
|
||||
assignedSlot = assignedSlot,
|
||||
rawHexCode = rawHexCode,
|
||||
status = runCatching { InfraredKeyStatus.valueOf(status) }.getOrDefault(InfraredKeyStatus.READY),
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
)
|
||||
|
||||
private fun RemoteKeyBinding.toEntity(): RemoteKeyBindingEntity = RemoteKeyBindingEntity(
|
||||
id = id,
|
||||
remoteId = remoteId.value,
|
||||
profileId = profileId.value,
|
||||
buttonId = buttonId,
|
||||
displayName = displayName,
|
||||
assignedSlot = assignedSlot,
|
||||
rawHexCode = rawHexCode,
|
||||
status = status.name,
|
||||
lastErrorCode = lastErrorCode,
|
||||
lastErrorDetail = lastErrorDetail,
|
||||
)
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package com.flagship.abox.manager.infrared.di
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import com.flagship.abox.manager.domain.InfraredGateway
|
||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||
import com.flagship.abox.manager.domain.infrared.DeleteInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyIdGenerator
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RecoverInterruptedInfraredLearning
|
||||
import com.flagship.abox.manager.domain.infrared.RenameInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
||||
import com.flagship.abox.manager.infrared.data.local.InfraredDao
|
||||
import com.flagship.abox.manager.infrared.data.local.InfraredDatabase
|
||||
import com.flagship.abox.manager.infrared.data.local.RoomInfraredKeyRepository
|
||||
import com.flagship.abox.manager.infrared.data.local.RoomVirtualRemoteRepository
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import java.util.UUID
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class InfraredApplicationScope
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object InfraredFeatureModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideInfraredDatabase(
|
||||
@ApplicationContext context: Context,
|
||||
): InfraredDatabase {
|
||||
return Room.databaseBuilder(
|
||||
context,
|
||||
InfraredDatabase::class.java,
|
||||
"abox_infrared.db",
|
||||
).fallbackToDestructiveMigration().build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideInfraredDao(database: InfraredDatabase): InfraredDao = database.infraredDao()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRoomInfraredKeyRepository(dao: InfraredDao): RoomInfraredKeyRepository =
|
||||
RoomInfraredKeyRepository(dao)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideInfraredKeyRepository(
|
||||
repository: RoomInfraredKeyRepository,
|
||||
): InfraredKeyRepository = repository
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideInfraredDeviceConfigurationRepository(
|
||||
repository: RoomInfraredKeyRepository,
|
||||
): InfraredDeviceConfigurationRepository = repository
|
||||
|
||||
@Provides
|
||||
fun provideInfraredKeyIdGenerator(): InfraredKeyIdGenerator =
|
||||
InfraredKeyIdGenerator { InfraredKeyId(UUID.randomUUID().toString()) }
|
||||
|
||||
@Provides
|
||||
fun provideEpochMillisClock(): EpochMillisClock =
|
||||
EpochMillisClock { System.currentTimeMillis() }
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideInfraredKeyFactory(
|
||||
idGenerator: InfraredKeyIdGenerator,
|
||||
clock: EpochMillisClock,
|
||||
): InfraredKeyFactory = InfraredKeyFactory(idGenerator, clock)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
@InfraredApplicationScope
|
||||
fun provideInfraredApplicationScope(): CoroutineScope =
|
||||
CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLearnInfraredKey(
|
||||
repository: InfraredKeyRepository,
|
||||
gateway: InfraredGateway,
|
||||
factory: InfraredKeyFactory,
|
||||
clock: EpochMillisClock,
|
||||
): LearnInfraredKey = LearnInfraredKey(repository, gateway, factory, clock)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRetryInfraredKeyLearning(
|
||||
repository: InfraredKeyRepository,
|
||||
gateway: InfraredGateway,
|
||||
clock: EpochMillisClock,
|
||||
): RetryInfraredKeyLearning = RetryInfraredKeyLearning(repository, gateway, clock)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSendInfraredKey(
|
||||
repository: InfraredKeyRepository,
|
||||
gateway: InfraredGateway,
|
||||
): SendInfraredKey = SendInfraredKey(repository, gateway)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRenameInfraredKey(
|
||||
repository: InfraredKeyRepository,
|
||||
clock: EpochMillisClock,
|
||||
): RenameInfraredKey = RenameInfraredKey(repository, clock)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeleteInfraredKey(
|
||||
repository: InfraredKeyRepository,
|
||||
): DeleteInfraredKey = DeleteInfraredKey(repository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRecoverInterruptedInfraredLearning(
|
||||
repository: InfraredKeyRepository,
|
||||
clock: EpochMillisClock,
|
||||
): RecoverInterruptedInfraredLearning = RecoverInterruptedInfraredLearning(repository, clock)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRelearnInfraredKey(
|
||||
repository: InfraredKeyRepository,
|
||||
gateway: InfraredGateway,
|
||||
clock: EpochMillisClock,
|
||||
): com.flagship.abox.manager.domain.infrared.RelearnInfraredKey =
|
||||
com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(repository, gateway, clock)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideInfraredLearningCoordinator(
|
||||
learnInfraredKey: LearnInfraredKey,
|
||||
retryInfraredKeyLearning: RetryInfraredKeyLearning,
|
||||
relearnInfraredKey: com.flagship.abox.manager.domain.infrared.RelearnInfraredKey,
|
||||
repository: InfraredKeyRepository,
|
||||
@InfraredApplicationScope scope: CoroutineScope,
|
||||
): InfraredLearningCoordinator = InfraredLearningCoordinator(
|
||||
learnInfraredKey = learnInfraredKey,
|
||||
retryInfraredKeyLearning = retryInfraredKeyLearning,
|
||||
relearnInfraredKey = relearnInfraredKey,
|
||||
keyRepository = repository,
|
||||
externalScope = scope,
|
||||
)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRoomVirtualRemoteRepository(dao: InfraredDao): RoomVirtualRemoteRepository =
|
||||
RoomVirtualRemoteRepository(dao)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideVirtualRemoteRepository(
|
||||
repository: RoomVirtualRemoteRepository,
|
||||
): com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository = repository
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideCreateVirtualRemote(
|
||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||
gateway: InfraredGateway,
|
||||
clock: EpochMillisClock,
|
||||
): com.flagship.abox.manager.domain.infrared.CreateVirtualRemote =
|
||||
com.flagship.abox.manager.domain.infrared.CreateVirtualRemote(repository, gateway, clock)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideSendVirtualRemoteAction(
|
||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||
gateway: InfraredGateway,
|
||||
): com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction =
|
||||
com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction(repository, gateway)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeleteVirtualRemote(
|
||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote =
|
||||
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote(repository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideLearnVirtualRemoteKey(
|
||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||
gateway: InfraredGateway,
|
||||
): com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey =
|
||||
com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey(repository, gateway)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAddVirtualRemoteKey(
|
||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||
): com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey =
|
||||
com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey(repository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDeleteVirtualRemoteKey(
|
||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||
): com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey =
|
||||
com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey(repository)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideUpdateVirtualRemoteLayout(
|
||||
repository: com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository,
|
||||
): com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout =
|
||||
com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout(repository)
|
||||
}
|
||||
+593
@@ -0,0 +1,593 @@
|
||||
package com.flagship.abox.manager.infrared.hub
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||
import com.flagship.abox.manager.domain.infrared.PresetCodeLibrary
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
@Composable
|
||||
fun InfraredHubRoute(
|
||||
onBack: () -> Unit,
|
||||
onOpenRemote: (String) -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
viewModel: InfraredHubViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
InfraredHubScreen(
|
||||
state = state,
|
||||
onBack = onBack,
|
||||
onNavigateToSettings = onOpenSettings,
|
||||
onOpenRemote = onOpenRemote,
|
||||
onDeleteRemote = viewModel::deleteRemote,
|
||||
onCreateRemote = viewModel::createRemote,
|
||||
onSessionInvalid = onSessionInvalid,
|
||||
onMessageShown = viewModel::clearMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun InfraredHubScreen(
|
||||
state: InfraredHubUiState,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onOpenRemote: (String) -> Unit,
|
||||
onDeleteRemote: (VirtualRemoteId) -> Unit,
|
||||
onCreateRemote: (String, RemoteDeviceType, String?, TransmissionMode, PanelLayoutStyle?) -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
onMessageShown: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
var showCreateDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
when (msg) {
|
||||
is HubUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is HubUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is HubUiMessage.SessionInvalid -> {
|
||||
onSessionInvalid()
|
||||
onMessageShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_hub_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
state.providerDisplayName?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text(stringResource(R.string.infrared_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = onNavigateToSettings,
|
||||
modifier = Modifier.testTag("hub_settings_button"),
|
||||
) {
|
||||
Text("⚙️", fontSize = 20.sp)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
ABoxSurface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// 1. 硬件槽位配额看板 (100 槽位预算)
|
||||
QuotaSummaryCard(quota = state.slotQuota)
|
||||
|
||||
// 2. 遥控器列表标题与新建按钮
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_hub_my_remotes),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = { showCreateDialog = true },
|
||||
modifier = Modifier.testTag("hub_add_remote_button"),
|
||||
) {
|
||||
Text("➕ " + stringResource(R.string.infrared_hub_add_remote))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 遥控器网格卡片
|
||||
if (state.remotes.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_hub_no_remotes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxWidth().weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(state.remotes, key = { it.id.value }) { remote ->
|
||||
VirtualRemoteCard(
|
||||
remote = remote,
|
||||
onClick = { onOpenRemote(remote.id.value) },
|
||||
onDelete = { onDeleteRemote(remote.id) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCreateDialog) {
|
||||
CreateVirtualRemoteDialog(
|
||||
onDismiss = { showCreateDialog = false },
|
||||
onConfirm = { name, type, brandModel, transMode, layoutStyle ->
|
||||
onCreateRemote(name, type, brandModel, transMode, layoutStyle)
|
||||
showCreateDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VirtualRemoteCard(
|
||||
remote: VirtualRemote,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.testTag("remote_card_${remote.id.value}"),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val icon = when (remote.type) {
|
||||
RemoteDeviceType.TV -> "📺"
|
||||
RemoteDeviceType.AIR_CONDITIONER -> "❄️"
|
||||
RemoteDeviceType.FAN -> "🌀"
|
||||
RemoteDeviceType.CUSTOM -> "🎛️"
|
||||
}
|
||||
Text(icon, fontSize = 28.sp)
|
||||
IconButton(onClick = onDelete, modifier = Modifier.testTag("delete_remote_${remote.id.value}")) {
|
||||
Text("🗑️", fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = remote.displayName,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
val modeText = if (remote.type == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
"❄️ 状态机动态写码"
|
||||
} else if (remote.transmissionMode == TransmissionMode.STATIC_SLOTS) {
|
||||
"🚀 静态独占槽位"
|
||||
} else {
|
||||
"📦 动态写码"
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${remote.layoutStyle.name} · $modeText",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuotaSummaryCard(quota: SlotQuotaSummary) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "硬件槽位配额 (总计 100 槽位)",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
Text(
|
||||
text = "${quota.usedCount} / ${quota.totalCapacity} 已占用",
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "静态槽位占用: ${quota.assignedStaticSlots.size} 个 | 动态缓冲: Slot 100 (共享) | 剩余静态: ${quota.remainingStaticCount} 个",
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun CreateVirtualRemoteDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String, type: RemoteDeviceType, brandModel: String?, transMode: TransmissionMode, layoutStyle: PanelLayoutStyle?) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
var selectedType by remember { mutableStateOf(RemoteDeviceType.TV) }
|
||||
var selectedBrand by remember { mutableStateOf(PresetCodeLibrary.availablePresets.first()) }
|
||||
var selectedCustomLayout by remember { mutableStateOf(PanelLayoutStyle.TV_NAV) }
|
||||
var transmissionMode by remember { mutableStateOf(TransmissionMode.STATIC_SLOTS) }
|
||||
|
||||
var typeDropdownExpanded by remember { mutableStateOf(false) }
|
||||
var brandDropdownExpanded by remember { mutableStateOf(false) }
|
||||
var layoutDropdownExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
val filteredPresets = PresetCodeLibrary.availablePresets.filter { it.deviceType == selectedType }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.infrared_hub_create_dialog_title)) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// 1. 设备类型选择
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = typeDropdownExpanded,
|
||||
onExpandedChange = { typeDropdownExpanded = !typeDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = when (selectedType) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
},
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.infrared_hub_device_type_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = typeDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = typeDropdownExpanded,
|
||||
onDismissRequest = { typeDropdownExpanded = false },
|
||||
) {
|
||||
RemoteDeviceType.entries.forEach { type ->
|
||||
val label = when (type) {
|
||||
RemoteDeviceType.TV -> stringResource(R.string.infrared_hub_device_type_tv)
|
||||
RemoteDeviceType.AIR_CONDITIONER -> stringResource(R.string.infrared_hub_device_type_ac)
|
||||
RemoteDeviceType.FAN -> stringResource(R.string.infrared_hub_device_type_fan)
|
||||
RemoteDeviceType.CUSTOM -> stringResource(R.string.infrared_hub_device_type_custom)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
text = { Text(label) },
|
||||
onClick = {
|
||||
selectedType = type
|
||||
val matched = PresetCodeLibrary.availablePresets.firstOrNull { it.deviceType == type }
|
||||
if (matched != null) selectedBrand = matched
|
||||
if (type == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH
|
||||
} else if (type == RemoteDeviceType.CUSTOM) {
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS
|
||||
}
|
||||
typeDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 遥控器名称输入
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("遥控器名称(如:客厅电视、我的遥控器)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("create_remote_name_input"),
|
||||
)
|
||||
|
||||
// 3. 自定义遥控器时:选择初始布局模板
|
||||
if (selectedType == RemoteDeviceType.CUSTOM) {
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = layoutDropdownExpanded,
|
||||
onExpandedChange = { layoutDropdownExpanded = !layoutDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = when (selectedCustomLayout) {
|
||||
PanelLayoutStyle.TV_NAV -> "📺 电视导航布局 (内置方向盘/音量/频道)"
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> "🎵 媒体播放器布局 (播放/快进/切歌)"
|
||||
PanelLayoutStyle.GRID -> "🎛️ 自由网格布局 (可自由增删按键)"
|
||||
PanelLayoutStyle.AC_COMPACT -> "❄️ 空调布局"
|
||||
},
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text("选择初始布局模板") },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = layoutDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = layoutDropdownExpanded,
|
||||
onDismissRequest = { layoutDropdownExpanded = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text("📺 电视导航布局 (内置方向盘/音量/频道)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.TV_NAV
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("🎵 媒体播放器布局 (播放/快进/切歌)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.MEDIA_PLAYER
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text("🎛️ 自由网格布局 (可自由增删按键)") },
|
||||
onClick = {
|
||||
selectedCustomLayout = PanelLayoutStyle.GRID
|
||||
layoutDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "💡 自定义学习按键由硬件固化录制,将自动分配独占静态槽位 (1..99),创建后开启学习模式即可对准录码。",
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else if (filteredPresets.isNotEmpty()) {
|
||||
// 码库预设设备:选择品牌型号
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = brandDropdownExpanded,
|
||||
onExpandedChange = { brandDropdownExpanded = !brandDropdownExpanded },
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = selectedBrand.displayName,
|
||||
onValueChange = {},
|
||||
readOnly = true,
|
||||
label = { Text(stringResource(R.string.infrared_hub_brand_label)) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = brandDropdownExpanded) },
|
||||
modifier = Modifier.fillMaxWidth().menuAnchor(),
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = brandDropdownExpanded,
|
||||
onDismissRequest = { brandDropdownExpanded = false },
|
||||
) {
|
||||
filteredPresets.forEach { preset ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(preset.displayName) },
|
||||
onClick = {
|
||||
selectedBrand = preset
|
||||
brandDropdownExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 传输模式选择(仅对非空调、非纯自定义设备可选)
|
||||
if (selectedType != RemoteDeviceType.AIR_CONDITIONER && selectedType != RemoteDeviceType.CUSTOM) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_label), fontWeight = FontWeight.Bold, fontSize = 13.sp)
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
||||
) {
|
||||
RadioButton(
|
||||
selected = transmissionMode == TransmissionMode.STATIC_SLOTS,
|
||||
onClick = { transmissionMode = TransmissionMode.STATIC_SLOTS },
|
||||
)
|
||||
Column {
|
||||
Text("🚀 静态独占槽位模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_static_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().clickable { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
||||
) {
|
||||
RadioButton(
|
||||
selected = transmissionMode == TransmissionMode.DYNAMIC_SCRATCH,
|
||||
onClick = { transmissionMode = TransmissionMode.DYNAMIC_SCRATCH },
|
||||
)
|
||||
Column {
|
||||
Text("📦 动态写码模式", fontWeight = FontWeight.Medium)
|
||||
Text(stringResource(R.string.infrared_hub_trans_mode_dynamic_desc), fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (selectedType == RemoteDeviceType.AIR_CONDITIONER) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
text = "❄️ 空调多维状态自动采用 App 状态机 + 动态缓冲写码,杜绝状态踩踏并节省槽位。",
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(10.dp),
|
||||
color = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
val finalName = if (name.isNotBlank()) name.trim() else if (selectedType == RemoteDeviceType.CUSTOM) "自定义遥控器" else selectedBrand.displayName
|
||||
val layout = if (selectedType == RemoteDeviceType.CUSTOM) selectedCustomLayout else null
|
||||
onConfirm(finalName, selectedType, selectedBrand.id, transmissionMode, layout)
|
||||
},
|
||||
modifier = Modifier.testTag("create_remote_confirm_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_dialog_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.infrared_dialog_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.flagship.abox.manager.infrared.hub
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
sealed interface HubUiMessage {
|
||||
data class Info(val text: UiText) : HubUiMessage
|
||||
data class Error(val text: UiText) : HubUiMessage
|
||||
data object SessionInvalid : HubUiMessage
|
||||
}
|
||||
|
||||
data class InfraredHubUiState(
|
||||
val profileId: ProviderProfileId? = null,
|
||||
val providerType: ProviderType? = null,
|
||||
val providerDisplayName: String? = null,
|
||||
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
|
||||
val remotes: List<VirtualRemote> = emptyList(),
|
||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||
val loading: Boolean = true,
|
||||
val message: HubUiMessage? = null,
|
||||
)
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
package com.flagship.abox.manager.infrared.hub
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class InfraredHubViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val providerProfileStore: ProviderProfileStore,
|
||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||
private val createVirtualRemote: CreateVirtualRemote,
|
||||
private val deleteVirtualRemote: DeleteVirtualRemote,
|
||||
) : ViewModel() {
|
||||
|
||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||
|
||||
private val messageFlow = MutableStateFlow<HubUiMessage?>(null)
|
||||
|
||||
private data class HubSnapshot(
|
||||
val providerProfile: ProviderProfile? = null,
|
||||
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
||||
val remotes: List<VirtualRemote> = emptyList(),
|
||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||
val configError: DomainError? = null,
|
||||
)
|
||||
|
||||
private val hubSnapshotFlow: Flow<HubSnapshot> =
|
||||
if (profileId == null) {
|
||||
flowOf(HubSnapshot())
|
||||
} else {
|
||||
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
|
||||
val active = profiles.find { it.id == profileId }
|
||||
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { devResult ->
|
||||
when (devResult) {
|
||||
is DomainResult.Failure -> flowOf(HubSnapshot(providerProfile = active, configError = devResult.error))
|
||||
is DomainResult.Success -> {
|
||||
val deviceName = devResult.value ?: DEFAULT_DEVICE_NAME
|
||||
combine(
|
||||
virtualRemoteRepository.observeRemotes(profileId, deviceName),
|
||||
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
|
||||
) { remotesResult, quotaResult ->
|
||||
val remotes = if (remotesResult is DomainResult.Success) remotesResult.value else emptyList()
|
||||
val quota = if (quotaResult is DomainResult.Success) quotaResult.value else SlotQuotaSummary()
|
||||
HubSnapshot(
|
||||
providerProfile = active,
|
||||
savedDevice = deviceName,
|
||||
remotes = remotes,
|
||||
slotQuota = quota,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<InfraredHubUiState> = combine(
|
||||
hubSnapshotFlow,
|
||||
messageFlow,
|
||||
) { snapshot, msg ->
|
||||
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> HubUiMessage.SessionInvalid
|
||||
is DomainError.DeviceOffline -> HubUiMessage.Error(
|
||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
||||
)
|
||||
else -> HubUiMessage.Error(
|
||||
error.detail?.let { UiText.DynamicString(it) }
|
||||
?: UiText.StringResource(R.string.infrared_msg_load_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
InfraredHubUiState(
|
||||
profileId = profileId,
|
||||
providerType = snapshot.providerProfile?.type,
|
||||
providerDisplayName = snapshot.providerProfile?.displayName,
|
||||
savedDeviceName = snapshot.savedDevice,
|
||||
remotes = snapshot.remotes,
|
||||
slotQuota = snapshot.slotQuota,
|
||||
loading = false,
|
||||
message = resolvedMessage,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = InfraredHubUiState(profileId = profileId, loading = true),
|
||||
)
|
||||
|
||||
fun createRemote(
|
||||
name: String,
|
||||
type: RemoteDeviceType,
|
||||
brandModel: String? = null,
|
||||
transmissionMode: TransmissionMode,
|
||||
layoutStyle: com.flagship.abox.manager.domain.infrared.PanelLayoutStyle? = null,
|
||||
) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentDevice = state.value.savedDeviceName
|
||||
viewModelScope.launch {
|
||||
when (val res = createVirtualRemote(
|
||||
profileId = currentProfile,
|
||||
deviceName = currentDevice,
|
||||
displayName = name,
|
||||
type = type,
|
||||
brandModel = brandModel,
|
||||
transmissionMode = transmissionMode,
|
||||
layoutStyle = layoutStyle,
|
||||
)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("已成功创建遥控器"))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteRemote(remoteId: VirtualRemoteId) {
|
||||
val currentProfile = profileId ?: return
|
||||
viewModelScope.launch {
|
||||
when (val res = deleteVirtualRemote(currentProfile, remoteId)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = HubUiMessage.Info(UiText.DynamicString("遥控器已删除"))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun saveDeviceName(newName: String) {
|
||||
val currentProfile = profileId ?: return
|
||||
val trimmed = newName.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
messageFlow.value = HubUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank))
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (val res = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = HubUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
messageFlow.value = null
|
||||
}
|
||||
|
||||
private fun handleDomainError(error: DomainError) {
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = HubUiMessage.SessionInvalid
|
||||
is DomainError.DeviceOffline -> messageFlow.value = HubUiMessage.Error(
|
||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
||||
)
|
||||
else -> messageFlow.value = HubUiMessage.Error(
|
||||
error.detail?.let { UiText.DynamicString(it) }
|
||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
||||
}
|
||||
}
|
||||
+801
@@ -0,0 +1,801 @@
|
||||
package com.flagship.abox.manager.infrared.panel
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
@Composable
|
||||
fun RemotePanelRoute(
|
||||
onBack: () -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
viewModel: RemotePanelViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
RemotePanelScreen(
|
||||
state = state,
|
||||
onBack = onBack,
|
||||
onSessionInvalid = onSessionInvalid,
|
||||
onButtonClick = viewModel::onButtonClick,
|
||||
onToggleLearnMode = viewModel::toggleLearnMode,
|
||||
onAddCustomButton = viewModel::addCustomButton,
|
||||
onDeleteButton = viewModel::deleteButton,
|
||||
onToggleAcPower = viewModel::toggleAcPower,
|
||||
onAdjustAcTemp = viewModel::adjustAcTemperature,
|
||||
onSetAcMode = viewModel::setAcMode,
|
||||
onSetFanSpeed = viewModel::setAcFanSpeed,
|
||||
onToggleAcVerticalSwing = viewModel::toggleAcVerticalSwing,
|
||||
onToggleAcHorizontalSwing = viewModel::toggleAcHorizontalSwing,
|
||||
onToggleAcEco = viewModel::toggleAcEco,
|
||||
onMessageShown = viewModel::clearMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun RemotePanelScreen(
|
||||
state: RemotePanelUiState,
|
||||
onBack: () -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
onButtonClick: (String) -> Unit,
|
||||
onToggleLearnMode: () -> Unit,
|
||||
onAddCustomButton: (String) -> Unit,
|
||||
onDeleteButton: (String) -> Unit,
|
||||
onToggleAcPower: () -> Unit,
|
||||
onAdjustAcTemp: (Int) -> Unit,
|
||||
onSetAcMode: (AcMode) -> Unit,
|
||||
onSetFanSpeed: (AcFanSpeed) -> Unit,
|
||||
onToggleAcVerticalSwing: () -> Unit,
|
||||
onToggleAcHorizontalSwing: () -> Unit,
|
||||
onToggleAcEco: () -> Unit,
|
||||
onMessageShown: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
var showAddKeyDialog by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
when (msg) {
|
||||
is PanelUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is PanelUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is PanelUiMessage.SessionInvalid -> {
|
||||
onSessionInvalid()
|
||||
onMessageShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
text = state.remote?.displayName ?: stringResource(R.string.infrared_remote_title),
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
state.remote?.let {
|
||||
val layoutLabel = when (it.layoutStyle) {
|
||||
PanelLayoutStyle.TV_NAV -> "电视导航布局"
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> "媒体播放布局"
|
||||
PanelLayoutStyle.GRID -> "自由网格布局"
|
||||
PanelLayoutStyle.AC_COMPACT -> "空调全状态布局"
|
||||
}
|
||||
Text(
|
||||
text = layoutLabel,
|
||||
fontSize = 11.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text(stringResource(R.string.infrared_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// 非空调面板显示学习模式开关
|
||||
if (state.remote?.layoutStyle != PanelLayoutStyle.AC_COMPACT) {
|
||||
FilterChip(
|
||||
selected = state.isLearnMode,
|
||||
onClick = onToggleLearnMode,
|
||||
label = { Text(if (state.isLearnMode) "🔴 录制中" else "⚪ 学习模式") },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = Color(0xFFFF5252),
|
||||
selectedLabelColor = Color.White,
|
||||
),
|
||||
modifier = Modifier.testTag("learn_mode_toggle"),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
ABoxSurface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
val remote = state.remote
|
||||
if (remote == null) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// 学习模式指示横幅
|
||||
if (state.isLearnMode) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFFFFEBEE)),
|
||||
shape = RoundedCornerShape(0.dp),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text("🔴", fontSize = 16.sp)
|
||||
Text(
|
||||
text = "学习模式已开启:点击任意按键,然后将物理实体遥控器对准转发器按下即可录码",
|
||||
color = Color(0xFFC62828),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 面板内容区
|
||||
Box(modifier = Modifier.fillMaxSize().weight(1f)) {
|
||||
when (remote.layoutStyle) {
|
||||
PanelLayoutStyle.AC_COMPACT -> {
|
||||
AcControlPanel(
|
||||
acState = state.acState,
|
||||
onTogglePower = onToggleAcPower,
|
||||
onAdjustTemp = onAdjustAcTemp,
|
||||
onSetMode = onSetAcMode,
|
||||
onSetFanSpeed = onSetFanSpeed,
|
||||
onToggleVSwing = onToggleAcVerticalSwing,
|
||||
onToggleHSwing = onToggleAcHorizontalSwing,
|
||||
onToggleEco = onToggleAcEco,
|
||||
)
|
||||
}
|
||||
PanelLayoutStyle.TV_NAV -> {
|
||||
TvControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
}
|
||||
PanelLayoutStyle.MEDIA_PLAYER -> {
|
||||
MediaPlayerControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
}
|
||||
PanelLayoutStyle.GRID -> {
|
||||
GenericGridControlPanel(
|
||||
bindings = state.bindings,
|
||||
isLearnMode = state.isLearnMode,
|
||||
learningButtonId = state.learningButtonId,
|
||||
sendingButtonId = state.sendingButtonId,
|
||||
onButtonClick = onButtonClick,
|
||||
onDeleteButton = onDeleteButton,
|
||||
onAddButton = { showAddKeyDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showAddKeyDialog) {
|
||||
AddCustomKeyDialog(
|
||||
onDismiss = { showAddKeyDialog = false },
|
||||
onConfirm = { name ->
|
||||
onAddCustomButton(name)
|
||||
showAddKeyDialog = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AcControlPanel(
|
||||
acState: AcStateSnapshot,
|
||||
onTogglePower: () -> Unit,
|
||||
onAdjustTemp: (Int) -> Unit,
|
||||
onSetMode: (AcMode) -> Unit,
|
||||
onSetFanSpeed: (AcFanSpeed) -> Unit,
|
||||
onToggleVSwing: () -> Unit,
|
||||
onToggleHSwing: () -> Unit,
|
||||
onToggleEco: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (acState.power) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (acState.power) "● 运行中" else "○ 已关机",
|
||||
color = if (acState.power) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(
|
||||
onClick = onTogglePower,
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (acState.power) Color(0xFFFF5252) else MaterialTheme.colorScheme.outline)
|
||||
.testTag("ac_power_toggle"),
|
||||
) {
|
||||
Text("⏻", color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "${acState.temperature}°C",
|
||||
fontSize = 64.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = if (acState.power) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "模式: ${acState.mode.name} | 风速: ${acState.fanSpeed.name} | 扫风: ${if (acState.verticalSwing) "开" else "关"}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Button(
|
||||
onClick = { onAdjustTemp(-1) },
|
||||
modifier = Modifier.size(56.dp).testTag("ac_temp_down"),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Text("-", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
Button(
|
||||
onClick = { onAdjustTemp(1) },
|
||||
modifier = Modifier.size(56.dp).testTag("ac_temp_up"),
|
||||
shape = CircleShape,
|
||||
) {
|
||||
Text("+", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text("模式选择", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AcMode.entries.forEach { mode ->
|
||||
FilterChip(
|
||||
selected = acState.mode == mode,
|
||||
onClick = { onSetMode(mode) },
|
||||
label = { Text(mode.name) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Text("风速调节", style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold), modifier = Modifier.align(Alignment.Start))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
AcFanSpeed.entries.forEach { speed ->
|
||||
FilterChip(
|
||||
selected = acState.fanSpeed == speed,
|
||||
onClick = { onSetFanSpeed(speed) },
|
||||
label = { Text(speed.name) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = acState.verticalSwing,
|
||||
onClick = onToggleVSwing,
|
||||
label = { Text("↕ 上下扫风") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilterChip(
|
||||
selected = acState.horizontalSwing,
|
||||
onClick = onToggleHSwing,
|
||||
label = { Text("↔ 左右扫风") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
FilterChip(
|
||||
selected = acState.eco,
|
||||
onClick = onToggleEco,
|
||||
label = { Text("🌱 节能 ECO") },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvControlPanel(
|
||||
bindings: List<RemoteKeyBinding>,
|
||||
isLearnMode: Boolean,
|
||||
learningButtonId: String?,
|
||||
sendingButtonId: String?,
|
||||
onButtonClick: (String) -> Unit,
|
||||
) {
|
||||
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// 顶部控制行 (电源、静音、信号源)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
InteractiveRemoteButton("TV_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["TV_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["TV_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_SOURCE", "📺 信号源", MaterialTheme.colorScheme.secondary, bindingMap["TV_SOURCE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// 中部五向方向导航盘
|
||||
Card(
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(220.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// 上
|
||||
IconButton(
|
||||
onClick = { onButtonClick("TV_NAV_UP") },
|
||||
modifier = Modifier.align(Alignment.TopCenter).padding(top = 10.dp).testTag("tv_nav_up"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_UP", "▲", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// 下
|
||||
IconButton(
|
||||
onClick = { onButtonClick("TV_NAV_DOWN") },
|
||||
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 10.dp).testTag("tv_nav_down"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_DOWN", "▼", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// 左
|
||||
IconButton(
|
||||
onClick = { onButtonClick("TV_NAV_LEFT") },
|
||||
modifier = Modifier.align(Alignment.CenterStart).padding(start = 10.dp).testTag("tv_nav_left"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_LEFT", "◀", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// 右
|
||||
IconButton(
|
||||
onClick = { onButtonClick("TV_NAV_RIGHT") },
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(end = 10.dp).testTag("tv_nav_right"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_NAV_RIGHT", "▶", learningButtonId, sendingButtonId)
|
||||
}
|
||||
|
||||
// OK 居中大按钮
|
||||
Button(
|
||||
onClick = { onButtonClick("TV_OK") },
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(76.dp).testTag("tv_ok"),
|
||||
) {
|
||||
ButtonIndicatorText("TV_OK", "OK", learningButtonId, sendingButtonId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 辅助按键行 (返回、主页、菜单)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceAround,
|
||||
) {
|
||||
InteractiveRemoteButton("TV_BACK", "↩ 返回", MaterialTheme.colorScheme.outline, bindingMap["TV_BACK"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_HOME", "⌂ 主页", MaterialTheme.colorScheme.outline, bindingMap["TV_HOME"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("TV_MENU", "☰ 菜单", MaterialTheme.colorScheme.outline, bindingMap["TV_MENU"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
}
|
||||
|
||||
// 底部音量与频道调节柱
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("音量 VOL", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
Button(onClick = { onButtonClick("TV_VOL_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_VOL_UP", "+", learningButtonId, sendingButtonId)
|
||||
}
|
||||
Button(onClick = { onButtonClick("TV_VOL_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_VOL_DOWN", "-", learningButtonId, sendingButtonId)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
Text("频道 CH", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
Button(onClick = { onButtonClick("TV_CH_UP") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_CH_UP", "▲", learningButtonId, sendingButtonId)
|
||||
}
|
||||
Button(onClick = { onButtonClick("TV_CH_DOWN") }, modifier = Modifier.size(54.dp), shape = RoundedCornerShape(12.dp)) {
|
||||
ButtonIndicatorText("TV_CH_DOWN", "▼", learningButtonId, sendingButtonId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaPlayerControlPanel(
|
||||
bindings: List<RemoteKeyBinding>,
|
||||
isLearnMode: Boolean,
|
||||
learningButtonId: String?,
|
||||
sendingButtonId: String?,
|
||||
onButtonClick: (String) -> Unit,
|
||||
) {
|
||||
val bindingMap = remember(bindings) { bindings.associateBy { it.buttonId } }
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
InteractiveRemoteButton("MEDIA_POWER", "⏻ 电源", Color(0xFFFF5252), bindingMap["MEDIA_POWER"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
InteractiveRemoteButton("MEDIA_MUTE", "🔇 静音", MaterialTheme.colorScheme.secondary, bindingMap["MEDIA_MUTE"], isLearnMode, learningButtonId, sendingButtonId, onButtonClick)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 播放控制中心大圆盘
|
||||
Card(
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(240.dp),
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
// 快进 >>
|
||||
IconButton(onClick = { onButtonClick("MEDIA_FF") }, modifier = Modifier.align(Alignment.CenterEnd).padding(end = 12.dp)) {
|
||||
ButtonIndicatorText("MEDIA_FF", "⏩", learningButtonId, sendingButtonId)
|
||||
}
|
||||
// 快退 <<
|
||||
IconButton(onClick = { onButtonClick("MEDIA_REWIND") }, modifier = Modifier.align(Alignment.CenterStart).padding(start = 12.dp)) {
|
||||
ButtonIndicatorText("MEDIA_REWIND", "⏪", learningButtonId, sendingButtonId)
|
||||
}
|
||||
// 上一曲
|
||||
IconButton(onClick = { onButtonClick("MEDIA_PREV") }, modifier = Modifier.align(Alignment.TopCenter).padding(top = 12.dp)) {
|
||||
ButtonIndicatorText("MEDIA_PREV", "⏮", learningButtonId, sendingButtonId)
|
||||
}
|
||||
// 下一曲
|
||||
IconButton(onClick = { onButtonClick("MEDIA_NEXT") }, modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 12.dp)) {
|
||||
ButtonIndicatorText("MEDIA_NEXT", "⏭", learningButtonId, sendingButtonId)
|
||||
}
|
||||
// 播放/暂停
|
||||
Button(onClick = { onButtonClick("MEDIA_PLAY_PAUSE") }, shape = CircleShape, modifier = Modifier.size(80.dp)) {
|
||||
ButtonIndicatorText("MEDIA_PLAY_PAUSE", "⏯", learningButtonId, sendingButtonId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GenericGridControlPanel(
|
||||
bindings: List<RemoteKeyBinding>,
|
||||
isLearnMode: Boolean,
|
||||
learningButtonId: String?,
|
||||
sendingButtonId: String?,
|
||||
onButtonClick: (String) -> Unit,
|
||||
onDeleteButton: (String) -> Unit,
|
||||
onAddButton: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text("按键列表 (${bindings.size})", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Button(onClick = onAddButton, modifier = Modifier.testTag("grid_add_key_button")) {
|
||||
Text("➕ 添加按键")
|
||||
}
|
||||
}
|
||||
|
||||
if (bindings.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
|
||||
Text("暂无自定义按键,点击上方【➕ 添加按键】新建", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize().weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(bindings, key = { it.id }) { binding ->
|
||||
GridButtonCard(
|
||||
binding = binding,
|
||||
isLearnMode = isLearnMode,
|
||||
isLearning = learningButtonId == binding.buttonId,
|
||||
isSending = sendingButtonId == binding.buttonId,
|
||||
onClick = { onButtonClick(binding.buttonId) },
|
||||
onDelete = { onDeleteButton(binding.buttonId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GridButtonCard(
|
||||
binding: RemoteKeyBinding,
|
||||
isLearnMode: Boolean,
|
||||
isLearning: Boolean,
|
||||
isSending: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(76.dp)
|
||||
.clickable(onClick = onClick)
|
||||
.testTag("generic_btn_${binding.buttonId}"),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(binding.displayName, fontWeight = FontWeight.Bold, fontSize = 15.sp)
|
||||
val statusText = when {
|
||||
isLearning -> "🟡 正在录制信号..."
|
||||
binding.status == InfraredKeyStatus.READY -> "🟢 已就绪 (Slot ${binding.assignedSlot ?: "动态"})"
|
||||
else -> "⚪ 待录制 (点击录码)"
|
||||
}
|
||||
Text(statusText, fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
|
||||
if (isSending || isLearning) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
// 始终提供删除操作按钮
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.size(36.dp).testTag("delete_key_${binding.buttonId}"),
|
||||
) {
|
||||
Text("🗑️", fontSize = 15.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InteractiveRemoteButton(
|
||||
buttonId: String,
|
||||
label: String,
|
||||
containerColor: Color,
|
||||
binding: RemoteKeyBinding?,
|
||||
isLearnMode: Boolean,
|
||||
learningButtonId: String?,
|
||||
sendingButtonId: String?,
|
||||
onButtonClick: (String) -> Unit,
|
||||
) {
|
||||
val isUnlearned = binding != null && binding.status != InfraredKeyStatus.READY
|
||||
|
||||
Button(
|
||||
onClick = { onButtonClick(buttonId) },
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = when {
|
||||
learningButtonId == buttonId -> Color(0xFFFF9800)
|
||||
isUnlearned -> MaterialTheme.colorScheme.surfaceVariant
|
||||
else -> containerColor
|
||||
},
|
||||
contentColor = if (isUnlearned) MaterialTheme.colorScheme.onSurfaceVariant else Color.White,
|
||||
),
|
||||
modifier = Modifier.testTag("tv_btn_$buttonId"),
|
||||
) {
|
||||
ButtonIndicatorText(buttonId, label, learningButtonId, sendingButtonId)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ButtonIndicatorText(
|
||||
buttonId: String,
|
||||
defaultLabel: String,
|
||||
learningButtonId: String?,
|
||||
sendingButtonId: String?,
|
||||
) {
|
||||
if (learningButtonId == buttonId) {
|
||||
Text("🟡 录制中", fontSize = 12.sp, fontWeight = FontWeight.Bold)
|
||||
} else if (sendingButtonId == buttonId) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(16.dp), color = Color.White)
|
||||
} else {
|
||||
Text(defaultLabel, fontWeight = FontWeight.Bold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AddCustomKeyDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onConfirm: (name: String) -> Unit,
|
||||
) {
|
||||
var name by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("添加自定义按键") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("输入按键显示名称,创建后点击按键或开启【学习模式】即可录制实体按键:", fontSize = 13.sp)
|
||||
OutlinedTextField(
|
||||
value = name,
|
||||
onValueChange = { name = it },
|
||||
label = { Text("按键名称(如:静音、低音增强)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth().testTag("add_key_name_input"),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
if (name.isNotBlank()) onConfirm(name.trim())
|
||||
},
|
||||
modifier = Modifier.testTag("add_key_confirm_button"),
|
||||
) {
|
||||
Text("确认添加")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("取消") }
|
||||
},
|
||||
)
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.flagship.abox.manager.infrared.panel
|
||||
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
sealed interface PanelUiMessage {
|
||||
data class Info(val text: UiText) : PanelUiMessage
|
||||
data class Error(val text: UiText) : PanelUiMessage
|
||||
data object SessionInvalid : PanelUiMessage
|
||||
}
|
||||
|
||||
data class RemotePanelUiState(
|
||||
val profileId: ProviderProfileId? = null,
|
||||
val remote: VirtualRemote? = null,
|
||||
val bindings: List<RemoteKeyBinding> = emptyList(),
|
||||
val acState: AcStateSnapshot = AcStateSnapshot(),
|
||||
val isLearnMode: Boolean = false,
|
||||
val learningButtonId: String? = null,
|
||||
val sendingButtonId: String? = null,
|
||||
val loading: Boolean = true,
|
||||
val message: PanelUiMessage? = null,
|
||||
)
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
package com.flagship.abox.manager.infrared.panel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
||||
import com.flagship.abox.manager.domain.infrared.AcStateSnapshot
|
||||
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteKeyBinding
|
||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class RemotePanelViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||
private val sendVirtualRemoteAction: SendVirtualRemoteAction,
|
||||
private val learnVirtualRemoteKey: LearnVirtualRemoteKey,
|
||||
private val addVirtualRemoteKey: AddVirtualRemoteKey,
|
||||
private val deleteVirtualRemoteKey: DeleteVirtualRemoteKey,
|
||||
private val updateVirtualRemoteLayout: UpdateVirtualRemoteLayout,
|
||||
) : ViewModel() {
|
||||
|
||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||
|
||||
private val rawRemoteId: String? = savedStateHandle["remoteId"]
|
||||
val remoteId: VirtualRemoteId? = rawRemoteId?.takeIf { it.isNotBlank() }?.let { VirtualRemoteId(it) }
|
||||
|
||||
private val isLearnModeFlow = MutableStateFlow(false)
|
||||
private val learningButtonIdFlow = MutableStateFlow<String?>(null)
|
||||
private val sendingButtonIdFlow = MutableStateFlow<String?>(null)
|
||||
private val localAcStateFlow = MutableStateFlow<AcStateSnapshot?>(null)
|
||||
private val messageFlow = MutableStateFlow<PanelUiMessage?>(null)
|
||||
private var acDebounceJob: Job? = null
|
||||
|
||||
private val remoteFlow = if (profileId == null || remoteId == null) {
|
||||
flowOf(null)
|
||||
} else {
|
||||
virtualRemoteRepository.observeRemote(profileId, remoteId).flatMapLatest { res ->
|
||||
val remote = if (res is DomainResult.Success) res.value else null
|
||||
flowOf(remote)
|
||||
}
|
||||
}
|
||||
|
||||
private val bindingsFlow = if (remoteId == null) {
|
||||
flowOf(emptyList())
|
||||
} else {
|
||||
virtualRemoteRepository.observeKeyBindings(remoteId).flatMapLatest { res ->
|
||||
val list = if (res is DomainResult.Success) res.value else emptyList()
|
||||
flowOf(list)
|
||||
}
|
||||
}
|
||||
|
||||
private data class RemoteSnapshot(
|
||||
val remote: VirtualRemote?,
|
||||
val bindings: List<RemoteKeyBinding>,
|
||||
)
|
||||
|
||||
private val snapshotFlow = combine(remoteFlow, bindingsFlow) { remote, bindings ->
|
||||
RemoteSnapshot(remote, bindings)
|
||||
}
|
||||
|
||||
private data class ActionState(
|
||||
val isLearnMode: Boolean,
|
||||
val learningButtonId: String?,
|
||||
val sendingButtonId: String?,
|
||||
)
|
||||
|
||||
private val actionStateFlow = combine(
|
||||
isLearnModeFlow,
|
||||
learningButtonIdFlow,
|
||||
sendingButtonIdFlow,
|
||||
) { isLearn, learningBtn, sendingBtn ->
|
||||
ActionState(isLearn, learningBtn, sendingBtn)
|
||||
}
|
||||
|
||||
val state: StateFlow<RemotePanelUiState> = combine(
|
||||
snapshotFlow,
|
||||
localAcStateFlow,
|
||||
actionStateFlow,
|
||||
messageFlow,
|
||||
) { snapshot, localAc, action, msg ->
|
||||
val resolvedAc = localAc ?: snapshot.remote?.acState ?: AcStateSnapshot()
|
||||
RemotePanelUiState(
|
||||
profileId = profileId,
|
||||
remote = snapshot.remote,
|
||||
bindings = snapshot.bindings,
|
||||
acState = resolvedAc,
|
||||
isLearnMode = action.isLearnMode,
|
||||
learningButtonId = action.learningButtonId,
|
||||
sendingButtonId = action.sendingButtonId,
|
||||
loading = snapshot.remote == null,
|
||||
message = msg,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = RemotePanelUiState(profileId = profileId, loading = true),
|
||||
)
|
||||
|
||||
fun toggleLearnMode() {
|
||||
val newMode = !isLearnModeFlow.value
|
||||
isLearnModeFlow.value = newMode
|
||||
if (newMode) {
|
||||
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learning_mode_enabled))
|
||||
}
|
||||
}
|
||||
|
||||
fun onButtonClick(buttonId: String) {
|
||||
if (isLearnModeFlow.value) {
|
||||
learnButton(buttonId)
|
||||
} else {
|
||||
sendButton(buttonId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendButton(buttonId: String) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
if (state.value.sendingButtonId != null || state.value.learningButtonId != null) return
|
||||
|
||||
sendingButtonIdFlow.value = buttonId
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
when (val res = sendVirtualRemoteAction.sendButton(currentProfile, currentRemoteId, buttonId)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_signal_sent, listOf(buttonId)))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
} finally {
|
||||
sendingButtonIdFlow.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun learnButton(buttonId: String) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
if (state.value.learningButtonId != null) return
|
||||
|
||||
learningButtonIdFlow.value = buttonId
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
when (val res = learnVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = PanelUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf(buttonId)))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
} finally {
|
||||
learningButtonIdFlow.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addCustomButton(displayName: String) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
viewModelScope.launch {
|
||||
when (val res = addVirtualRemoteKey(currentProfile, currentRemoteId, displayName)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已添加按键【${displayName}】,开启学习模式即可录制"))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun deleteButton(buttonId: String) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
viewModelScope.launch {
|
||||
when (val res = deleteVirtualRemoteKey(currentProfile, currentRemoteId, buttonId)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("按键已删除"))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchLayout(newLayout: PanelLayoutStyle) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
viewModelScope.launch {
|
||||
when (val res = updateVirtualRemoteLayout(currentProfile, currentRemoteId, newLayout)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已切换面板布局风格"))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAcPower() {
|
||||
val current = state.value.acState
|
||||
updateAndDebounceAc(current.copy(power = !current.power))
|
||||
}
|
||||
|
||||
fun adjustAcTemperature(delta: Int) {
|
||||
val current = state.value.acState
|
||||
val target = (current.temperature + delta).coerceIn(16, 30)
|
||||
if (target != current.temperature) {
|
||||
updateAndDebounceAc(current.copy(temperature = target))
|
||||
}
|
||||
}
|
||||
|
||||
fun setAcMode(mode: AcMode) {
|
||||
val current = state.value.acState
|
||||
updateAndDebounceAc(current.copy(mode = mode))
|
||||
}
|
||||
|
||||
fun setAcFanSpeed(fanSpeed: AcFanSpeed) {
|
||||
val current = state.value.acState
|
||||
updateAndDebounceAc(current.copy(fanSpeed = fanSpeed))
|
||||
}
|
||||
|
||||
fun toggleAcVerticalSwing() {
|
||||
val current = state.value.acState
|
||||
updateAndDebounceAc(current.copy(verticalSwing = !current.verticalSwing))
|
||||
}
|
||||
|
||||
fun toggleAcHorizontalSwing() {
|
||||
val current = state.value.acState
|
||||
updateAndDebounceAc(current.copy(horizontalSwing = !current.horizontalSwing))
|
||||
}
|
||||
|
||||
fun toggleAcEco() {
|
||||
val current = state.value.acState
|
||||
updateAndDebounceAc(current.copy(eco = !current.eco))
|
||||
}
|
||||
|
||||
private fun updateAndDebounceAc(newState: AcStateSnapshot) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentRemoteId = remoteId ?: return
|
||||
|
||||
// 1. Optimistic UI update
|
||||
localAcStateFlow.value = newState
|
||||
|
||||
// 2. Debounce transmission (300ms)
|
||||
acDebounceJob?.cancel()
|
||||
acDebounceJob = viewModelScope.launch {
|
||||
delay(300L)
|
||||
when (val res = sendVirtualRemoteAction.sendAcState(currentProfile, currentRemoteId, newState)) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = PanelUiMessage.Info(UiText.DynamicString("已同步空调状态:${newState.temperature}℃ | ${newState.mode.name}"))
|
||||
}
|
||||
is DomainResult.Failure -> handleDomainError(res.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
messageFlow.value = null
|
||||
}
|
||||
|
||||
private fun handleDomainError(error: DomainError) {
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> messageFlow.value = PanelUiMessage.SessionInvalid
|
||||
is DomainError.DeviceOffline -> messageFlow.value = PanelUiMessage.Error(
|
||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
||||
)
|
||||
else -> messageFlow.value = PanelUiMessage.Error(
|
||||
error.detail?.let { UiText.DynamicString(it) }
|
||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+331
@@ -0,0 +1,331 @@
|
||||
package com.flagship.abox.manager.infrared.remote
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRepository
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
sealed interface RemoteUiMessage {
|
||||
data class Info(val text: UiText) : RemoteUiMessage
|
||||
data class Error(val text: UiText) : RemoteUiMessage
|
||||
data object SessionInvalid : RemoteUiMessage
|
||||
}
|
||||
|
||||
data class InfraredRemoteUiState(
|
||||
val selectedScene: RemoteScene = RemoteScene.AIR_CONDITIONER,
|
||||
val isLearningMode: Boolean = false,
|
||||
val profileId: ProviderProfileId? = null,
|
||||
val providerType: ProviderType? = null,
|
||||
val providerDisplayName: String? = null,
|
||||
val savedDeviceName: DeviceName? = null,
|
||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||
val sendingKeySlot: String? = null,
|
||||
val activeLearningSlot: String? = null,
|
||||
val message: RemoteUiMessage? = null,
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class InfraredRemoteViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val keyRepository: InfraredKeyRepository,
|
||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||
private val providerProfileStore: ProviderProfileStore,
|
||||
private val learningCoordinator: InfraredLearningCoordinator,
|
||||
private val sendInfraredKey: SendInfraredKey,
|
||||
) : ViewModel() {
|
||||
|
||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||
|
||||
private val sceneFlow = MutableStateFlow(RemoteScene.AIR_CONDITIONER)
|
||||
private val isLearningModeFlow = MutableStateFlow(false)
|
||||
private val sendingKeySlotFlow = MutableStateFlow<String?>(null)
|
||||
private val activeLearningSlotFlow = MutableStateFlow<String?>(null)
|
||||
private val messageFlow = MutableStateFlow<RemoteUiMessage?>(null)
|
||||
private var activeLearningJob: Job? = null
|
||||
private var underlyingLearningDeferred: kotlinx.coroutines.Deferred<*>? = null
|
||||
|
||||
init {
|
||||
if (profileId != null) {
|
||||
val startupCutoff = System.currentTimeMillis()
|
||||
learningCoordinator.failInterruptedLearning(profileId, cutoffEpochMillis = startupCutoff)
|
||||
}
|
||||
}
|
||||
|
||||
private data class InfraredRemoteConfigSnapshot(
|
||||
val providerProfile: ProviderProfile? = null,
|
||||
val savedDeviceName: DeviceName = DEFAULT_DEVICE_NAME,
|
||||
val learnedSlotMap: Map<String, SavedInfraredKey> = emptyMap(),
|
||||
val configError: DomainError? = null,
|
||||
)
|
||||
|
||||
private val configSnapshotFlow: Flow<InfraredRemoteConfigSnapshot> =
|
||||
if (profileId == null) {
|
||||
flowOf(InfraredRemoteConfigSnapshot())
|
||||
} else {
|
||||
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
|
||||
val active = profiles.find { it.id == profileId }
|
||||
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
|
||||
when (deviceResult) {
|
||||
is DomainResult.Failure -> flowOf(
|
||||
InfraredRemoteConfigSnapshot(
|
||||
providerProfile = active,
|
||||
configError = deviceResult.error,
|
||||
),
|
||||
)
|
||||
is DomainResult.Success -> {
|
||||
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
||||
keyRepository.observeKeys(profileId, deviceName).map { keysResult ->
|
||||
when (keysResult) {
|
||||
is DomainResult.Failure -> InfraredRemoteConfigSnapshot(
|
||||
providerProfile = active,
|
||||
savedDeviceName = deviceName,
|
||||
configError = keysResult.error,
|
||||
)
|
||||
is DomainResult.Success -> InfraredRemoteConfigSnapshot(
|
||||
providerProfile = active,
|
||||
savedDeviceName = deviceName,
|
||||
learnedSlotMap = keysResult.value.associateBy { it.sdkKey },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<InfraredRemoteUiState> = combine(
|
||||
sceneFlow,
|
||||
isLearningModeFlow,
|
||||
configSnapshotFlow,
|
||||
combine(sendingKeySlotFlow, activeLearningSlotFlow, messageFlow) { sending, learning, msg ->
|
||||
Triple(sending, learning, msg)
|
||||
},
|
||||
) { scene, isLearning, snapshot, (sendingSlot, learningSlot, msg) ->
|
||||
val resolvedMessage = msg ?: snapshot.configError?.let { error ->
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> RemoteUiMessage.SessionInvalid
|
||||
is DomainError.DeviceOffline -> RemoteUiMessage.Error(
|
||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
||||
)
|
||||
else -> RemoteUiMessage.Error(
|
||||
error.detail?.let { UiText.DynamicString(it) }
|
||||
?: UiText.StringResource(R.string.infrared_msg_load_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
InfraredRemoteUiState(
|
||||
selectedScene = scene,
|
||||
isLearningMode = isLearning,
|
||||
profileId = profileId,
|
||||
providerType = snapshot.providerProfile?.type,
|
||||
providerDisplayName = snapshot.providerProfile?.displayName,
|
||||
savedDeviceName = snapshot.savedDeviceName,
|
||||
learnedSlotMap = snapshot.learnedSlotMap,
|
||||
sendingKeySlot = sendingSlot,
|
||||
activeLearningSlot = learningSlot,
|
||||
message = resolvedMessage,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = InfraredRemoteUiState(profileId = profileId),
|
||||
)
|
||||
|
||||
fun switchScene(scene: RemoteScene) {
|
||||
sceneFlow.value = scene
|
||||
}
|
||||
|
||||
fun toggleLearningMode(enabled: Boolean) {
|
||||
isLearningModeFlow.value = enabled
|
||||
if (!enabled) {
|
||||
val shutdownCutoff = System.currentTimeMillis()
|
||||
underlyingLearningDeferred?.cancel()
|
||||
underlyingLearningDeferred = null
|
||||
activeLearningJob?.cancel()
|
||||
activeLearningJob = null
|
||||
activeLearningSlotFlow.value = null
|
||||
profileId?.let { profile ->
|
||||
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = shutdownCutoff)
|
||||
}
|
||||
} else {
|
||||
messageFlow.value = RemoteUiMessage.Info(
|
||||
UiText.StringResource(R.string.infrared_msg_learning_mode_enabled),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun onButtonClick(spec: RemoteButtonSpec) {
|
||||
val currentProfile = profileId ?: return
|
||||
val currentDevice = state.value.savedDeviceName ?: DEFAULT_DEVICE_NAME
|
||||
|
||||
if (isLearningModeFlow.value) {
|
||||
if (underlyingLearningDeferred?.isActive == true || activeLearningJob?.isActive == true || activeLearningSlotFlow.value != null) {
|
||||
return
|
||||
}
|
||||
val existingKey = state.value.learnedSlotMap[spec.defaultSlot]
|
||||
messageFlow.value = RemoteUiMessage.Info(
|
||||
UiText.StringResource(
|
||||
R.string.infrared_msg_recording_key,
|
||||
listOf(spec.displayName, spec.defaultSlot),
|
||||
),
|
||||
)
|
||||
activeLearningSlotFlow.value = spec.defaultSlot
|
||||
val deferred = when (existingKey?.status) {
|
||||
InfraredKeyStatus.FAILED -> learningCoordinator.retry(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
InfraredKeyStatus.READY, InfraredKeyStatus.LEARNING -> learningCoordinator.relearn(InfraredKeyRef(currentProfile, existingKey.id))
|
||||
null -> learningCoordinator.learn(
|
||||
profileId = currentProfile,
|
||||
deviceName = currentDevice,
|
||||
displayName = spec.displayName,
|
||||
sdkKey = spec.defaultSlot,
|
||||
)
|
||||
}
|
||||
underlyingLearningDeferred = deferred
|
||||
val job = viewModelScope.launch {
|
||||
try {
|
||||
val result = if (existingKey != null) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(deferred as kotlinx.coroutines.Deferred<DomainResult<Unit>>).await()
|
||||
} else {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
when (val res = (deferred as kotlinx.coroutines.Deferred<DomainResult<InfraredKeyId>>).await()) {
|
||||
is DomainResult.Success -> DomainResult.Success(Unit)
|
||||
is DomainResult.Failure -> DomainResult.Failure(res.error)
|
||||
}
|
||||
}
|
||||
when (result) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = RemoteUiMessage.Info(
|
||||
UiText.StringResource(
|
||||
R.string.infrared_msg_learned_success,
|
||||
listOf(spec.displayName),
|
||||
),
|
||||
)
|
||||
}
|
||||
is DomainResult.Failure -> {
|
||||
handleDomainError(result.error)
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
throw e
|
||||
} finally {
|
||||
if (underlyingLearningDeferred === deferred) {
|
||||
underlyingLearningDeferred = null
|
||||
}
|
||||
if (activeLearningSlotFlow.value == spec.defaultSlot) {
|
||||
activeLearningSlotFlow.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
activeLearningJob = job
|
||||
} else {
|
||||
if (sendingKeySlotFlow.value != null) {
|
||||
return
|
||||
}
|
||||
val key = state.value.learnedSlotMap[spec.defaultSlot]
|
||||
if (key == null || key.status != InfraredKeyStatus.READY || key.profileId != currentProfile || key.deviceName != currentDevice) {
|
||||
messageFlow.value = RemoteUiMessage.Error(
|
||||
UiText.StringResource(
|
||||
R.string.infrared_msg_key_not_learned,
|
||||
listOf(spec.displayName),
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
sendingKeySlotFlow.value = spec.defaultSlot
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
when (val result = sendInfraredKey(InfraredKeyRef(currentProfile, key.id))) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = RemoteUiMessage.Info(
|
||||
UiText.StringResource(
|
||||
R.string.infrared_msg_signal_sent,
|
||||
listOf(spec.displayName),
|
||||
),
|
||||
)
|
||||
}
|
||||
is DomainResult.Failure -> {
|
||||
handleDomainError(result.error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
sendingKeySlotFlow.value = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
val teardownCutoff = System.currentTimeMillis()
|
||||
underlyingLearningDeferred?.cancel()
|
||||
underlyingLearningDeferred = null
|
||||
activeLearningJob?.cancel()
|
||||
activeLearningJob = null
|
||||
profileId?.let { profile ->
|
||||
learningCoordinator.failInterruptedLearning(profile, cutoffEpochMillis = teardownCutoff)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
messageFlow.value = null
|
||||
}
|
||||
|
||||
private fun handleDomainError(error: DomainError) {
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> {
|
||||
messageFlow.value = RemoteUiMessage.SessionInvalid
|
||||
}
|
||||
is DomainError.DeviceOffline -> {
|
||||
messageFlow.value = RemoteUiMessage.Error(
|
||||
UiText.StringResource(
|
||||
R.string.infrared_msg_device_offline,
|
||||
listOf(error.externalCode ?: "20206"),
|
||||
),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
messageFlow.value = RemoteUiMessage.Error(
|
||||
error.detail?.let { UiText.DynamicString(it) }
|
||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
package com.flagship.abox.manager.infrared.remote
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
|
||||
sealed interface UiText {
|
||||
data class DynamicString(val value: String) : UiText
|
||||
data class StringResource(
|
||||
@StringRes val resId: Int,
|
||||
val args: List<Any> = emptyList(),
|
||||
) : UiText
|
||||
}
|
||||
|
||||
enum class RemoteScene {
|
||||
AIR_CONDITIONER,
|
||||
TELEVISION,
|
||||
}
|
||||
|
||||
data class RemoteButtonSpec(
|
||||
val id: String,
|
||||
val defaultSlot: String,
|
||||
@StringRes val nameRes: Int,
|
||||
val displayName: String,
|
||||
val symbol: String,
|
||||
)
|
||||
|
||||
object RemoteSceneConfig {
|
||||
object AirConditioner {
|
||||
val POWER = RemoteButtonSpec("AC_POWER", "1", R.string.infrared_key_ac_power, "空调-电源", "⏻")
|
||||
val TEMP_UP = RemoteButtonSpec("AC_TEMP_UP", "2", R.string.infrared_key_ac_temp_up, "空调-温度+", "▲")
|
||||
val TEMP_DOWN = RemoteButtonSpec("AC_TEMP_DOWN", "3", R.string.infrared_key_ac_temp_down, "空调-温度-", "▼")
|
||||
val MODE = RemoteButtonSpec("AC_MODE", "4", R.string.infrared_key_ac_mode, "空调-模式", "模式 ❄/☀")
|
||||
val FAN = RemoteButtonSpec("AC_FAN", "5", R.string.infrared_key_ac_fan, "空调-风速", "风速 💨")
|
||||
val SWING = RemoteButtonSpec("AC_SWING", "6", R.string.infrared_key_ac_swing, "空调-扫风", "扫风 ↔")
|
||||
val TIMER = RemoteButtonSpec("AC_TIMER", "7", R.string.infrared_key_ac_timer, "空调-定时", "定时 ⏱")
|
||||
|
||||
val buttons = listOf(POWER, TEMP_UP, TEMP_DOWN, MODE, FAN, SWING, TIMER)
|
||||
}
|
||||
|
||||
object Television {
|
||||
val POWER = RemoteButtonSpec("TV_POWER", "21", R.string.infrared_key_tv_power, "电视-电源", "⏻")
|
||||
val MUTE = RemoteButtonSpec("TV_MUTE", "22", R.string.infrared_key_tv_mute, "电视-静音", "🔇")
|
||||
val NAV_UP = RemoteButtonSpec("TV_NAV_UP", "23", R.string.infrared_key_tv_nav_up, "电视-上", "▲")
|
||||
val NAV_DOWN = RemoteButtonSpec("TV_NAV_DOWN", "24", R.string.infrared_key_tv_nav_down, "电视-下", "▼")
|
||||
val NAV_LEFT = RemoteButtonSpec("TV_NAV_LEFT", "25", R.string.infrared_key_tv_nav_left, "电视-左", "◀")
|
||||
val NAV_RIGHT = RemoteButtonSpec("TV_NAV_RIGHT", "26", R.string.infrared_key_tv_nav_right, "电视-右", "▶")
|
||||
val OK = RemoteButtonSpec("TV_OK", "27", R.string.infrared_key_tv_ok, "电视-确定", "OK")
|
||||
val VOL_UP = RemoteButtonSpec("TV_VOL_UP", "28", R.string.infrared_key_tv_vol_up, "电视-音量+", "VOL +")
|
||||
val VOL_DOWN = RemoteButtonSpec("TV_VOL_DOWN", "29", R.string.infrared_key_tv_vol_down, "电视-音量-", "VOL -")
|
||||
val CH_UP = RemoteButtonSpec("TV_CH_UP", "30", R.string.infrared_key_tv_ch_up, "电视-频道+", "CH +")
|
||||
val CH_DOWN = RemoteButtonSpec("TV_CH_DOWN", "31", R.string.infrared_key_tv_ch_down, "电视-频道-", "CH -")
|
||||
val HOME = RemoteButtonSpec("TV_HOME", "32", R.string.infrared_key_tv_home, "电视-主页", "🏠")
|
||||
val BACK = RemoteButtonSpec("TV_BACK", "33", R.string.infrared_key_tv_back, "电视-返回", "↩")
|
||||
val MENU = RemoteButtonSpec("TV_MENU", "34", R.string.infrared_key_tv_menu, "电视-菜单", "☰")
|
||||
|
||||
val buttons = listOf(
|
||||
POWER, MUTE, NAV_UP, NAV_DOWN, NAV_LEFT, NAV_RIGHT, OK,
|
||||
VOL_UP, VOL_DOWN, CH_UP, CH_DOWN, HOME, BACK, MENU,
|
||||
)
|
||||
}
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
package com.flagship.abox.manager.infrared.remote.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
||||
|
||||
@Composable
|
||||
fun AcRemotePanel(
|
||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
||||
activeLearningSlot: String?,
|
||||
sendingKeySlot: String?,
|
||||
isLearningMode: Boolean,
|
||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
RemoteChassis(modifier = modifier) {
|
||||
// 顶部虚拟 LCD 液晶显示区
|
||||
AcLcdDisplay()
|
||||
|
||||
// 电源与温度控制行
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// 电源按键
|
||||
val powerSpec = RemoteSceneConfig.AirConditioner.POWER
|
||||
val isPowerLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY
|
||||
val isPowerLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot
|
||||
|
||||
RemoteCircleButton(
|
||||
spec = powerSpec,
|
||||
isLearned = isPowerLearned,
|
||||
isLoading = isPowerLoading,
|
||||
isLearningMode = isLearningMode,
|
||||
onClick = { onButtonClick(powerSpec) },
|
||||
size = 72.dp,
|
||||
containerColor = Color(0xFFE53935),
|
||||
contentColor = Color.White,
|
||||
)
|
||||
|
||||
// 垂直胶囊温度调节器
|
||||
val tempUpSpec = RemoteSceneConfig.AirConditioner.TEMP_UP
|
||||
val tempDownSpec = RemoteSceneConfig.AirConditioner.TEMP_DOWN
|
||||
|
||||
VerticalCapsuleRocker(
|
||||
upSpec = tempUpSpec,
|
||||
downSpec = tempDownSpec,
|
||||
isUpLearned = learnedSlotMap[tempUpSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isDownLearned = learnedSlotMap[tempDownSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isUpLoading = activeLearningSlot == tempUpSpec.defaultSlot || sendingKeySlot == tempUpSpec.defaultSlot,
|
||||
isDownLoading = activeLearningSlot == tempDownSpec.defaultSlot || sendingKeySlot == tempDownSpec.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onUpClick = { onButtonClick(tempUpSpec) },
|
||||
onDownClick = { onButtonClick(tempDownSpec) },
|
||||
label = stringResource(R.string.infrared_label_temp_control),
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 功能按键网格 (模式 / 风速 / 扫风 / 定时)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
AcGridButton(RemoteSceneConfig.AirConditioner.MODE, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
||||
AcGridButton(RemoteSceneConfig.AirConditioner.FAN, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
) {
|
||||
AcGridButton(RemoteSceneConfig.AirConditioner.SWING, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
||||
AcGridButton(RemoteSceneConfig.AirConditioner.TIMER, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AcGridButton(
|
||||
spec: RemoteButtonSpec,
|
||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
||||
activeLearningSlot: String?,
|
||||
sendingKeySlot: String?,
|
||||
isLearningMode: Boolean,
|
||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
||||
) {
|
||||
RemoteRectButton(
|
||||
spec = spec,
|
||||
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onClick = { onButtonClick(spec) },
|
||||
width = 120.dp,
|
||||
height = 52.dp,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AcLcdDisplay() {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(96.dp)
|
||||
.shadow(4.dp, shape = RoundedCornerShape(16.dp)),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = Color(0xFF1E293B),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_label_cool_mode),
|
||||
color = Color(0xFF38BDF8),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_label_ac_status),
|
||||
color = Color(0xFF94A3B8),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "26°C",
|
||||
color = Color(0xFF4ADE80),
|
||||
fontSize = 38.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
package com.flagship.abox.manager.infrared.remote.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteUiState
|
||||
import com.flagship.abox.manager.infrared.remote.InfraredRemoteViewModel
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteScene
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteUiMessage
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
@Composable
|
||||
fun InfraredRemoteRoute(
|
||||
onBack: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
viewModel: InfraredRemoteViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
InfraredRemoteScreen(
|
||||
state = state,
|
||||
onBack = onBack,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onSessionInvalid = onSessionInvalid,
|
||||
onSceneSelected = viewModel::switchScene,
|
||||
onToggleLearningMode = viewModel::toggleLearningMode,
|
||||
onButtonClick = viewModel::onButtonClick,
|
||||
onMessageShown = viewModel::clearMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun InfraredRemoteScreen(
|
||||
state: InfraredRemoteUiState,
|
||||
onBack: () -> Unit,
|
||||
onOpenSettings: () -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
onSceneSelected: (RemoteScene) -> Unit,
|
||||
onToggleLearningMode: (Boolean) -> Unit,
|
||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
||||
onMessageShown: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
when (msg) {
|
||||
is RemoteUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is RemoteUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is RemoteUiMessage.SessionInvalid -> {
|
||||
onSessionInvalid()
|
||||
onMessageShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_remote_title),
|
||||
style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text(stringResource(R.string.infrared_back))
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// 学习模式开关
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = if (state.isLearningMode) {
|
||||
stringResource(R.string.infrared_remote_recording)
|
||||
} else {
|
||||
stringResource(R.string.infrared_remote_learning_mode)
|
||||
},
|
||||
fontSize = 12.sp,
|
||||
fontWeight = if (state.isLearningMode) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (state.isLearningMode) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Switch(
|
||||
checked = state.isLearningMode,
|
||||
onCheckedChange = onToggleLearningMode,
|
||||
modifier = Modifier.testTag("remote_learning_mode_switch"),
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.error,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.errorContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 按键管理 / 设置入口按钮
|
||||
IconButton(
|
||||
onClick = onOpenSettings,
|
||||
modifier = Modifier.testTag("remote_settings_button"),
|
||||
) {
|
||||
Text("⚙️", fontSize = 20.sp)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
ABoxSurface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// 学习模式顶部横幅提示
|
||||
if (state.isLearningMode) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.8f),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(vertical = 8.dp, horizontal = 12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_remote_learning_banner),
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 场景切换 Tab (空调 | 电视)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
FilterChip(
|
||||
selected = state.selectedScene == RemoteScene.AIR_CONDITIONER,
|
||||
onClick = { onSceneSelected(RemoteScene.AIR_CONDITIONER) },
|
||||
label = { Text(stringResource(R.string.infrared_remote_scene_ac)) },
|
||||
modifier = Modifier
|
||||
.padding(end = 8.dp)
|
||||
.testTag("scene_tab_ac"),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.selectedScene == RemoteScene.TELEVISION,
|
||||
onClick = { onSceneSelected(RemoteScene.TELEVISION) },
|
||||
label = { Text(stringResource(R.string.infrared_remote_scene_tv)) },
|
||||
modifier = Modifier.testTag("scene_tab_tv"),
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// 对应场景遥控器面板
|
||||
when (state.selectedScene) {
|
||||
RemoteScene.AIR_CONDITIONER -> {
|
||||
AcRemotePanel(
|
||||
learnedSlotMap = state.learnedSlotMap,
|
||||
activeLearningSlot = state.activeLearningSlot,
|
||||
sendingKeySlot = state.sendingKeySlot,
|
||||
isLearningMode = state.isLearningMode,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
}
|
||||
RemoteScene.TELEVISION -> {
|
||||
TvRemotePanel(
|
||||
learnedSlotMap = state.learnedSlotMap,
|
||||
activeLearningSlot = state.activeLearningSlot,
|
||||
sendingKeySlot = state.sendingKeySlot,
|
||||
isLearningMode = state.isLearningMode,
|
||||
onButtonClick = onButtonClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+429
@@ -0,0 +1,429 @@
|
||||
package com.flagship.abox.manager.infrared.remote.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Divider
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
||||
|
||||
@Composable
|
||||
fun RemoteChassis(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp)
|
||||
.shadow(12.dp, shape = RoundedCornerShape(32.dp)),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f),
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoteCircleButton(
|
||||
spec: RemoteButtonSpec,
|
||||
isLearned: Boolean,
|
||||
isLoading: Boolean,
|
||||
isLearningMode: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
size: Dp = 64.dp,
|
||||
containerColor: Color? = null,
|
||||
contentColor: Color? = null,
|
||||
) {
|
||||
val defaultContainer = when {
|
||||
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
isLearned -> containerColor ?: MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
val defaultContent = when {
|
||||
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
isLearned -> contentColor ?: MaterialTheme.colorScheme.onPrimary
|
||||
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
}
|
||||
|
||||
val borderModifier = if (isLearningMode) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
|
||||
} else if (!isLearned) {
|
||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.size(size)
|
||||
.then(borderModifier)
|
||||
.testTag("remote_button_${spec.id}"),
|
||||
shape = CircleShape,
|
||||
color = defaultContainer,
|
||||
contentColor = defaultContent,
|
||||
shadowElevation = if (isLearned) 4.dp else 1.dp,
|
||||
enabled = !isLoading,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(size * 0.45f),
|
||||
strokeWidth = 2.5.dp,
|
||||
color = defaultContent,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = spec.symbol,
|
||||
style = MaterialTheme.typography.titleMedium.copy(
|
||||
fontSize = if (spec.symbol.length <= 2) 22.sp else 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoteRectButton(
|
||||
spec: RemoteButtonSpec,
|
||||
isLearned: Boolean,
|
||||
isLoading: Boolean,
|
||||
isLearningMode: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
width: Dp = 100.dp,
|
||||
height: Dp = 48.dp,
|
||||
) {
|
||||
val containerColor = when {
|
||||
isLearningMode -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
isLearned -> MaterialTheme.colorScheme.secondaryContainer
|
||||
else -> MaterialTheme.colorScheme.surface
|
||||
}
|
||||
val contentColor = when {
|
||||
isLearningMode -> MaterialTheme.colorScheme.onPrimaryContainer
|
||||
isLearned -> MaterialTheme.colorScheme.onSecondaryContainer
|
||||
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
}
|
||||
|
||||
val borderModifier = if (isLearningMode) {
|
||||
Modifier.border(2.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(16.dp))
|
||||
} else if (!isLearned) {
|
||||
Modifier.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), RoundedCornerShape(16.dp))
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = modifier
|
||||
.width(width)
|
||||
.height(height)
|
||||
.then(borderModifier)
|
||||
.testTag("remote_button_${spec.id}"),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = containerColor,
|
||||
contentColor = contentColor,
|
||||
shadowElevation = if (isLearned) 3.dp else 1.dp,
|
||||
enabled = !isLoading,
|
||||
onClick = onClick,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = contentColor,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = spec.symbol,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VerticalCapsuleRocker(
|
||||
upSpec: RemoteButtonSpec,
|
||||
downSpec: RemoteButtonSpec,
|
||||
isUpLearned: Boolean,
|
||||
isDownLearned: Boolean,
|
||||
isUpLoading: Boolean,
|
||||
isDownLoading: Boolean,
|
||||
isLearningMode: Boolean,
|
||||
onUpClick: () -> Unit,
|
||||
onDownClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String = "",
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.width(68.dp)
|
||||
.height(136.dp)
|
||||
.shadow(4.dp, shape = RoundedCornerShape(34.dp)),
|
||||
shape = RoundedCornerShape(34.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
// Top half
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(topStart = 34.dp, topEnd = 34.dp))
|
||||
.background(
|
||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
)
|
||||
.clickable(enabled = !isUpLoading, onClick = onUpClick)
|
||||
.testTag("remote_button_${upSpec.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isUpLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(
|
||||
text = upSpec.symbol,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant)
|
||||
|
||||
// Bottom half
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(bottomStart = 34.dp, bottomEnd = 34.dp))
|
||||
.background(
|
||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
)
|
||||
.clickable(enabled = !isDownLoading, onClick = onDownClick)
|
||||
.testTag("remote_button_${downSpec.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isDownLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||
} else {
|
||||
Text(
|
||||
text = downSpec.symbol,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (label.isNotBlank()) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RemoteDpadWheel(
|
||||
upSpec: RemoteButtonSpec,
|
||||
downSpec: RemoteButtonSpec,
|
||||
leftSpec: RemoteButtonSpec,
|
||||
rightSpec: RemoteButtonSpec,
|
||||
okSpec: RemoteButtonSpec,
|
||||
isUpLearned: Boolean,
|
||||
isDownLearned: Boolean,
|
||||
isLeftLearned: Boolean,
|
||||
isRightLearned: Boolean,
|
||||
isOkLearned: Boolean,
|
||||
isUpLoading: Boolean,
|
||||
isDownLoading: Boolean,
|
||||
isLeftLoading: Boolean,
|
||||
isRightLoading: Boolean,
|
||||
isOkLoading: Boolean,
|
||||
isLearningMode: Boolean,
|
||||
onUpClick: () -> Unit,
|
||||
onDownClick: () -> Unit,
|
||||
onLeftClick: () -> Unit,
|
||||
onRightClick: () -> Unit,
|
||||
onOkClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
wheelSize: Dp = 190.dp,
|
||||
) {
|
||||
Card(
|
||||
modifier = modifier
|
||||
.size(wheelSize)
|
||||
.shadow(6.dp, shape = CircleShape),
|
||||
shape = CircleShape,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Up Button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(top = 10.dp)
|
||||
.size(54.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
else if (isUpLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
)
|
||||
.clickable(enabled = !isUpLoading, onClick = onUpClick)
|
||||
.testTag("remote_button_${upSpec.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isUpLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
else Text(upSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
|
||||
// Down Button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 10.dp)
|
||||
.size(54.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
else if (isDownLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
)
|
||||
.clickable(enabled = !isDownLoading, onClick = onDownClick)
|
||||
.testTag("remote_button_${downSpec.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isDownLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
else Text(downSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
|
||||
// Left Button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.padding(start = 10.dp)
|
||||
.size(54.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
else if (isLeftLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
)
|
||||
.clickable(enabled = !isLeftLoading, onClick = onLeftClick)
|
||||
.testTag("remote_button_${leftSpec.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isLeftLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
else Text(leftSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
|
||||
// Right Button
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 10.dp)
|
||||
.size(54.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isLearningMode) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.8f)
|
||||
else if (isRightLearned) MaterialTheme.colorScheme.primary.copy(alpha = 0.15f)
|
||||
else Color.Transparent,
|
||||
)
|
||||
.clickable(enabled = !isRightLoading, onClick = onRightClick)
|
||||
.testTag("remote_button_${rightSpec.id}"),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isRightLoading) CircularProgressIndicator(modifier = Modifier.size(18.dp))
|
||||
else Text(rightSpec.symbol, style = MaterialTheme.typography.titleLarge)
|
||||
}
|
||||
|
||||
// Center OK Button
|
||||
RemoteCircleButton(
|
||||
spec = okSpec,
|
||||
isLearned = isOkLearned,
|
||||
isLoading = isOkLoading,
|
||||
isLearningMode = isLearningMode,
|
||||
onClick = onOkClick,
|
||||
size = 62.dp,
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.flagship.abox.manager.infrared.remote.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteButtonSpec
|
||||
import com.flagship.abox.manager.infrared.remote.RemoteSceneConfig
|
||||
|
||||
@Composable
|
||||
fun TvRemotePanel(
|
||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
||||
activeLearningSlot: String?,
|
||||
sendingKeySlot: String?,
|
||||
isLearningMode: Boolean,
|
||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
RemoteChassis(modifier = modifier) {
|
||||
// 顶部电源与静音行
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val powerSpec = RemoteSceneConfig.Television.POWER
|
||||
RemoteCircleButton(
|
||||
spec = powerSpec,
|
||||
isLearned = learnedSlotMap[powerSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isLoading = activeLearningSlot == powerSpec.defaultSlot || sendingKeySlot == powerSpec.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onClick = { onButtonClick(powerSpec) },
|
||||
size = 56.dp,
|
||||
containerColor = Color(0xFFE53935),
|
||||
contentColor = Color.White,
|
||||
)
|
||||
|
||||
val muteSpec = RemoteSceneConfig.Television.MUTE
|
||||
RemoteCircleButton(
|
||||
spec = muteSpec,
|
||||
isLearned = learnedSlotMap[muteSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isLoading = activeLearningSlot == muteSpec.defaultSlot || sendingKeySlot == muteSpec.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onClick = { onButtonClick(muteSpec) },
|
||||
size = 56.dp,
|
||||
)
|
||||
}
|
||||
|
||||
// 中心圆形方向轮盘 (D-Pad Wheel)
|
||||
val upSpec = RemoteSceneConfig.Television.NAV_UP
|
||||
val downSpec = RemoteSceneConfig.Television.NAV_DOWN
|
||||
val leftSpec = RemoteSceneConfig.Television.NAV_LEFT
|
||||
val rightSpec = RemoteSceneConfig.Television.NAV_RIGHT
|
||||
val okSpec = RemoteSceneConfig.Television.OK
|
||||
|
||||
RemoteDpadWheel(
|
||||
upSpec = upSpec,
|
||||
downSpec = downSpec,
|
||||
leftSpec = leftSpec,
|
||||
rightSpec = rightSpec,
|
||||
okSpec = okSpec,
|
||||
isUpLearned = learnedSlotMap[upSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isDownLearned = learnedSlotMap[downSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isLeftLearned = learnedSlotMap[leftSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isRightLearned = learnedSlotMap[rightSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isOkLearned = learnedSlotMap[okSpec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isUpLoading = activeLearningSlot == upSpec.defaultSlot || sendingKeySlot == upSpec.defaultSlot,
|
||||
isDownLoading = activeLearningSlot == downSpec.defaultSlot || sendingKeySlot == downSpec.defaultSlot,
|
||||
isLeftLoading = activeLearningSlot == leftSpec.defaultSlot || sendingKeySlot == leftSpec.defaultSlot,
|
||||
isRightLoading = activeLearningSlot == rightSpec.defaultSlot || sendingKeySlot == rightSpec.defaultSlot,
|
||||
isOkLoading = activeLearningSlot == okSpec.defaultSlot || sendingKeySlot == okSpec.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onUpClick = { onButtonClick(upSpec) },
|
||||
onDownClick = { onButtonClick(downSpec) },
|
||||
onLeftClick = { onButtonClick(leftSpec) },
|
||||
onRightClick = { onButtonClick(rightSpec) },
|
||||
onOkClick = { onButtonClick(okSpec) },
|
||||
)
|
||||
|
||||
// 系统功能按键行 (返回 / 主页 / 菜单)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TvActionButton(RemoteSceneConfig.Television.BACK, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
||||
TvActionButton(RemoteSceneConfig.Television.HOME, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
||||
TvActionButton(RemoteSceneConfig.Television.MENU, learnedSlotMap, activeLearningSlot, sendingKeySlot, isLearningMode, onButtonClick)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// 音量与频道双摇杆胶囊
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// 音量控制
|
||||
val volUp = RemoteSceneConfig.Television.VOL_UP
|
||||
val volDown = RemoteSceneConfig.Television.VOL_DOWN
|
||||
VerticalCapsuleRocker(
|
||||
upSpec = volUp,
|
||||
downSpec = volDown,
|
||||
isUpLearned = learnedSlotMap[volUp.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isDownLearned = learnedSlotMap[volDown.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isUpLoading = activeLearningSlot == volUp.defaultSlot || sendingKeySlot == volUp.defaultSlot,
|
||||
isDownLoading = activeLearningSlot == volDown.defaultSlot || sendingKeySlot == volDown.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onUpClick = { onButtonClick(volUp) },
|
||||
onDownClick = { onButtonClick(volDown) },
|
||||
label = stringResource(R.string.infrared_label_vol),
|
||||
)
|
||||
|
||||
// 频道控制
|
||||
val chUp = RemoteSceneConfig.Television.CH_UP
|
||||
val chDown = RemoteSceneConfig.Television.CH_DOWN
|
||||
VerticalCapsuleRocker(
|
||||
upSpec = chUp,
|
||||
downSpec = chDown,
|
||||
isUpLearned = learnedSlotMap[chUp.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isDownLearned = learnedSlotMap[chDown.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isUpLoading = activeLearningSlot == chUp.defaultSlot || sendingKeySlot == chUp.defaultSlot,
|
||||
isDownLoading = activeLearningSlot == chDown.defaultSlot || sendingKeySlot == chDown.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onUpClick = { onButtonClick(chUp) },
|
||||
onDownClick = { onButtonClick(chDown) },
|
||||
label = stringResource(R.string.infrared_label_ch),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TvActionButton(
|
||||
spec: RemoteButtonSpec,
|
||||
learnedSlotMap: Map<String, SavedInfraredKey>,
|
||||
activeLearningSlot: String?,
|
||||
sendingKeySlot: String?,
|
||||
isLearningMode: Boolean,
|
||||
onButtonClick: (RemoteButtonSpec) -> Unit,
|
||||
) {
|
||||
RemoteRectButton(
|
||||
spec = spec,
|
||||
isLearned = learnedSlotMap[spec.defaultSlot]?.status == InfraredKeyStatus.READY,
|
||||
isLoading = activeLearningSlot == spec.defaultSlot || sendingKeySlot == spec.defaultSlot,
|
||||
isLearningMode = isLearningMode,
|
||||
onClick = { onButtonClick(spec) },
|
||||
width = 84.dp,
|
||||
height = 44.dp,
|
||||
)
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package com.flagship.abox.manager.infrared.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.flagship.abox.manager.designsystem.ABoxSurface
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
@Composable
|
||||
fun InfraredControlRoute(
|
||||
onBack: () -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
viewModel: InfraredControlViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
InfraredControlScreen(
|
||||
state = state,
|
||||
onBack = onBack,
|
||||
onSessionInvalid = onSessionInvalid,
|
||||
onDeviceNameChanged = viewModel::onDeviceNameChanged,
|
||||
onSaveDeviceName = viewModel::saveDeviceName,
|
||||
onMessageShown = viewModel::clearMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun InfraredControlScreen(
|
||||
state: InfraredControlUiState,
|
||||
onBack: () -> Unit,
|
||||
onSessionInvalid: () -> Unit,
|
||||
onDeviceNameChanged: (String) -> Unit,
|
||||
onSaveDeviceName: () -> Unit,
|
||||
onMessageShown: () -> Unit,
|
||||
) {
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val context = LocalContext.current
|
||||
val scrollState = rememberScrollState()
|
||||
|
||||
LaunchedEffect(state.message) {
|
||||
state.message?.let { msg ->
|
||||
when (msg) {
|
||||
is InfraredUiMessage.Info -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is InfraredUiMessage.Error -> {
|
||||
val text = when (val t = msg.text) {
|
||||
is UiText.DynamicString -> t.value
|
||||
is UiText.StringResource -> context.getString(t.resId, *t.args.toTypedArray())
|
||||
}
|
||||
snackbarHostState.showSnackbar(text)
|
||||
onMessageShown()
|
||||
}
|
||||
is InfraredUiMessage.SessionInvalid -> {
|
||||
onSessionInvalid()
|
||||
onMessageShown()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.infrared_control_title)) },
|
||||
navigationIcon = {
|
||||
TextButton(onClick = onBack) {
|
||||
Text(stringResource(R.string.infrared_back))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
) { innerPadding ->
|
||||
ABoxSurface(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
if (state.loading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scrollState)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// 1. 当前环境卡片
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "运行环境与连接信息",
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
val envText = when (state.providerType) {
|
||||
ProviderType.LOCAL_EXPERIENCE -> stringResource(R.string.infrared_mode_local_simulation)
|
||||
ProviderType.ABOX -> stringResource(R.string.infrared_mode_abox_real)
|
||||
null -> state.profileId?.value ?: "未知环境"
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_mode_label, envText),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
|
||||
state.providerDisplayName?.let {
|
||||
Text(
|
||||
text = "当前 Profile: $it",
|
||||
fontSize = 13.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. ABSDK 硬件设备名称配置(修改当前设备名)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_device_config_section),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_device_config_desc),
|
||||
fontSize = 12.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_device_name_current, state.savedDeviceName.value),
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = state.deviceNameInput,
|
||||
onValueChange = onDeviceNameChanged,
|
||||
label = { Text("修改设备名(如:红外控制器)") },
|
||||
singleLine = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.testTag("infrared_device_name_input"),
|
||||
)
|
||||
|
||||
Button(
|
||||
onClick = onSaveDeviceName,
|
||||
enabled = state.deviceNameInput.isNotBlank() && state.deviceNameInput != state.savedDeviceName.value,
|
||||
modifier = Modifier.testTag("infrared_save_device_button"),
|
||||
) {
|
||||
Text(stringResource(R.string.infrared_device_save_changes))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 硬件槽位与遥控器概览
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.infrared_device_stats_section),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = "已配置的虚拟遥控器:${state.totalRemotesCount} 个",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
|
||||
QuotaDetailProgress(quota = state.slotQuota)
|
||||
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.background,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text("💡 槽位工作原理:", fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||
Text("• 静态独占槽位 (Slot 1..99):用于物理学习录制的按键或预设静态按键,硬件直发低延迟(~20ms)。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("• 动态共享缓冲 (Slot 100):用于空调全状态算法写码与动态模式遥控器,按需现场写入发射,不消耗独占槽位。", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QuotaDetailProgress(quota: SlotQuotaSummary) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "槽位总容量占用: ${quota.usedCount} / ${quota.totalCapacity}",
|
||||
fontWeight = FontWeight.Medium,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Text(
|
||||
text = "剩余可用静态槽位: ${quota.remainingStaticCount}",
|
||||
fontSize = 12.sp,
|
||||
color = if (quota.isStaticFull) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
|
||||
LinearProgressIndicator(
|
||||
progress = { quota.usedCount.toFloat() / quota.totalCapacity.toFloat() },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp)),
|
||||
)
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
package com.flagship.abox.manager.infrared.ui
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
|
||||
sealed interface InfraredUiMessage {
|
||||
data class Info(val text: UiText) : InfraredUiMessage
|
||||
data class Error(val text: UiText) : InfraredUiMessage
|
||||
data object SessionInvalid : InfraredUiMessage
|
||||
}
|
||||
|
||||
data class InfraredControlUiState(
|
||||
val profileId: ProviderProfileId? = null,
|
||||
val providerType: ProviderType? = null,
|
||||
val providerDisplayName: String? = null,
|
||||
val deviceNameInput: String = "",
|
||||
val savedDeviceName: DeviceName = DeviceName("红外控制器"),
|
||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||
val totalRemotesCount: Int = 0,
|
||||
val loading: Boolean = true,
|
||||
val message: InfraredUiMessage? = null,
|
||||
)
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package com.flagship.abox.manager.infrared.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainError
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderProfileStore
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@HiltViewModel
|
||||
class InfraredControlViewModel @Inject constructor(
|
||||
savedStateHandle: SavedStateHandle,
|
||||
private val deviceConfigurationRepository: InfraredDeviceConfigurationRepository,
|
||||
private val virtualRemoteRepository: VirtualRemoteRepository,
|
||||
private val providerProfileStore: ProviderProfileStore,
|
||||
) : ViewModel() {
|
||||
|
||||
private val rawProfileId: String? = savedStateHandle["profileId"]
|
||||
val profileId: ProviderProfileId? = rawProfileId?.takeIf { it.isNotBlank() }?.let { ProviderProfileId(it) }
|
||||
|
||||
private val deviceNameInputFlow = MutableStateFlow<String?>(null)
|
||||
private val messageFlow = MutableStateFlow<InfraredUiMessage?>(null)
|
||||
|
||||
private data class ServerState(
|
||||
val providerProfile: com.flagship.abox.manager.domain.ProviderProfile? = null,
|
||||
val savedDevice: DeviceName = DEFAULT_DEVICE_NAME,
|
||||
val slotQuota: SlotQuotaSummary = SlotQuotaSummary(),
|
||||
val remotesCount: Int = 0,
|
||||
val loading: Boolean = false,
|
||||
val configError: DomainError? = null,
|
||||
)
|
||||
|
||||
private val serverStateFlow: Flow<ServerState> =
|
||||
if (profileId == null) {
|
||||
flowOf(ServerState(loading = false))
|
||||
} else {
|
||||
providerProfileStore.observeProfiles().flatMapLatest { profiles ->
|
||||
val active = profiles.find { it.id == profileId }
|
||||
deviceConfigurationRepository.observeDeviceName(profileId).flatMapLatest { deviceResult ->
|
||||
when (deviceResult) {
|
||||
is DomainResult.Failure -> flowOf(
|
||||
ServerState(
|
||||
providerProfile = active,
|
||||
configError = deviceResult.error,
|
||||
),
|
||||
)
|
||||
is DomainResult.Success -> {
|
||||
val deviceName = deviceResult.value ?: DEFAULT_DEVICE_NAME
|
||||
combine(
|
||||
virtualRemoteRepository.observeRemotes(profileId, deviceName),
|
||||
virtualRemoteRepository.observeSlotQuota(profileId, deviceName),
|
||||
) { remotesResult, quotaResult ->
|
||||
val remotes = (remotesResult as? DomainResult.Success)?.value ?: emptyList()
|
||||
val quota = (quotaResult as? DomainResult.Success)?.value
|
||||
?: SlotQuotaSummary()
|
||||
ServerState(
|
||||
providerProfile = active,
|
||||
savedDevice = deviceName,
|
||||
slotQuota = quota,
|
||||
remotesCount = remotes.size,
|
||||
loading = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<InfraredControlUiState> = combine(
|
||||
serverStateFlow,
|
||||
deviceNameInputFlow,
|
||||
messageFlow,
|
||||
) { serverState, input, msg ->
|
||||
val resolvedMessage = msg ?: serverState.configError?.let { error ->
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> InfraredUiMessage.SessionInvalid
|
||||
is DomainError.DeviceOffline -> InfraredUiMessage.Error(
|
||||
UiText.StringResource(R.string.infrared_msg_device_offline, listOf(error.externalCode ?: "20206")),
|
||||
)
|
||||
else -> InfraredUiMessage.Error(
|
||||
error.detail?.let { UiText.DynamicString(it) }
|
||||
?: UiText.StringResource(R.string.infrared_msg_load_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val effectiveInput = input ?: serverState.savedDevice.value
|
||||
|
||||
InfraredControlUiState(
|
||||
profileId = profileId,
|
||||
providerType = serverState.providerProfile?.type,
|
||||
providerDisplayName = serverState.providerProfile?.displayName,
|
||||
deviceNameInput = effectiveInput,
|
||||
savedDeviceName = serverState.savedDevice,
|
||||
slotQuota = serverState.slotQuota,
|
||||
totalRemotesCount = serverState.remotesCount,
|
||||
loading = serverState.loading,
|
||||
message = resolvedMessage,
|
||||
)
|
||||
}.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.WhileSubscribed(5_000),
|
||||
initialValue = InfraredControlUiState(profileId = profileId, loading = true),
|
||||
)
|
||||
|
||||
fun onDeviceNameChanged(value: String) {
|
||||
deviceNameInputFlow.value = value
|
||||
}
|
||||
|
||||
fun saveDeviceName() {
|
||||
val currentProfile = profileId ?: return
|
||||
val input = deviceNameInputFlow.value ?: state.value.savedDeviceName.value
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isBlank()) {
|
||||
messageFlow.value = InfraredUiMessage.Error(
|
||||
UiText.StringResource(R.string.infrared_msg_device_name_blank),
|
||||
)
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
when (val result = deviceConfigurationRepository.setDeviceName(currentProfile, DeviceName(trimmed))) {
|
||||
is DomainResult.Success -> {
|
||||
messageFlow.value = InfraredUiMessage.Info(
|
||||
UiText.StringResource(R.string.infrared_msg_device_name_saved),
|
||||
)
|
||||
}
|
||||
is DomainResult.Failure -> {
|
||||
handleDomainError(result.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearMessage() {
|
||||
messageFlow.value = null
|
||||
}
|
||||
|
||||
private fun handleDomainError(error: DomainError) {
|
||||
when (error) {
|
||||
is DomainError.TokenInvalid, is DomainError.Authentication -> {
|
||||
messageFlow.value = InfraredUiMessage.SessionInvalid
|
||||
}
|
||||
is DomainError.DeviceOffline -> {
|
||||
messageFlow.value = InfraredUiMessage.Error(
|
||||
UiText.StringResource(
|
||||
R.string.infrared_msg_device_offline,
|
||||
listOf(error.externalCode ?: "20206"),
|
||||
),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
messageFlow.value = InfraredUiMessage.Error(
|
||||
error.detail?.let { UiText.DynamicString(it) }
|
||||
?: UiText.StringResource(R.string.infrared_msg_operation_failed),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val DEFAULT_DEVICE_NAME = DeviceName("红外控制器")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
<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>
|
||||
@@ -0,0 +1,117 @@
|
||||
<resources>
|
||||
<string name="infrared_title">Infrared Control</string>
|
||||
<string name="infrared_mode_fake">Mode: Fake / Simulated</string>
|
||||
<string name="infrared_mode_real">Mode: Real ABSDK</string>
|
||||
<string name="infrared_switch_mode">Switch Mode</string>
|
||||
<string name="infrared_device_name_label">Infrared Device Name</string>
|
||||
<string name="infrared_device_name_hint">e.g. ir-living</string>
|
||||
<string name="infrared_saved_device">Saved Device: %1$s</string>
|
||||
<string name="infrared_no_device_saved">No infrared device configured</string>
|
||||
<string name="infrared_save_device">Save Device</string>
|
||||
<string name="infrared_keys_title">Learned Keys</string>
|
||||
<string name="infrared_no_keys">No infrared keys saved</string>
|
||||
<string name="infrared_learn_new_key">Learn New Key</string>
|
||||
<string name="infrared_status_learning">Learning…</string>
|
||||
<string name="infrared_status_ready">Ready</string>
|
||||
<string name="infrared_status_failed">Failed</string>
|
||||
<string name="infrared_send">Send</string>
|
||||
<string name="infrared_sending">Sending…</string>
|
||||
<string name="infrared_retry">Retry</string>
|
||||
<string name="infrared_rename">Rename</string>
|
||||
<string name="infrared_delete">Delete</string>
|
||||
<string name="infrared_learn_dialog_title">Learn Infrared Key</string>
|
||||
<string name="infrared_learn_dialog_prompt">Prepare the physical remote. After starting, point it at the infrared controller and press the target button.</string>
|
||||
<string name="infrared_learn_dialog_input_label">Key Name (e.g. Power)</string>
|
||||
<string name="infrared_learn_dialog_slot_label">Key Slot (1-100)</string>
|
||||
<string name="infrared_key_slot_label">Key Slot: %1$s</string>
|
||||
<string name="infrared_learn_dialog_start">Start Learning</string>
|
||||
<string name="infrared_rename_dialog_title">Rename Key</string>
|
||||
<string name="infrared_rename_dialog_input_label">New Display Name</string>
|
||||
<string name="infrared_delete_dialog_title">Delete Key</string>
|
||||
<string name="infrared_delete_dialog_message">Only removes the key locally from this app. ABox server may retain the learned key.</string>
|
||||
<string name="infrared_dialog_cancel">Cancel</string>
|
||||
<string name="infrared_dialog_confirm">Confirm</string>
|
||||
<string name="infrared_back">Back</string>
|
||||
<string name="infrared_remote_title">Infrared Remote</string>
|
||||
<string name="infrared_remote_recording">Recording</string>
|
||||
<string name="infrared_hub_my_remotes">My Remotes</string>
|
||||
<string name="infrared_remote_learning_mode">Learn Mode</string>
|
||||
<string name="infrared_remote_learning_banner">🔴 Learn mode active: tap any button to record</string>
|
||||
<string name="infrared_remote_scene_ac">❄️ Air Conditioner</string>
|
||||
<string name="infrared_remote_scene_tv">📺 Television</string>
|
||||
<string name="infrared_remote_settings">Settings</string>
|
||||
|
||||
<string name="infrared_msg_learning_mode_enabled">🔴 Learn mode active: tap any button to record</string>
|
||||
<string name="infrared_msg_recording_key">Recording [%1$s] (Slot %2$s), press remote button…</string>
|
||||
<string name="infrared_msg_learned_success">[%1$s] recorded successfully</string>
|
||||
<string name="infrared_msg_key_not_learned">Button [%1$s] is not learned. Enable learn mode to record.</string>
|
||||
<string name="infrared_msg_signal_sent">Sent: %1$s</string>
|
||||
<string name="infrared_msg_device_offline">Infrared controller offline (code: %1$s)</string>
|
||||
<string name="infrared_msg_load_failed">Failed to load data</string>
|
||||
<string name="infrared_msg_operation_failed">Operation failed</string>
|
||||
<string name="infrared_label_temp_control">Temp Control</string>
|
||||
<string name="infrared_label_vol">VOL</string>
|
||||
<string name="infrared_label_ch">CH</string>
|
||||
<string name="infrared_label_cool_mode">❄️ Cool Mode</string>
|
||||
<string name="infrared_label_ac_status">💨 Auto Fan | ↔ Swing</string>
|
||||
|
||||
<string name="infrared_key_ac_power">AC Power</string>
|
||||
<string name="infrared_key_ac_temp_up">AC Temp +</string>
|
||||
<string name="infrared_key_ac_temp_down">AC Temp -</string>
|
||||
<string name="infrared_key_ac_mode">AC Mode</string>
|
||||
<string name="infrared_key_ac_fan">AC Fan Speed</string>
|
||||
<string name="infrared_key_ac_swing">AC Swing</string>
|
||||
<string name="infrared_key_ac_timer">AC Timer</string>
|
||||
|
||||
<string name="infrared_key_tv_power">TV Power</string>
|
||||
<string name="infrared_key_tv_mute">TV Mute</string>
|
||||
<string name="infrared_key_tv_nav_up">TV Up</string>
|
||||
<string name="infrared_key_tv_nav_down">TV Down</string>
|
||||
<string name="infrared_key_tv_nav_left">TV Left</string>
|
||||
<string name="infrared_key_tv_nav_right">TV Right</string>
|
||||
<string name="infrared_key_tv_ok">TV OK</string>
|
||||
<string name="infrared_key_tv_vol_up">TV Vol +</string>
|
||||
<string name="infrared_key_tv_vol_down">TV Vol -</string>
|
||||
<string name="infrared_key_tv_ch_up">TV CH +</string>
|
||||
<string name="infrared_key_tv_ch_down">TV CH -</string>
|
||||
<string name="infrared_key_tv_home">TV Home</string>
|
||||
<string name="infrared_key_tv_back">TV Back</string>
|
||||
<string name="infrared_key_tv_menu">TV Menu</string>
|
||||
<string name="infrared_control_title">Device Configuration</string>
|
||||
<string name="infrared_device_config_section">ABSDK Device Name</string>
|
||||
<string name="infrared_device_config_desc">ABSDK matches hardware devices by device name (default: 红外控制器). Commands will be dispatched to this target device.</string>
|
||||
<string name="infrared_device_name_current">Current Bound Device: %1$s</string>
|
||||
<string name="infrared_device_save_changes">Save Changes</string>
|
||||
<string name="infrared_device_stats_section">Hardware Slot Budget</string>
|
||||
<string name="infrared_mode_label">Environment: %1$s</string>
|
||||
<string name="infrared_mode_local_simulation">Local Experience (Simulation)</string>
|
||||
<string name="infrared_mode_abox_real">ABox (Real Hardware)</string>
|
||||
<string name="infrared_msg_device_name_saved">Device name saved successfully</string>
|
||||
<string name="infrared_msg_device_name_blank">Device name cannot be blank</string>
|
||||
<string name="infrared_msg_key_name_blank">Key name cannot be blank</string>
|
||||
<string name="infrared_msg_slot_invalid">Key slot must be between 1 and 100</string>
|
||||
<string name="infrared_msg_key_renamed">Key renamed</string>
|
||||
<string name="infrared_msg_key_deleted">Key removed locally</string>
|
||||
<string name="infrared_msg_key_not_ready">Key is not ready to send</string>
|
||||
<string name="infrared_hub_title">Infrared Hub</string>
|
||||
<string name="infrared_hub_slot_quota">Hardware Slots: %1$d / %2$d allocated (%3$d free)</string>
|
||||
<string name="infrared_hub_static_quota">Static: %1$d</string>
|
||||
<string name="infrared_hub_dynamic_scratch">Dynamic Buffer: Slot %1$s</string>
|
||||
<string name="infrared_hub_remotes_title">Virtual Remotes</string>
|
||||
<string name="infrared_hub_no_remotes">No virtual remotes added yet. Tap + to add one.</string>
|
||||
<string name="infrared_hub_add_remote">Add Remote</string>
|
||||
<string name="infrared_hub_mode_static">🚀 Static Mode (%1$s slots)</string>
|
||||
<string name="infrared_hub_mode_dynamic">📦 Dynamic Mode (Scratch Slot %1$s)</string>
|
||||
<string name="infrared_hub_create_dialog_title">Add Virtual Remote</string>
|
||||
<string name="infrared_hub_device_type_label">Device Type</string>
|
||||
<string name="infrared_hub_brand_label">Brand / Preset Library</string>
|
||||
<string name="infrared_hub_trans_mode_label">Transmission Mode</string>
|
||||
<string name="infrared_hub_trans_mode_static_desc">Low latency (~20ms), instant response, occupies ~15 slots</string>
|
||||
<string name="infrared_hub_trans_mode_dynamic_desc">Saves slots (0 extra slots), writes code on-the-fly (~80ms)</string>
|
||||
<string name="infrared_hub_quota_insufficient">Slot quota exceeded. Please choose Dynamic mode.</string>
|
||||
<string name="infrared_hub_raw_keys_button">Hardware Slot / Raw Key Management</string>
|
||||
<string name="infrared_hub_device_type_tv">📺 Television (TV)</string>
|
||||
<string name="infrared_hub_device_type_ac">❄️ Air Conditioner (AC)</string>
|
||||
<string name="infrared_hub_device_type_fan">🌀 Fan / Air Purifier</string>
|
||||
<string name="infrared_hub_device_type_custom">🎛️ Custom Panel</string>
|
||||
</resources>
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package com.flagship.abox.manager.infrared.application
|
||||
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyRef
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RelearnInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class InfraredLearningCoordinatorTest {
|
||||
|
||||
private val profile = ProviderProfileId("profile1")
|
||||
private val device = DeviceName("ir-1")
|
||||
private val clock = EpochMillisClock { 1_000L }
|
||||
private val repository = FakeInfraredKeyRepository()
|
||||
private val gateway = FakeInfraredGateway()
|
||||
private val factory = InfraredKeyFactory({ InfraredKeyId("key1") }, clock)
|
||||
|
||||
private val learn = LearnInfraredKey(repository, gateway, factory, clock)
|
||||
private val retry = RetryInfraredKeyLearning(repository, gateway, clock)
|
||||
private val relearn = RelearnInfraredKey(repository, gateway, clock)
|
||||
|
||||
@Test
|
||||
fun learnRunsAsynchronouslyInExternalScope() = runTest {
|
||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
||||
|
||||
coordinator.learn(profile, device, "Power")
|
||||
advanceUntilIdle()
|
||||
|
||||
val keysResult = repository.observeKeys(profile, device).first()
|
||||
assertTrue(keysResult is DomainResult.Success)
|
||||
val keys = (keysResult as DomainResult.Success).value
|
||||
assertEquals(1, keys.size)
|
||||
assertEquals(InfraredKeyStatus.READY, keys.single().status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun retryRunsAsynchronouslyInExternalScope() = runTest {
|
||||
val failedKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("key1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "Power",
|
||||
sdkKey = "app_ir_key1",
|
||||
status = InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = "20206",
|
||||
lastErrorDetail = "offline",
|
||||
createdAtEpochMillis = 500L,
|
||||
updatedAtEpochMillis = 500L,
|
||||
)
|
||||
repository.insert(failedKey)
|
||||
|
||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
||||
|
||||
coordinator.retry(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
||||
advanceUntilIdle()
|
||||
|
||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relearnRunsAsynchronouslyInExternalScope() = runTest {
|
||||
val readyKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("key1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "Power",
|
||||
sdkKey = "app_ir_key1",
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 500L,
|
||||
updatedAtEpochMillis = 500L,
|
||||
)
|
||||
repository.insert(readyKey)
|
||||
|
||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
||||
|
||||
coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
||||
advanceUntilIdle()
|
||||
|
||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failInterruptedLearningFailsLearningKeys() = runTest {
|
||||
val learningKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("key1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "Power",
|
||||
sdkKey = "app_ir_key1",
|
||||
status = InfraredKeyStatus.LEARNING,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 500L,
|
||||
updatedAtEpochMillis = 500L,
|
||||
)
|
||||
repository.insert(learningKey)
|
||||
|
||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
||||
|
||||
coordinator.failInterruptedLearning(profile, 2_000L)
|
||||
advanceUntilIdle()
|
||||
|
||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.FAILED, updated.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subsequentLearnWaitsForCleanupCompletion() = runTest {
|
||||
val learningKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("key1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "Power",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 500L,
|
||||
updatedAtEpochMillis = 500L,
|
||||
)
|
||||
repository.insert(learningKey)
|
||||
|
||||
val coordinator = InfraredLearningCoordinator(learn, retry, relearn, repository, this)
|
||||
|
||||
coordinator.failInterruptedLearning(profile, cutoffEpochMillis = 1000L)
|
||||
val relearnJob = coordinator.relearn(InfraredKeyRef(profile, InfraredKeyId("key1")))
|
||||
advanceUntilIdle()
|
||||
|
||||
val relearnResult = relearnJob.await()
|
||||
assertTrue(relearnResult is DomainResult.Success)
|
||||
val updated = (repository.getKey(InfraredKeyRef(profile, InfraredKeyId("key1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
||||
}
|
||||
}
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package com.flagship.abox.manager.infrared.hub
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class InfraredHubViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private val profileId = ProviderProfileId("profile1")
|
||||
private val providerProfile = ProviderProfile(profileId, ProviderType.LOCAL_EXPERIENCE, "Local Simulation")
|
||||
private val device = DeviceName("ir-1")
|
||||
private val clock = EpochMillisClock { 10_000L }
|
||||
|
||||
private val profileStore = FakeProviderProfileStore()
|
||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||
private val remoteRepo = FakeVirtualRemoteRepository()
|
||||
private val gateway = FakeInfraredGateway()
|
||||
|
||||
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
|
||||
private val deleteRemote = DeleteVirtualRemote(remoteRepo)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(): InfraredHubViewModel = InfraredHubViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
||||
providerProfileStore = profileStore,
|
||||
deviceConfigurationRepository = deviceRepo,
|
||||
virtualRemoteRepository = remoteRepo,
|
||||
createVirtualRemote = createRemote,
|
||||
deleteVirtualRemote = deleteRemote,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun initialStateLoadsRemotesAndSlotQuota() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
deviceRepo.setDeviceName(profileId, device)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
||||
assertEquals(device, vm.state.value.savedDeviceName)
|
||||
assertEquals(0, vm.state.value.remotes.size)
|
||||
assertEquals(99, vm.state.value.slotQuota.remainingStaticCount)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createRemoteAddsToRepositoryAndUpdatesState() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
deviceRepo.setDeviceName(profileId, device)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.createRemote(
|
||||
name = "Living Room TV",
|
||||
type = RemoteDeviceType.TV,
|
||||
brandModel = "generic_tv",
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, vm.state.value.remotes.size)
|
||||
assertEquals("Living Room TV", vm.state.value.remotes.first().displayName)
|
||||
assertEquals(TransmissionMode.STATIC_SLOTS, vm.state.value.remotes.first().transmissionMode)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteRemoteRemovesFromRepository() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
deviceRepo.setDeviceName(profileId, device)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.createRemote(
|
||||
name = "Fan",
|
||||
type = RemoteDeviceType.FAN,
|
||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
val remoteId = vm.state.value.remotes.first().id
|
||||
vm.deleteRemote(remoteId)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(0, vm.state.value.remotes.size)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createCustomRemoteWithTvLayoutAssignsLayoutAndStaticSlots() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
deviceRepo.setDeviceName(profileId, device)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.createRemote(
|
||||
name = "Custom TV Remote",
|
||||
type = RemoteDeviceType.CUSTOM,
|
||||
brandModel = null,
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||
layoutStyle = com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV,
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, vm.state.value.remotes.size)
|
||||
val created = vm.state.value.remotes.first()
|
||||
assertEquals("Custom TV Remote", created.displayName)
|
||||
assertEquals(com.flagship.abox.manager.domain.infrared.PanelLayoutStyle.TV_NAV, created.layoutStyle)
|
||||
assertEquals(TransmissionMode.STATIC_SLOTS, created.transmissionMode)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package com.flagship.abox.manager.infrared.panel
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.AcFanSpeed
|
||||
import com.flagship.abox.manager.domain.infrared.AcMode
|
||||
import com.flagship.abox.manager.domain.infrared.AddVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.DeleteVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||
import com.flagship.abox.manager.domain.infrared.LearnVirtualRemoteKey
|
||||
import com.flagship.abox.manager.domain.infrared.PanelLayoutStyle
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
|
||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||
import com.flagship.abox.manager.domain.infrared.UpdateVirtualRemoteLayout
|
||||
import com.flagship.abox.manager.domain.infrared.VirtualRemoteId
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RemotePanelViewModelTest {
|
||||
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private val profileId = ProviderProfileId("profile1")
|
||||
private val device = DeviceName("ir-1")
|
||||
private val clock = EpochMillisClock { 10_000L }
|
||||
|
||||
private val remoteRepo = FakeVirtualRemoteRepository()
|
||||
private val gateway = FakeInfraredGateway()
|
||||
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
|
||||
private val sendAction = SendVirtualRemoteAction(remoteRepo, gateway)
|
||||
private val learnKey = LearnVirtualRemoteKey(remoteRepo, gateway)
|
||||
private val addKey = AddVirtualRemoteKey(remoteRepo)
|
||||
private val deleteKey = DeleteVirtualRemoteKey(remoteRepo)
|
||||
private val updateLayout = UpdateVirtualRemoteLayout(remoteRepo)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(remoteId: String): RemotePanelViewModel = RemotePanelViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1", "remoteId" to remoteId)),
|
||||
virtualRemoteRepository = remoteRepo,
|
||||
sendVirtualRemoteAction = sendAction,
|
||||
learnVirtualRemoteKey = learnKey,
|
||||
addVirtualRemoteKey = addKey,
|
||||
deleteVirtualRemoteKey = deleteKey,
|
||||
updateVirtualRemoteLayout = updateLayout,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun tvButtonTriggersInstantTransmissionInNormalMode() = testScope.runTest {
|
||||
val res = createRemote(
|
||||
profileId = profileId,
|
||||
deviceName = device,
|
||||
displayName = "TV",
|
||||
type = RemoteDeviceType.TV,
|
||||
brandModel = "generic_tv",
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||
)
|
||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
||||
|
||||
val vm = createViewModel(remoteId.value)
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.onButtonClick("TV_POWER")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.sentKeys.size)
|
||||
assertEquals("1", gateway.sentKeys.first().key)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learnModeToggledTriggersLearningWhenButtonIsClicked() = testScope.runTest {
|
||||
val res = createRemote(
|
||||
profileId = profileId,
|
||||
deviceName = device,
|
||||
displayName = "TV",
|
||||
type = RemoteDeviceType.TV,
|
||||
brandModel = "generic_tv",
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||
)
|
||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
||||
|
||||
val vm = createViewModel(remoteId.value)
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
// 1. Toggle learn mode ON
|
||||
vm.toggleLearnMode()
|
||||
runCurrent()
|
||||
assertTrue(vm.state.value.isLearnMode)
|
||||
|
||||
// 2. Click button -> triggers learn instead of send
|
||||
vm.onButtonClick("TV_POWER")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.learnedKeys.size)
|
||||
assertEquals("1", gateway.learnedKeys.first().key)
|
||||
assertEquals(0, gateway.sentKeys.size)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addAndRemoveCustomButtonUpdatesBindings() = testScope.runTest {
|
||||
val res = createRemote(
|
||||
profileId = profileId,
|
||||
deviceName = device,
|
||||
displayName = "Custom",
|
||||
type = RemoteDeviceType.CUSTOM,
|
||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
||||
)
|
||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
||||
|
||||
val vm = createViewModel(remoteId.value)
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
// Add button
|
||||
vm.addCustomButton("Sound Effect")
|
||||
advanceUntilIdle()
|
||||
|
||||
val added = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
|
||||
assertNotNull(added)
|
||||
|
||||
// Delete button
|
||||
vm.deleteButton(added!!.buttonId)
|
||||
advanceUntilIdle()
|
||||
|
||||
val afterDelete = vm.state.value.bindings.find { it.displayName == "Sound Effect" }
|
||||
assertEquals(null, afterDelete)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun switchLayoutUpdatesRemoteLayoutStyle() = testScope.runTest {
|
||||
val res = createRemote(
|
||||
profileId = profileId,
|
||||
deviceName = device,
|
||||
displayName = "Custom Box",
|
||||
type = RemoteDeviceType.CUSTOM,
|
||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
||||
)
|
||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
||||
|
||||
val vm = createViewModel(remoteId.value)
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.switchLayout(PanelLayoutStyle.TV_NAV)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(PanelLayoutStyle.TV_NAV, vm.state.value.remote?.layoutStyle)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acTemperatureAdjustmentTriggersDebouncedTransmissionAndUpdatesState() = testScope.runTest {
|
||||
val res = createRemote(
|
||||
profileId = profileId,
|
||||
deviceName = device,
|
||||
displayName = "AC",
|
||||
type = RemoteDeviceType.AIR_CONDITIONER,
|
||||
brandModel = "gree_ac",
|
||||
transmissionMode = TransmissionMode.DYNAMIC_SCRATCH,
|
||||
)
|
||||
val remoteId = (res as com.flagship.abox.manager.domain.DomainResult.Success).value
|
||||
|
||||
val vm = createViewModel(remoteId.value)
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
// 1. Click +1 temp (optimistic update to 27)
|
||||
vm.adjustAcTemperature(1)
|
||||
runCurrent()
|
||||
assertEquals(27, vm.state.value.acState.temperature)
|
||||
|
||||
// 2. Click +1 temp again within debounce window (optimistic update to 28)
|
||||
advanceTimeBy(100L)
|
||||
vm.adjustAcTemperature(1)
|
||||
runCurrent()
|
||||
assertEquals(28, vm.state.value.acState.temperature)
|
||||
|
||||
// Advance debounce window to fire (300ms)
|
||||
advanceTimeBy(400L)
|
||||
advanceUntilIdle()
|
||||
|
||||
// Only 1 download and 1 transmission fired for final state 28
|
||||
assertEquals(1, gateway.downloadedCodes.size)
|
||||
assertEquals("100", gateway.downloadedCodes.first().key)
|
||||
assertEquals(1, gateway.sentKeys.size)
|
||||
assertEquals("100", gateway.sentKeys.first().key)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
package com.flagship.abox.manager.infrared.remote
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.DomainResult
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyFactory
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyId
|
||||
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
|
||||
import com.flagship.abox.manager.domain.infrared.LearnInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.RetryInfraredKeyLearning
|
||||
import com.flagship.abox.manager.domain.infrared.SavedInfraredKey
|
||||
import com.flagship.abox.manager.domain.infrared.SendInfraredKey
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.application.InfraredLearningCoordinator
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredKeyRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class InfraredRemoteViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private val profile = ProviderProfileId("abox")
|
||||
private val device = DeviceName("ir-living")
|
||||
private val clock = EpochMillisClock { 10_000L }
|
||||
|
||||
private val keyRepo = FakeInfraredKeyRepository()
|
||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||
private val profileStore = com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore()
|
||||
private val gateway = FakeInfraredGateway()
|
||||
private val factory = InfraredKeyFactory({ InfraredKeyId("01J5MOLERABC123") }, clock)
|
||||
|
||||
private val learn = LearnInfraredKey(keyRepo, gateway, factory, clock)
|
||||
private val retry = RetryInfraredKeyLearning(keyRepo, gateway, clock)
|
||||
private val relearn = com.flagship.abox.manager.domain.infrared.RelearnInfraredKey(keyRepo, gateway, clock)
|
||||
private val send = SendInfraredKey(keyRepo, gateway)
|
||||
private lateinit var coordinator: InfraredLearningCoordinator
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
coordinator = InfraredLearningCoordinator(learn, retry, relearn, keyRepo, testScope)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(): InfraredRemoteViewModel {
|
||||
return InfraredRemoteViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "abox")),
|
||||
keyRepository = keyRepo,
|
||||
deviceConfigurationRepository = deviceRepo,
|
||||
providerProfileStore = profileStore,
|
||||
learningCoordinator = coordinator,
|
||||
sendInfraredKey = send,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun controlModeSendsSignalWhenKeyIsReady() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val readyKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "空调-电源",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
keyRepo.insert(readyKey)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.sentKeys.size)
|
||||
assertEquals("1", gateway.sentKeys.first().key)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learningModeTriggersLearnOnButtonClick() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.learnedKeys.size)
|
||||
assertEquals("1", gateway.learnedKeys.first().key)
|
||||
assertEquals(null, vm.state.value.activeLearningSlot)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablingLearningModeClearsActiveLearningSlot() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
vm.toggleLearningMode(false)
|
||||
|
||||
assertEquals(null, vm.state.value.activeLearningSlot)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun duplicateSendButtonClickIsIgnoredWhileSending() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val readyKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "空调-电源",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
keyRepo.insert(readyKey)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.sentKeys.size)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learningExistingKeyRetriesExistingKey() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val failedKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "空调-电源",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.FAILED,
|
||||
lastErrorCode = "20206",
|
||||
lastErrorDetail = "offline",
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
keyRepo.insert(failedKey)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.learnedKeys.size)
|
||||
assertEquals("1", gateway.learnedKeys.first().key)
|
||||
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
||||
assertEquals(
|
||||
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
|
||||
vm.state.value.message,
|
||||
)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learningFailurePropagatesErrorMessage() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.DeviceOffline(externalCode = "20206"))
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
RemoteUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_offline, listOf("20206"))),
|
||||
vm.state.value.message,
|
||||
)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun learningSessionInvalidPropagatesSessionInvalidMessage() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
gateway.learnResult = DomainResult.Failure(com.flagship.abox.manager.domain.DomainError.TokenInvalid(detail = "Session expired"))
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(RemoteUiMessage.SessionInvalid, vm.state.value.message)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disablingLearningModeCleansUpAndFailsInterruptedLearning() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
vm.toggleLearningMode(false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(null, vm.state.value.activeLearningSlot)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relearningExistingReadyKeyOverwritesKey() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val readyKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "空调-电源",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.READY,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
keyRepo.insert(readyKey)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.learnedKeys.size)
|
||||
assertEquals("1", gateway.learnedKeys.first().key)
|
||||
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
||||
assertEquals(
|
||||
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
|
||||
vm.state.value.message,
|
||||
)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relearningKeyInLearningStatusSucceeds() = testScope.runTest {
|
||||
deviceRepo.setDeviceName(profile, device)
|
||||
val learningKey = SavedInfraredKey(
|
||||
id = InfraredKeyId("k1"),
|
||||
profileId = profile,
|
||||
deviceName = device,
|
||||
displayName = "空调-电源",
|
||||
sdkKey = "1",
|
||||
status = InfraredKeyStatus.LEARNING,
|
||||
lastErrorCode = null,
|
||||
lastErrorDetail = null,
|
||||
createdAtEpochMillis = 100L,
|
||||
updatedAtEpochMillis = 100L,
|
||||
)
|
||||
keyRepo.insert(learningKey)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.toggleLearningMode(true)
|
||||
vm.onButtonClick(RemoteSceneConfig.AirConditioner.POWER)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, gateway.learnedKeys.size)
|
||||
assertEquals("1", gateway.learnedKeys.first().key)
|
||||
val updated = (keyRepo.getKey(com.flagship.abox.manager.domain.infrared.InfraredKeyRef(profile, InfraredKeyId("k1"))) as DomainResult.Success).value!!
|
||||
assertEquals(InfraredKeyStatus.READY, updated.status)
|
||||
assertEquals(
|
||||
RemoteUiMessage.Info(UiText.StringResource(R.string.infrared_msg_learned_success, listOf("空调-电源"))),
|
||||
vm.state.value.message,
|
||||
)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configSnapshotStorageErrorPropagatesToUiStateMessage() = testScope.runTest {
|
||||
deviceRepo.observeError = com.flagship.abox.manager.domain.DomainError.Unknown(detail = "Disk failure")
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(RemoteUiMessage.Error(UiText.DynamicString("Disk failure")), vm.state.value.message)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.flagship.abox.manager.infrared.remote
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RemoteModelsTest {
|
||||
@Test
|
||||
fun acSpecsHaveUniqueSlotsBetween1And20() {
|
||||
val slots = RemoteSceneConfig.AirConditioner.buttons.map { it.defaultSlot }
|
||||
assertEquals(slots.size, slots.distinct().size)
|
||||
assertTrue(slots.all { it.toInt() in 1..20 })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tvSpecsHaveUniqueSlotsBetween21And50() {
|
||||
val slots = RemoteSceneConfig.Television.buttons.map { it.defaultSlot }
|
||||
assertEquals(slots.size, slots.distinct().size)
|
||||
assertTrue(slots.all { it.toInt() in 21..50 })
|
||||
}
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package com.flagship.abox.manager.infrared.ui
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.flagship.abox.manager.domain.DeviceName
|
||||
import com.flagship.abox.manager.domain.ProviderProfile
|
||||
import com.flagship.abox.manager.domain.ProviderProfileId
|
||||
import com.flagship.abox.manager.domain.ProviderType
|
||||
import com.flagship.abox.manager.domain.infrared.CreateVirtualRemote
|
||||
import com.flagship.abox.manager.domain.infrared.EpochMillisClock
|
||||
import com.flagship.abox.manager.domain.infrared.RemoteDeviceType
|
||||
import com.flagship.abox.manager.domain.infrared.TransmissionMode
|
||||
import com.flagship.abox.manager.infrared.R
|
||||
import com.flagship.abox.manager.infrared.remote.UiText
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredDeviceConfigurationRepository
|
||||
import com.flagship.abox.manager.testing.fakes.FakeInfraredGateway
|
||||
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
|
||||
import com.flagship.abox.manager.testing.fakes.FakeVirtualRemoteRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class InfraredControlViewModelTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
private val testScope = TestScope(testDispatcher)
|
||||
|
||||
private val profileId = ProviderProfileId("profile1")
|
||||
private val providerProfile = ProviderProfile(
|
||||
id = profileId,
|
||||
type = ProviderType.LOCAL_EXPERIENCE,
|
||||
displayName = "Local Simulation",
|
||||
)
|
||||
private val device = DeviceName("ir-living")
|
||||
private val clock = EpochMillisClock { 10_000L }
|
||||
|
||||
private val deviceRepo = FakeInfraredDeviceConfigurationRepository()
|
||||
private val remoteRepo = FakeVirtualRemoteRepository()
|
||||
private val profileStore = FakeProviderProfileStore()
|
||||
private val gateway = FakeInfraredGateway()
|
||||
private val createRemote = CreateVirtualRemote(remoteRepo, gateway, clock)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun teardown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun createViewModel(): InfraredControlViewModel {
|
||||
return InfraredControlViewModel(
|
||||
savedStateHandle = SavedStateHandle(mapOf("profileId" to "profile1")),
|
||||
deviceConfigurationRepository = deviceRepo,
|
||||
virtualRemoteRepository = remoteRepo,
|
||||
providerProfileStore = profileStore,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun initialStateLoadsEnvironmentAndDeviceNameForProfile() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
deviceRepo.setDeviceName(profileId, device)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(ProviderType.LOCAL_EXPERIENCE, vm.state.value.providerType)
|
||||
assertEquals(device, vm.state.value.savedDeviceName)
|
||||
assertEquals("ir-living", vm.state.value.deviceNameInput)
|
||||
assertEquals(0, vm.state.value.totalRemotesCount)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveDeviceNameUpdatesDeviceConfiguration() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.onDeviceNameChanged("ir-bedroom")
|
||||
vm.saveDeviceName()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(DeviceName("ir-bedroom"), vm.state.value.savedDeviceName)
|
||||
assertEquals(
|
||||
InfraredUiMessage.Info(UiText.StringResource(R.string.infrared_msg_device_name_saved)),
|
||||
vm.state.value.message,
|
||||
)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun blankDeviceNameEmitsErrorMessage() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
vm.onDeviceNameChanged(" ")
|
||||
vm.saveDeviceName()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(
|
||||
InfraredUiMessage.Error(UiText.StringResource(R.string.infrared_msg_device_name_blank)),
|
||||
vm.state.value.message,
|
||||
)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stateReflectsConfiguredRemotesAndQuota() = testScope.runTest {
|
||||
profileStore.saveProfile(providerProfile)
|
||||
deviceRepo.setDeviceName(profileId, device)
|
||||
createRemote(
|
||||
profileId = profileId,
|
||||
deviceName = device,
|
||||
displayName = "Living TV",
|
||||
type = RemoteDeviceType.TV,
|
||||
transmissionMode = TransmissionMode.STATIC_SLOTS,
|
||||
)
|
||||
|
||||
val vm = createViewModel()
|
||||
val job = launch { vm.state.collect {} }
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, vm.state.value.totalRemotesCount)
|
||||
assertEquals(15, vm.state.value.slotQuota.assignedStaticSlots.size)
|
||||
assertEquals(16, vm.state.value.slotQuota.usedCount)
|
||||
|
||||
job.cancel()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user