feat(ui): complete app experience redesign

This commit is contained in:
2026-08-27 11:45:30 +08:00
parent 816606234b
commit 54aa74126f
70 changed files with 4510 additions and 985 deletions
@@ -0,0 +1,55 @@
package com.flagship.abox.manager.ui.devicecenter
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceId
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DeviceCenterScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun configuredDeviceCardRendersWithSaveableGridKey() {
val profileId = ProviderProfileId("profile-1")
val device = DeviceDescriptor(
id = DeviceId("profile-1:socket-1"),
profileId = profileId,
name = DeviceName("socket-1"),
type = DeviceType.SOCKET,
)
val card = DeviceCardUiModel(
key = DeviceCardKey(profileId, device.name),
device = device,
)
composeRule.setContent {
ABoxTheme {
DeviceCenterScreen(
state = DeviceCenterUiState(
profileId = profileId,
cards = listOf(card),
content = DeviceCenterContent.CONTENT,
),
onFilterChanged = {},
onOpenDevice = {},
onConfigureDevice = { _, _ -> },
onQuickAction = {},
)
}
}
composeRule.onNodeWithTag("device_card_profile-1:socket-1").assertIsDisplayed()
}
}
@@ -1,26 +1,24 @@
package com.flagship.abox.manager.ui.preferences
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Text
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -31,60 +29,100 @@ class SettingsScreenTest {
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() {
fun interfaceStyleOpensMenuBeforeChangingRenderer() {
val actions = mutableListOf<SettingsUiAction>()
composeRule.setContent {
ABoxTheme {
SettingsScreen(
state = SettingsUiState(
preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3),
loaded = true,
),
onAction = {},
state = SettingsUiState(preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3), loaded = true),
onAction = actions::add,
)
}
}
composeRule.onNodeWithText("界面设置").assertIsDisplayed()
composeRule.onNodeWithText("渲染器").assertIsDisplayed()
composeRule.onNodeWithText("Material 3").assertIsSelected()
composeRule.onNodeWithText("跟随系统").assertIsSelected()
composeRule.onNodeWithText("动态取色").assertIsDisplayed()
composeRule.onNodeWithText("系统辅助功能").assertIsDisplayed()
composeRule.onNodeWithTag("settings-dynamic-color")
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("settings-blur")
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("settings-haptics")
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
composeRule.onNodeWithTag("settings-style-entry").performClick()
assertTrue(actions.isEmpty())
composeRule.onNodeWithTag("settings-style-menu").assertIsDisplayed()
composeRule.onNodeWithText("Miuix").performClick()
assertEquals(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX), actions.single())
}
@Test
fun switchingRendererKeepsCurrentlyRenderedTestScreenPresent() {
fun materialThemeShowsEveryMaterialOptionInReferenceOrder() {
showTheme(DesignRenderer.MATERIAL3)
assertVerticalOrder(
"settings-material-colors",
"settings-theme-mode",
"settings-color-style",
"settings-color-standard",
"settings-predictive-back",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-liquid-glass").assertCountEquals(0)
}
@Test
fun miuixThemeShowsEveryMiuixOptionInReferenceOrder() {
showTheme(DesignRenderer.MIUIX)
assertVerticalOrder(
"settings-theme-mode",
"settings-miuix-accent",
"settings-blur",
"settings-floating-navigation",
"settings-liquid-glass",
"settings-predictive-back",
"settings-interface-scale",
)
composeRule.onAllNodesWithTag("settings-renderer-selector").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-color-style").assertCountEquals(0)
composeRule.onAllNodesWithTag("settings-color-standard").assertCountEquals(0)
}
@Test
fun themeDetailRemainsOpenWhenRendererChangesFromHomeMenu() {
var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3))
val experienceState = SettingsExperienceState()
composeRule.setContent {
ABoxTheme(preferences = preferences) {
Column {
Text("当前测试内容")
SettingsScreen(
state = SettingsUiState(preferences = preferences, loaded = true),
onAction = { action ->
if (action is SettingsUiAction.SetRenderer) {
preferences = preferences.copy(renderer = action.value)
}
},
modifier = Modifier.weight(1f),
)
}
SettingsExperience(
state = SettingsUiState(preferences = preferences, loaded = true),
experienceState = experienceState,
onAction = { action ->
if (action is SettingsUiAction.SetRenderer) preferences = preferences.copy(renderer = action.value)
},
onExit = {},
)
}
}
composeRule.onNodeWithTag("settings-style-entry").performClick()
composeRule.onNodeWithText("Miuix").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("settings-theme-entry").performClick()
composeRule.onNodeWithText("当前测试内容").assertIsDisplayed()
composeRule.onNodeWithText("Miuix").assertIsSelected()
composeRule.onNodeWithTag("settings-theme-detail").assertIsDisplayed()
composeRule.onNodeWithTag("settings-miuix-accent").assertIsDisplayed()
}
private fun showTheme(renderer: DesignRenderer) {
composeRule.setContent {
val experienceState = remember { SettingsExperienceState("THEME") }
ABoxTheme {
SettingsExperience(
state = SettingsUiState(preferences = UiPreferences(renderer = renderer), loaded = true),
experienceState = experienceState,
onAction = {},
onExit = {},
)
}
}
}
private fun assertVerticalOrder(vararg tags: String) {
val tops = tags.map { tag -> composeRule.onNodeWithTag(tag).getUnclippedBoundsInRoot().top }
assertTrue("Expected controls in reference-image order: ${tags.toList()}", tops.zipWithNext().all { it.first < it.second })
}
}
@@ -0,0 +1,40 @@
package com.flagship.abox.manager.ui.scene
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.width
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SceneChoiceFlowTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun choicesWrapOntoAnotherLineAtNarrowEnglishWidth() {
composeRule.setContent {
ABoxTheme {
SceneChoiceFlow(modifier = Modifier.width(240.dp)) {
listOf("Socket", "Remote", "Delay", "Notify").forEach { label ->
FilterChip(selected = false, onClick = {}, label = { Text(label) })
}
}
}
}
val firstTop = composeRule.onNodeWithText("Socket").getUnclippedBoundsInRoot().top
val lastTop = composeRule.onNodeWithText("Notify").getUnclippedBoundsInRoot().top
assertTrue("The final choice should wrap below the first row", lastTop > firstTop)
}
}
@@ -0,0 +1,41 @@
package com.flagship.abox.manager.ui.socket
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SocketDetailScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun awaitingConfirmationShowsMainControlAndWarning() {
composeRule.setContent {
ABoxTheme {
SocketScreen(
state = SocketUiState(
profileId = ProviderProfileId("p1"),
deviceName = DeviceName("socket-1"),
powerState = SocketPowerState.ON,
operation = SocketOperationState.AwaitingConfirmation(SocketPowerState.OFF),
),
onEvent = {},
onBackClick = {},
)
}
}
composeRule.onNodeWithTag("socket_power_dial").assertIsDisplayed()
composeRule.onNodeWithTag("socket_awaiting_confirmation").assertIsDisplayed()
}
}
@@ -0,0 +1,43 @@
package com.flagship.abox.manager.ui.temperaturehumidity
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfileId
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class TemperatureHumidityDetailScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun partialReadingRendersOnlyAvailableMetric() {
composeRule.setContent {
ABoxTheme {
TemperatureHumidityScreen(
state = TemperatureHumidityUiState(
profileId = ProviderProfileId("p1"),
deviceName = DeviceName("sensor-1"),
temperature = "24.2",
humidity = null,
),
onEvent = {},
onBackClick = {},
)
}
}
composeRule.onNodeWithTag("sensor_metric_temperature").assertIsDisplayed()
composeRule.onAllNodesWithTag("sensor_metric_humidity").assertCountEquals(0)
composeRule.onNodeWithTag("sensor_monitoring_notice").assertIsDisplayed()
}
}
+1
View File
@@ -3,6 +3,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".ABoxManagerApplication"
android:enableOnBackInvokedCallback="true"
android:allowBackup="false"
android:label="@string/app_name"
android:supportsRtl="true"
@@ -7,6 +7,8 @@ import com.flagship.abox.manager.domain.SceneExecutor
import com.flagship.abox.manager.domain.SceneRepository
import com.flagship.abox.manager.domain.SocketGateway
import com.flagship.abox.manager.domain.infrared.SendVirtualRemoteAction
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.ui.scene.RecordingSceneExecutor
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -29,9 +31,13 @@ object SceneIntegrationModule {
socketGateway: SocketGateway,
infraredGateway: InfraredGateway,
sendVirtualRemoteAction: SendVirtualRemoteAction,
): SceneExecutor = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = infraredGateway,
sendVirtualRemoteAction = sendVirtualRemoteAction,
activityWriter: ActivityEventWriter,
): SceneExecutor = RecordingSceneExecutor(
delegate = DefaultSceneExecutor(
socketGateway = socketGateway,
infraredGateway = infraredGateway,
sendVirtualRemoteAction = sendVirtualRemoteAction,
),
writer = activityWriter,
)
}
@@ -11,6 +11,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -25,6 +26,7 @@ import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel
import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
import com.flagship.abox.manager.ui.preferences.SettingsRoute
import com.flagship.abox.manager.ui.preferences.SettingsExperienceState
import com.flagship.abox.manager.ui.preferences.SettingsViewModel
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.session.AppSessionUiState
@@ -45,6 +47,11 @@ fun ABoxManagerApp(
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val actionError = actionState.errorMessage?.asString()
val screenKey = sessionState.screenKey()
var showAccountDialog by rememberSaveable(screenKey) { mutableStateOf(false) }
var showSettings by rememberSaveable(screenKey) { mutableStateOf(false) }
val settingsExperienceState = rememberSaveable(screenKey, saver = SettingsExperienceState.Saver) {
SettingsExperienceState()
}
LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError()
@@ -78,6 +85,11 @@ fun ABoxManagerApp(
profile = state.profile,
onLogout = sessionActionsViewModel::logout,
onSessionAction = sessionActionsViewModel::dispatch,
showAccountDialog = showAccountDialog,
showSettings = showSettings,
settingsExperienceState = settingsExperienceState,
onShowAccountDialogChange = { showAccountDialog = it },
onShowSettingsChange = { showSettings = it },
)
}
}
@@ -90,20 +102,25 @@ private fun AuthenticatedNavHost(
profile: ProviderProfile,
onLogout: () -> Unit,
onSessionAction: (SessionUiAction) -> Unit,
showAccountDialog: Boolean,
showSettings: Boolean,
settingsExperienceState: SettingsExperienceState,
onShowAccountDialogChange: (Boolean) -> Unit,
onShowSettingsChange: (Boolean) -> Unit,
) {
var showAccountDialog by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
if (showSettings) {
BackHandler { showSettings = false }
SettingsRoute()
BackHandler { onShowSettingsChange(false) }
SettingsRoute(
experienceState = settingsExperienceState,
onExit = { onShowSettingsChange(false) },
)
} else {
AuthenticatedNavigation(
username = username,
profile = profile,
onLogout = onLogout,
onProviderClick = { showAccountDialog = true },
onAvatarClick = { showAccountDialog = true },
onProviderClick = { onShowAccountDialogChange(true) },
onAvatarClick = { onShowAccountDialogChange(true) },
)
}
@@ -111,21 +128,21 @@ private fun AuthenticatedNavHost(
AccountProviderDialog(
userName = username.value,
providerName = profile.displayName,
onDismissRequest = { showAccountDialog = false },
onDismissRequest = { onShowAccountDialogChange(false) },
onOpenSettings = {
showAccountDialog = false
showSettings = true
onShowAccountDialogChange(false)
onShowSettingsChange(true)
},
onSwitchProvider = {
showAccountDialog = false
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.SwitchProvider)
},
onEndSession = {
showAccountDialog = false
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.EndSession)
},
onForgetCredentials = {
showAccountDialog = false
onShowAccountDialogChange(false)
onSessionAction(SessionUiAction.ForgetSavedCredentials)
},
)
@@ -0,0 +1,122 @@
package com.flagship.abox.manager.ui.defaults
import android.content.Context
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
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.ProviderType
import com.flagship.abox.manager.domain.infrared.InfraredKeyStatus
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.RemoteKeyBinding
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 dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.first
@Singleton
class ABoxDefaultContentInitializer @Inject constructor(
@ApplicationContext context: Context,
private val profileStore: ProviderProfileStore,
private val deviceRepository: ConfiguredDeviceRepository,
private val remoteRepository: VirtualRemoteRepository,
) {
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
suspend fun initialize(profileId: ProviderProfileId) {
val profile = profileStore.activeProfile()
if (profile?.id != profileId || profile.type != ProviderType.ABOX) return
val marker = "$MARKER_PREFIX${profileId.value}"
if (preferences.getInt(marker, 0) >= CONTENT_VERSION) return
if (!ensureDevices(profileId)) return
if (!ensureCustomRemotes(profileId)) return
preferences.edit().putInt(marker, CONTENT_VERSION).apply()
}
private suspend fun ensureDevices(profileId: ProviderProfileId): Boolean {
val existing = when (val result = deviceRepository.observe(profileId).first()) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return false
}
val defaults = listOf(
DeviceName("Zigbee插座控制器") to DeviceType.SOCKET,
DeviceName("温湿度传感器") to DeviceType.TEMPERATURE_HUMIDITY,
DeviceName("红外控制器") to DeviceType.INFRARED,
)
for ((name, type) in defaults) {
if (existing.any { it.type == type }) continue
if (deviceRepository.add(profileId, name, type) is DomainResult.Failure) return false
}
return true
}
private suspend fun ensureCustomRemotes(profileId: ProviderProfileId): Boolean {
val deviceName = DeviceName("红外控制器")
val existing = when (val result = remoteRepository.observeRemotes(profileId, deviceName).first()) {
is DomainResult.Success -> result.value
is DomainResult.Failure -> return false
}
val now = System.currentTimeMillis()
val remotes = listOf(
customRemote(profileId, deviceName, "custom-tv", "自定义电视遥控器", PanelLayoutStyle.TV_NAV, now),
customRemote(profileId, deviceName, "custom-panel", "自定义面板遥控器", PanelLayoutStyle.GRID, now + 1),
)
for (remote in remotes) {
if (existing.any { it.id == remote.id }) continue
val templates = PresetCodeLibrary.getTemplatesForType(
type = RemoteDeviceType.CUSTOM,
layoutStyle = remote.layoutStyle,
)
val bindings = templates.mapIndexed { index, template ->
RemoteKeyBinding(
id = "${remote.id.value}:${index + 1}",
remoteId = remote.id,
profileId = profileId,
hardwareDeviceName = deviceName,
buttonId = template.buttonId,
displayName = template.displayName,
assignedSlot = null,
rawHexCode = null,
status = InfraredKeyStatus.UNLEARNED,
)
}
if (remoteRepository.createRemote(remote, bindings) is DomainResult.Failure) return false
}
return true
}
private fun customRemote(
profileId: ProviderProfileId,
deviceName: DeviceName,
idSuffix: String,
displayName: String,
layout: PanelLayoutStyle,
createdAt: Long,
) = VirtualRemote(
id = VirtualRemoteId("${profileId.value}:default:$idSuffix"),
profileId = profileId,
deviceName = deviceName,
displayName = displayName,
type = RemoteDeviceType.CUSTOM,
layoutStyle = layout,
transmissionMode = TransmissionMode.STATIC_SLOTS,
createdAtEpochMillis = createdAt,
updatedAtEpochMillis = createdAt,
)
private companion object {
const val PREFERENCES_NAME = "abox_default_content"
const val MARKER_PREFIX = "content_version_"
const val CONTENT_VERSION = 1
}
}
@@ -4,8 +4,13 @@ import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.SocketPowerState
import com.flagship.abox.manager.domain.infrared.SlotQuotaSummary
data class DeviceCardKey(val profileId: ProviderProfileId, val deviceName: DeviceName)
fun DeviceCardKey.saveableKey(): String = "${profileId.value}\u0000${deviceName.value}"
enum class DeviceCategoryFilter { ALL, SOCKET, SENSOR, INFRARED }
data class DeviceCardUiModel(
val key: DeviceCardKey,
@@ -16,7 +21,30 @@ data class DeviceCardUiModel(
DeviceType.TEMPERATURE_HUMIDITY -> "温湿度"
DeviceType.INFRARED -> "红外控制器"
},
val detail: DeviceCardDetail = DeviceCardDetail.Loading,
)
sealed interface DeviceCardDetail {
data object Loading : DeviceCardDetail
data class Socket(
val powerState: SocketPowerState,
val operating: Boolean = false,
val pendingTarget: SocketPowerState? = null,
val lastConfirmedAtMillis: Long? = null,
) : DeviceCardDetail
data class Sensor(
val temperature: String?,
val humidity: String?,
val refreshing: Boolean = false,
val lastUpdatedAtMillis: Long? = null,
) : DeviceCardDetail
data class Infrared(
val remoteNames: List<String>,
val quota: SlotQuotaSummary,
) : DeviceCardDetail
data object Offline : DeviceCardDetail
data object Unavailable : DeviceCardDetail
}
enum class DeviceCenterContent { LOADING, CONTENT, EMPTY, ERROR }
data class DeviceCenterUiState(
val profileId: ProviderProfileId? = null,
@@ -22,6 +22,10 @@ fun DeviceCenterRoute(
onFilterChanged = viewModel::setFilter,
onOpenDevice = { card -> onOpenDevice(card.device.name.value, card.device.type) },
onConfigureDevice = viewModel::addConfiguredDevice,
onQuickAction = { card ->
if (card.device.type == DeviceType.SOCKET) viewModel.toggleSocket(card)
else viewModel.refreshDevice(card)
},
modifier = modifier,
)
}
@@ -6,6 +6,22 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.clickable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.IconButton
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Surface
import androidx.compose.material3.Button
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.OutlinedTextField
@@ -20,14 +36,20 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxCard
import com.flagship.abox.manager.designsystem.ABoxEmptyState
import com.flagship.abox.manager.designsystem.ABoxFilterChip
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.SocketPowerState
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun DeviceCenterScreen(
@@ -35,6 +57,7 @@ fun DeviceCenterScreen(
onFilterChanged: (DeviceCategoryFilter) -> Unit,
onOpenDevice: (DeviceCardUiModel) -> Unit,
onConfigureDevice: (String, DeviceType) -> Unit,
onQuickAction: (DeviceCardUiModel) -> Unit,
modifier: Modifier = Modifier,
) {
var showConfiguration by remember { mutableStateOf(false) }
@@ -42,20 +65,21 @@ fun DeviceCenterScreen(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("设备中心", style = MaterialTheme.typography.headlineSmall)
Text("选择设备以查看状态或打开控制页面", style = MaterialTheme.typography.bodyMedium)
FilterRow(selected = state.filter, onSelected = onFilterChanged)
Column(modifier = Modifier.padding(horizontal = 2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(stringResource(R.string.device_center_heading), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(stringResource(R.string.device_center_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
when (state.content) {
DeviceCenterContent.LOADING -> Text("正在加载设备", modifier = Modifier.testTag("device_center_loading"))
DeviceCenterContent.LOADING -> Text(stringResource(R.string.device_center_loading), modifier = Modifier.testTag("device_center_loading"))
DeviceCenterContent.EMPTY -> ABoxEmptyState(
title = "尚未配置可管理设备",
description = "请先配置设备名称和类型",
title = stringResource(R.string.device_center_empty_title),
description = stringResource(R.string.device_center_empty_description),
modifier = Modifier.testTag("device_center_empty"),
action = {
Button(onClick = { showConfiguration = true }) { Text("配置设备") }
Button(onClick = { showConfiguration = true }) { Text(stringResource(R.string.device_center_configure)) }
},
)
DeviceCenterContent.ERROR -> Text("设备列表加载失败", modifier = Modifier.testTag("device_center_error"))
DeviceCenterContent.ERROR -> Text(stringResource(R.string.device_center_error), modifier = Modifier.testTag("device_center_error"))
DeviceCenterContent.CONTENT -> LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 220.dp),
contentPadding = PaddingValues(bottom = 24.dp),
@@ -63,8 +87,12 @@ fun DeviceCenterScreen(
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.testTag("device_center_grid"),
) {
items(state.cards, key = { it.key }) { card ->
DeviceCard(card = card, onClick = { onOpenDevice(card) })
items(state.cards, key = { it.key.saveableKey() }) { card ->
when (card.device.type) {
DeviceType.SOCKET -> SocketDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) })
DeviceType.TEMPERATURE_HUMIDITY -> SensorDeviceCard(card, { onOpenDevice(card) }, { onQuickAction(card) })
DeviceType.INFRARED -> InfraredDeviceCard(card, { onOpenDevice(card) })
}
}
}
}
@@ -90,13 +118,13 @@ private fun DeviceConfigurationDialog(
var type by remember { mutableStateOf(DeviceType.SOCKET) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("配置设备") },
title = { Text(stringResource(R.string.device_center_configure_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("设备名称") },
label = { Text(stringResource(R.string.device_center_device_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
@@ -118,9 +146,9 @@ private fun DeviceConfigurationDialog(
}
},
confirmButton = {
Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text("保存") }
Button(onClick = { onConfirm(name.trim(), type) }, enabled = name.isNotBlank()) { Text(stringResource(R.string.action_confirm)) }
},
dismissButton = { Button(onClick = onDismiss) { Text("取消") } },
dismissButton = { Button(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
)
}
@@ -150,17 +178,173 @@ private fun FilterChipItem(
}
@Composable
private fun DeviceCard(card: DeviceCardUiModel, onClick: () -> Unit) {
val icon = when (card.device.type) {
DeviceType.SOCKET -> ABoxIconKey.SOCKET
DeviceType.TEMPERATURE_HUMIDITY -> ABoxIconKey.SENSOR
DeviceType.INFRARED -> ABoxIconKey.INFRARED
private fun SocketDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onToggle: () -> Unit) {
val detail = card.detail as? DeviceCardDetail.Socket
val isOffline = card.detail is DeviceCardDetail.Offline
val isAwaitingConfirmation = detail?.pendingTarget != null && !detail.operating
val isOn = detail?.powerState == SocketPowerState.ON
DeviceSurface(card, onOpen, minimumHeight = 0.dp) {
DeviceHeader(
icon = ABoxIconKey.SOCKET,
title = card.displayName,
subtitle = "${stringResource(R.string.socket_title)} · ${when { isOffline -> stringResource(R.string.device_status_offline); detail == null -> stringResource(R.string.device_status_unavailable); detail.operating -> stringResource(R.string.device_status_switching); isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation); isOn -> stringResource(R.string.device_status_on); else -> stringResource(R.string.device_status_off) }}",
action = {
Surface(
modifier = Modifier
.size(48.dp)
.border(
width = if (isOn) 2.dp else 1.dp,
color = if (isOn) MaterialTheme.colorScheme.primary.copy(alpha = 0.45f)
else MaterialTheme.colorScheme.outline,
shape = CircleShape,
),
shape = CircleShape,
color = if (isOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
tonalElevation = if (isOn) 6.dp else 0.dp,
) {
IconButton(
onClick = onToggle,
enabled = !isOffline && detail != null && !detail.operating && !isAwaitingConfirmation,
) {
if (detail?.operating == true) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = if (isOn) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.primary,
)
} else {
ABoxIcon(
ABoxIconKey.POWER,
if (isOn) "关闭插座" else "开启插座",
modifier = Modifier.size(22.dp),
)
}
}
}
},
)
CardFooter(
left = when {
isOffline -> stringResource(R.string.device_status_offline)
isAwaitingConfirmation -> stringResource(R.string.device_status_awaiting_confirmation)
detail?.lastConfirmedAtMillis != null -> stringResource(R.string.device_status_confirmed)
else -> stringResource(R.string.device_status_waiting_first_confirmation)
},
right = stringResource(R.string.device_open_control_panel),
modifier = Modifier.padding(top = 24.dp),
)
}
}
@Composable
private fun SensorDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit, onRefresh: () -> Unit) {
val detail = card.detail as? DeviceCardDetail.Sensor
val isOffline = card.detail is DeviceCardDetail.Offline
DeviceSurface(card, onOpen, minimumHeight = 190.dp) {
DeviceHeader(
icon = ABoxIconKey.SENSOR,
title = card.displayName,
subtitle = if (isOffline) {
"${stringResource(R.string.device_sensor_subtitle)} · ${stringResource(R.string.device_status_offline)}"
} else {
stringResource(R.string.device_sensor_subtitle)
},
action = {
IconButton(onClick = onRefresh, enabled = detail?.refreshing != true) {
if (detail?.refreshing == true) CircularProgressIndicator(Modifier.size(20.dp))
else ABoxIcon(ABoxIconKey.REFRESH, "刷新温湿度")
}
},
)
Row(Modifier.fillMaxWidth().padding(vertical = 22.dp), horizontalArrangement = Arrangement.SpaceEvenly) {
Metric("环境温度", detail?.temperature, "°C", ABoxIconKey.TEMPERATURE)
Surface(Modifier.width(1.dp).height(48.dp), color = MaterialTheme.colorScheme.outlineVariant) {}
Metric("相对湿度", detail?.humidity, "%", ABoxIconKey.HUMIDITY)
}
CardFooter(
if (isOffline) stringResource(R.string.device_status_offline) else stringResource(R.string.device_live_reading),
stringResource(R.string.device_details),
Modifier.padding(top = 4.dp),
)
}
}
@Composable
private fun InfraredDeviceCard(card: DeviceCardUiModel, onOpen: () -> Unit) {
val detail = card.detail as? DeviceCardDetail.Infrared
DeviceSurface(card, onOpen, minimumHeight = 172.dp) {
DeviceHeader(
icon = ABoxIconKey.INFRARED,
title = card.displayName,
subtitle = stringResource(R.string.device_infrared_subtitle, detail?.remoteNames?.size ?: 0),
)
Text(
text = stringResource(R.string.device_infrared_mounted, detail?.remoteNames?.joinToString("、")?.takeIf { it.isNotBlank() } ?: stringResource(R.string.device_infrared_no_remotes)),
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(top = 22.dp, bottom = 18.dp),
)
CardFooter(
left = detail?.quota?.let { stringResource(R.string.device_infrared_slots, it.usedCount, it.totalCapacity) } ?: stringResource(R.string.device_infrared_slots_unavailable),
right = stringResource(R.string.device_infrared_center),
)
}
}
@Composable
private fun DeviceSurface(
card: DeviceCardUiModel,
onOpen: () -> Unit,
minimumHeight: androidx.compose.ui.unit.Dp,
content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().testTag("device_card_${card.device.id.value}"),
shape = RoundedCornerShape(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(
Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = minimumHeight)
.clickable(onClick = onOpen)
.padding(horizontal = 18.dp, vertical = 18.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
content = content,
)
}
}
@Composable
private fun DeviceHeader(icon: ABoxIconKey, title: String, subtitle: String, action: (@Composable () -> Unit)? = null) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Surface(shape = RoundedCornerShape(6.dp), color = MaterialTheme.colorScheme.primaryContainer) {
ABoxIcon(icon, subtitle, Modifier.padding(10.dp).size(24.dp))
}
Column(Modifier.weight(1f).padding(start = 12.dp)) {
Text(title, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
action?.invoke()
}
}
@Composable
private fun Metric(label: String, value: String?, unit: String, icon: ABoxIconKey) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Row(verticalAlignment = Alignment.CenterVertically) {
ABoxIcon(icon, label, Modifier.size(18.dp))
Text(value ?: stringResource(R.string.device_metric_no_data), style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 6.dp))
if (value != null) Text(unit, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 2.dp))
}
Text(label, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun CardFooter(left: String, right: String, modifier: Modifier = Modifier) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(left, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(right, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Bold)
}
ABoxCard(
title = card.displayName,
subtitle = card.typeLabel,
onClick = onClick,
modifier = Modifier.testTag("device_card_${card.device.id.value}"),
leadingContent = { ABoxIcon(icon, contentDescription = card.typeLabel) },
)
}
@@ -5,15 +5,26 @@ import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DeviceCatalog
import com.flagship.abox.manager.domain.DeviceDescriptor
import com.flagship.abox.manager.domain.DeviceType
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.domain.TemperatureHumidityGateway
import com.flagship.abox.manager.domain.infrared.VirtualRemoteRepository
import com.flagship.abox.manager.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.ui.defaults.ABoxDefaultContentInitializer
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@@ -21,6 +32,11 @@ import javax.inject.Inject
class DeviceCenterViewModel @Inject constructor(
private val catalog: DeviceCatalog,
private val configuredRepository: ConfiguredDeviceRepository,
private val socketGateway: SocketGateway,
private val temperatureHumidityGateway: TemperatureHumidityGateway,
private val virtualRemoteRepository: VirtualRemoteRepository,
private val defaultContentInitializer: ABoxDefaultContentInitializer,
private val activityWriter: ActivityEventWriter,
) : ViewModel() {
private val _uiState = MutableStateFlow(DeviceCenterUiState())
val uiState: StateFlow<DeviceCenterUiState> = _uiState.asStateFlow()
@@ -33,11 +49,13 @@ class DeviceCenterViewModel @Inject constructor(
allCards = emptyList()
_uiState.value = DeviceCenterUiState(profileId = profileId)
catalogJob = viewModelScope.launch {
defaultContentInitializer.initialize(profileId)
catalog.observeDevices(profileId).collectLatest { result ->
when (result) {
is DomainResult.Success -> {
allCards = result.value.map(::toCard)
publishCards()
refreshAllCards()
}
is DomainResult.Failure -> _uiState.value = _uiState.value.copy(content = DeviceCenterContent.ERROR)
}
@@ -64,6 +82,112 @@ class DeviceCenterViewModel @Inject constructor(
viewModelScope.launch { configuredRepository.remove(profileId, device.device.name) }
}
fun refreshDevice(card: DeviceCardUiModel) {
when (card.device.type) {
DeviceType.SOCKET -> refreshSocket(card)
DeviceType.TEMPERATURE_HUMIDITY -> refreshSensor(card)
DeviceType.INFRARED -> refreshInfrared(card)
}
}
fun toggleSocket(card: DeviceCardUiModel) {
val detail = card.detail as? DeviceCardDetail.Socket ?: return
if (detail.operating) return
val target = if (detail.powerState == SocketPowerState.ON) SocketPowerState.OFF else SocketPowerState.ON
updateCard(card.key) { it.copy(detail = detail.copy(operating = true, pendingTarget = target)) }
viewModelScope.launch {
val command = socketGateway.commandPower(card.device.profileId, card.device.name, target)
activityWriter.recordDevice(
card.device.profileId,
card.device.name,
ActivityEventKind.SOCKET_COMMAND,
command.activityOutcome(),
command.activityErrorCode(),
)
if (command is DomainResult.Success) {
repeat(CONFIRMATION_ATTEMPTS) { attempt ->
if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS)
when (val confirmed = socketGateway.getStatus(card.device.profileId, card.device.name)) {
is DomainResult.Success -> if (confirmed.value.powerState == target) {
activityWriter.recordDevice(card.device.profileId, card.device.name, ActivityEventKind.SOCKET_CONFIRMATION, ActivityOutcome.SUCCEEDED)
updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Socket(target, lastConfirmedAtMillis = System.currentTimeMillis()))
}
return@launch
}
is DomainResult.Failure -> Unit
}
}
activityWriter.recordDevice(
card.device.profileId,
card.device.name,
ActivityEventKind.SOCKET_CONFIRMATION,
ActivityOutcome.AWAITING_CONFIRMATION,
)
updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = target)) }
} else {
updateCard(card.key) { it.copy(detail = detail.copy(operating = false, pendingTarget = null)) }
}
}
}
private fun refreshAllCards() = allCards.forEach(::refreshDevice)
private fun refreshSocket(card: DeviceCardUiModel) {
viewModelScope.launch {
when (val result = socketGateway.getStatus(card.device.profileId, card.device.name)) {
is DomainResult.Success -> updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Socket(result.value.powerState, lastConfirmedAtMillis = System.currentTimeMillis()))
}
is DomainResult.Failure -> updateCard(card.key) {
it.copy(detail = if (result.error is DomainError.DeviceOffline) DeviceCardDetail.Offline else DeviceCardDetail.Unavailable)
}
}
}
}
private fun refreshSensor(card: DeviceCardUiModel) {
val previous = card.detail as? DeviceCardDetail.Sensor
updateCard(card.key) { it.copy(detail = previous?.copy(refreshing = true) ?: DeviceCardDetail.Loading) }
viewModelScope.launch {
when (val result = temperatureHumidityGateway.getReading(card.device.profileId, card.device.name)) {
is DomainResult.Success -> updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Sensor(
temperature = result.value.temperature,
humidity = result.value.humidity,
lastUpdatedAtMillis = System.currentTimeMillis(),
))
}
is DomainResult.Failure -> updateCard(card.key) {
it.copy(
detail = if (result.error is DomainError.DeviceOffline) {
DeviceCardDetail.Offline
} else {
previous?.copy(refreshing = false) ?: DeviceCardDetail.Unavailable
},
)
}
}
}
}
private fun refreshInfrared(card: DeviceCardUiModel) {
viewModelScope.launch {
val remotes = virtualRemoteRepository.observeRemotes(card.device.profileId, card.device.name).first()
val quota = virtualRemoteRepository.observeSlotQuota(card.device.profileId, card.device.name).first()
if (remotes is DomainResult.Success && quota is DomainResult.Success) {
updateCard(card.key) {
it.copy(detail = DeviceCardDetail.Infrared(remotes.value.map { remote -> remote.displayName }, quota.value))
}
} else updateCard(card.key) { it.copy(detail = DeviceCardDetail.Unavailable) }
}
}
private fun updateCard(key: DeviceCardKey, transform: (DeviceCardUiModel) -> DeviceCardUiModel) {
allCards = allCards.map { if (it.key == key) transform(it) else it }
publishCards()
}
private fun publishCards() {
val filter = _uiState.value.filter
val cards = allCards.filter { filter.matches(it.device.type) }
@@ -71,8 +195,19 @@ class DeviceCenterViewModel @Inject constructor(
}
private fun toCard(device: DeviceDescriptor) = DeviceCardUiModel(DeviceCardKey(device.profileId, device.name), device)
private companion object {
const val CONFIRMATION_ATTEMPTS = 4
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
}
}
private fun DomainResult<*>.activityOutcome(): ActivityOutcome =
if (this is DomainResult.Success) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED
private fun DomainResult<*>.activityErrorCode(): String? =
(this as? DomainResult.Failure)?.error?.externalCode
private fun DeviceCategoryFilter.matches(type: DeviceType): Boolean = when (this) {
DeviceCategoryFilter.ALL -> true
DeviceCategoryFilter.SOCKET -> type == DeviceType.SOCKET
@@ -2,11 +2,14 @@ package com.flagship.abox.manager.ui.navigation
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.PredictiveBackHandler
import android.os.Build
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -14,11 +17,14 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@@ -54,6 +60,9 @@ import com.flagship.abox.manager.ui.shell.MainAppShell
import com.flagship.abox.manager.ui.shell.MainTab
import com.flagship.abox.manager.ui.shell.detailPresentation
import com.flagship.abox.manager.ui.shell.widthClass
import com.flagship.abox.manager.designsystem.LocalABoxPredictiveBackEnabled
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.collect
import com.flagship.abox.manager.ui.socket.SocketScreen
import com.flagship.abox.manager.ui.socket.SocketUiEvent
import com.flagship.abox.manager.ui.socket.SocketViewModel
@@ -185,35 +194,60 @@ fun AuthenticatedNavigationStateHost(
selectedDevice = selectedDevice,
detailPresentation = selectedPresentation,
)
val canNavigateBack = selectedDevice != null || currentRoute != MainTab.DEVICES.route
val predictiveBackEnabled = LocalABoxPredictiveBackEnabled.current &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
var backProgress by remember { mutableFloatStateOf(0f) }
content(
snapshot,
{ tab ->
savedSelection = null
if (tab == currentTab) {
navController.popBackStack(tab.route, inclusive = false)
} else {
navController.navigate(tab.route) {
launchSingleTop = true
restoreState = true
popUpTo(navController.graph.startDestinationId) {
saveState = true
Box(
modifier = Modifier.fillMaxSize()
.clip(RoundedCornerShape((28f * backProgress).dp))
.graphicsLayer {
val scale = 1f - 0.04f * backProgress
scaleX = scale
scaleY = scale
translationX = 28.dp.toPx() * backProgress
},
) {
content(
snapshot,
{ tab ->
savedSelection = null
if (tab == currentTab) {
navController.popBackStack(tab.route, inclusive = false)
} else {
navController.navigate(tab.route) {
launchSingleTop = true
restoreState = true
popUpTo(navController.graph.startDestinationId) {
saveState = true
}
}
}
}
},
{ deviceName, type ->
savedSelection = DeviceSelection(profileId, deviceName, type)
},
{ savedSelection = null },
)
},
{ deviceName, type ->
savedSelection = DeviceSelection(profileId, deviceName, type)
},
{ savedSelection = null },
)
}
BackHandler(enabled = selectedDevice != null || navController.previousBackStackEntry != null) {
savedSelection = null
if (currentRoute != MainTab.DEVICES.route) {
navController.popBackStack()
PredictiveBackHandler(enabled = canNavigateBack && predictiveBackEnabled) { events ->
try {
events.collect { event -> backProgress = event.progress }
savedSelection = null
navController.popCurrentDestinationUnlessRoot()
} catch (cancelled: CancellationException) {
throw cancelled
} finally {
backProgress = 0f
}
}
BackHandler(enabled = canNavigateBack && !predictiveBackEnabled) {
savedSelection = null
navController.popCurrentDestinationUnlessRoot()
}
}
@Composable
@@ -246,28 +280,22 @@ fun AuthenticatedNavigation(
selectionForRoute = { route -> profile.selectionForRoute(route) },
) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection ->
val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES
MainAppShell(
AuthenticatedGraphContent(
navController = navController,
profile = profile,
username = username,
currentTab = currentTab,
windowSizeClass = resolvedWindowSizeClass,
userName = username.value,
providerName = profile.displayName,
isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
snapshot.currentRoute == MainTab.DEVICES.route,
selectedDevice = snapshot.selectedDevice,
onTabClick = onTabClick,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
content = { paddingValues ->
AuthenticatedGraphContent(
navController = navController,
profile = profile,
paddingValues = paddingValues,
isExpandedDevicePane = snapshot.detailPresentation == DetailPresentation.PANE &&
snapshot.currentRoute == MainTab.DEVICES.route,
selectedDevice = snapshot.selectedDevice,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout,
onOpenSettings = onOpenSettings,
)
},
onLogout = onLogout,
onOpenSettings = onOpenSettings,
)
}
}
@@ -277,48 +305,36 @@ fun AuthenticatedNavigation(
private fun AuthenticatedGraphContent(
navController: NavHostController,
profile: ProviderProfile,
paddingValues: PaddingValues,
username: Username,
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
isExpandedDevicePane: Boolean,
selectedDevice: DeviceSelection?,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
) {
if (isExpandedDevicePane && selectedDevice != null) {
Row(modifier = Modifier.fillMaxSize()) {
AuthenticatedNavHost(
modifier = Modifier.weight(1f),
navController = navController,
profile = profile,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout,
onOpenSettings = onOpenSettings,
)
DeviceDetailPane(
modifier = Modifier.weight(1f),
selection = selectedDevice,
onBack = onClearDeviceSelection,
)
}
} else {
AuthenticatedNavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
profile = profile,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout,
onOpenSettings = onOpenSettings,
)
}
}
AuthenticatedNavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
profile = profile,
username = username,
currentTab = currentTab,
windowSizeClass = windowSizeClass,
isExpandedDevicePane = isExpandedDevicePane,
selectedDevice = selectedDevice,
onTabClick = onTabClick,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
onLogout = onLogout,
onOpenSettings = onOpenSettings,
)
}
@Composable
@@ -326,8 +342,16 @@ private fun AuthenticatedNavHost(
modifier: Modifier,
navController: NavHostController,
profile: ProviderProfile,
username: Username,
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
isExpandedDevicePane: Boolean,
selectedDevice: DeviceSelection?,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
onLogout: () -> Unit,
onOpenSettings: () -> Unit,
) {
@@ -337,21 +361,65 @@ private fun AuthenticatedNavHost(
modifier = modifier,
) {
composable(MainTab.DEVICES.route) {
DeviceCenterRoute(
profileId = profile.id,
onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) },
)
MainAppShell(
currentTab = currentTab,
windowSizeClass = windowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
) { paddingValues ->
Row(Modifier.fillMaxSize().padding(paddingValues)) {
DeviceCenterRoute(
profileId = profile.id,
onOpenDevice = { deviceName, type -> onSelectDevice(DeviceName(deviceName), type) },
modifier = Modifier.weight(1f),
)
if (isExpandedDevicePane && selectedDevice != null) {
DeviceDetailPane(
modifier = Modifier.weight(1f),
selection = selectedDevice,
onBack = onClearDeviceSelection,
)
}
}
}
}
composable(MainTab.SCENES.route) {
SceneListScreen(
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
MainAppShell(
currentTab = currentTab,
windowSizeClass = windowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
) { paddingValues ->
Box(Modifier.fillMaxSize().padding(paddingValues)) {
SceneListScreen(
onAddManual = { navController.navigate(SceneRoutes.EDITOR_MANUAL) },
onAddAutomatic = { navController.navigate(SceneRoutes.EDITOR_AUTOMATIC) },
onEditScene = { sceneId -> navController.navigate(SceneRoutes.editorFor(sceneId)) },
onExecuteScene = { sceneId -> navController.navigate(SceneRoutes.executionFor(sceneId)) },
)
}
}
}
composable(MainTab.ACTIVITY.route) {
ActivityRoute(profileId = profile.id)
MainAppShell(
currentTab = currentTab,
windowSizeClass = windowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
) { paddingValues ->
Box(Modifier.fillMaxSize().padding(paddingValues)) {
ActivityRoute(profileId = profile.id)
}
}
}
composable(Routes.SETTINGS) {
SettingsRoute()
@@ -493,3 +561,9 @@ private fun NavBackStackEntry.actualRoute(): String? {
}
return route
}
private fun NavHostController.popCurrentDestinationUnlessRoot() {
if (currentBackStackEntry?.actualRoute() != MainTab.DEVICES.route) {
popBackStack()
}
}
@@ -1,227 +1,573 @@
package com.flagship.abox.manager.ui.preferences
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.ColumnScope
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.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.Check
import androidx.compose.material.icons.outlined.ChevronRight
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import android.os.Build
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
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 androidx.compose.runtime.saveable.Saver
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxCard
import com.flagship.abox.manager.designsystem.ABoxErrorState
import com.flagship.abox.manager.designsystem.ABoxFilterChip
import com.flagship.abox.manager.designsystem.ABoxPreferenceGroup
import com.flagship.abox.manager.designsystem.ABoxSwitch
import com.flagship.abox.manager.designsystem.LocalABoxShapes
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.InterfaceScale
import com.flagship.abox.manager.preferences.ColorStandard
import com.flagship.abox.manager.preferences.ColorStyle
import com.flagship.abox.manager.preferences.NavigationPresentation
private enum class SettingsDestination { HOME, THEME }
@Stable
class SettingsExperienceState(initialDestination: String = SettingsDestination.HOME.name) {
var destination: String by mutableStateOf(initialDestination)
companion object {
val Saver: Saver<SettingsExperienceState, String> = Saver(
save = { it.destination },
restore = { SettingsExperienceState(it) },
)
}
}
@Composable
fun SettingsRoute(
experienceState: SettingsExperienceState = SettingsExperienceState(),
onExit: () -> Unit = {},
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsScreen(state = state, onAction = viewModel::onAction)
SettingsExperience(state, experienceState, viewModel::onAction, onExit)
}
@Composable
fun SettingsExperience(
state: SettingsUiState,
experienceState: SettingsExperienceState,
onAction: (SettingsUiAction) -> Unit,
onExit: () -> Unit,
) {
val destination = runCatching { SettingsDestination.valueOf(experienceState.destination) }
.getOrDefault(SettingsDestination.HOME)
BackHandler {
if (destination == SettingsDestination.THEME) experienceState.destination = SettingsDestination.HOME.name
else onExit()
}
when (destination) {
SettingsDestination.HOME -> SettingsHomeScreen(
state = state,
onOpenTheme = { experienceState.destination = SettingsDestination.THEME.name },
onBack = onExit,
onAction = onAction,
)
SettingsDestination.THEME -> ThemeSettingsScreen(
state = state,
onBack = { experienceState.destination = SettingsDestination.HOME.name },
onAction = onAction,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
state: SettingsUiState,
onAction: (SettingsUiAction) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = { TopAppBar(title = { Text(stringResource(R.string.settings_title)) }) },
) { contentPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(contentPadding)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
ABoxPreferenceGroup(title = stringResource(R.string.settings_style_section)) {
PreferenceRow(
title = stringResource(R.string.settings_renderer_label),
description = stringResource(R.string.settings_renderer_description),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ABoxFilterChip(
selected = state.preferences.renderer == DesignRenderer.MATERIAL3,
label = stringResource(R.string.settings_renderer_material3),
onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MATERIAL3)) },
)
ABoxFilterChip(
selected = state.preferences.renderer == DesignRenderer.MIUIX,
label = stringResource(R.string.settings_renderer_miuix),
onClick = { onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX)) },
)
}
}
}
ABoxPreferenceGroup(title = stringResource(R.string.settings_theme_section)) {
PreferenceRow(
title = stringResource(R.string.settings_theme_label),
description = null,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ThemeChip(AppThemeMode.SYSTEM, state, onAction, R.string.settings_theme_system)
ThemeChip(AppThemeMode.LIGHT, state, onAction, R.string.settings_theme_light)
ThemeChip(AppThemeMode.DARK, state, onAction, R.string.settings_theme_dark)
}
}
SwitchPreference(
title = stringResource(R.string.settings_dynamic_color),
description = stringResource(R.string.settings_dynamic_color_description),
checked = state.preferences.dynamicColorEnabled,
modifier = Modifier.testTag("settings-dynamic-color"),
onCheckedChange = { onAction(SettingsUiAction.SetDynamicColor(it)) },
)
}
ABoxPreferenceGroup(title = stringResource(R.string.settings_surface_section)) {
SwitchPreference(
title = stringResource(R.string.settings_blur),
description = stringResource(R.string.settings_blur_description),
checked = state.preferences.blurEnabled,
modifier = Modifier.testTag("settings-blur"),
onCheckedChange = { onAction(SettingsUiAction.SetBlur(it)) },
)
PreferenceRow(
title = stringResource(R.string.settings_navigation),
description = null,
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
NavigationChip(
presentation = NavigationPresentation.STANDARD,
selected = state.preferences.navigationPresentation == NavigationPresentation.STANDARD,
label = stringResource(R.string.settings_navigation_standard),
onAction = onAction,
)
NavigationChip(
presentation = NavigationPresentation.FLOATING,
selected = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
label = stringResource(R.string.settings_navigation_floating),
onAction = onAction,
)
}
}
}
ABoxPreferenceGroup(title = stringResource(R.string.settings_feedback_section)) {
SwitchPreference(
title = stringResource(R.string.settings_haptics),
description = stringResource(R.string.settings_haptics_description),
checked = state.preferences.hapticsEnabled,
modifier = Modifier.testTag("settings-haptics"),
onCheckedChange = { onAction(SettingsUiAction.SetHaptics(it)) },
)
}
ABoxPreferenceGroup(title = stringResource(R.string.settings_accessibility_section)) {
ABoxCard(
title = stringResource(R.string.settings_accessibility_card_title),
subtitle = stringResource(R.string.settings_accessibility_description),
)
}
state.error?.let { error ->
ABoxErrorState(
title = stringResource(R.string.settings_error_save),
description = error.asString(),
)
}
}
}
}
) = SettingsHomeScreen(state, onOpenTheme = {}, onBack = {}, onAction = onAction, modifier = modifier)
@Composable
private fun ThemeChip(
mode: AppThemeMode,
private fun SettingsHomeScreen(
state: SettingsUiState,
onOpenTheme: () -> Unit,
onBack: () -> Unit,
onAction: (SettingsUiAction) -> Unit,
labelRes: Int,
modifier: Modifier = Modifier,
) {
ABoxFilterChip(
selected = state.preferences.themeMode == mode,
label = stringResource(labelRes),
onClick = { onAction(SettingsUiAction.SetThemeMode(mode)) },
)
}
@Composable
private fun NavigationChip(
presentation: NavigationPresentation,
selected: Boolean,
label: String,
onAction: (SettingsUiAction) -> Unit,
) {
ABoxFilterChip(
selected = selected,
label = label,
onClick = { onAction(SettingsUiAction.SetNavigation(presentation)) },
)
}
@Composable
private fun PreferenceRow(
title: String,
description: String?,
content: @Composable () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
description?.let {
Text(text = it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
var styleMenuExpanded by remember { mutableStateOf(false) }
SettingsPage(modifier = modifier, title = stringResource(R.string.settings_title), onBack = onBack) {
SettingsGroup {
Box(Modifier.fillMaxWidth()) {
SettingsLinkRow(
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_style_entry),
description = stringResource(R.string.settings_renderer_description),
value = rendererLabel(state.preferences.renderer),
modifier = Modifier.testTag("settings-style-entry"),
) { styleMenuExpanded = true }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
DropdownMenu(
expanded = styleMenuExpanded,
onDismissRequest = { styleMenuExpanded = false },
modifier = Modifier.testTag("settings-style-menu"),
) {
DesignRenderer.entries.forEach { renderer ->
DropdownMenuItem(
text = { Text(rendererLabel(renderer)) },
trailingIcon = {
if (renderer == state.preferences.renderer) Icon(Icons.Outlined.Check, null)
},
onClick = {
styleMenuExpanded = false
onAction(SettingsUiAction.SetRenderer(renderer))
},
)
}
}
}
}
GroupDivider()
SettingsLinkRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_theme_entry),
description = stringResource(R.string.settings_theme_entry_description),
value = null,
modifier = Modifier.testTag("settings-theme-entry"),
onClick = onOpenTheme,
)
}
content()
SettingsGroup {
SettingsSwitchRow(
icon = ABoxIconKey.FEEDBACK,
title = stringResource(R.string.settings_haptics),
description = stringResource(R.string.settings_haptics_description),
checked = state.preferences.hapticsEnabled,
tag = "settings-haptics",
) { onAction(SettingsUiAction.SetHaptics(it)) }
}
SettingsGroup {
SettingsInfoRow(
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_accessibility_card_title),
description = stringResource(R.string.settings_accessibility_description),
)
}
SettingsError(state)
}
}
@Composable
private fun SwitchPreference(
title: String,
description: String,
checked: Boolean,
modifier: Modifier,
onCheckedChange: (Boolean) -> Unit,
private fun ThemeSettingsScreen(
state: SettingsUiState,
onBack: () -> Unit,
onAction: (SettingsUiAction) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
SettingsPage(
title = stringResource(R.string.settings_theme_detail_title),
onBack = onBack,
modifier = Modifier.testTag("settings-theme-detail"),
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
ThemePreview(state)
when (state.preferences.renderer) {
DesignRenderer.MATERIAL3 -> MaterialThemeOptions(state, onAction)
DesignRenderer.MIUIX -> MiuixThemeOptions(state, onAction)
}
ABoxSwitch(
checked = checked,
onCheckedChange = onCheckedChange,
contentDescription = title,
modifier = modifier,
SettingsError(state)
}
}
@Composable
private fun ColumnScope.MaterialThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
SettingsGroup(modifier = Modifier.testTag("settings-material-colors")) {
SettingsSwitchRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_dynamic_color),
description = stringResource(R.string.settings_dynamic_color_description),
checked = state.preferences.dynamicColorEnabled,
tag = "settings-dynamic-color",
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
GroupDivider()
AccentSelector(state, onAction)
}
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
EnumSettingRow(
modifier = Modifier.testTag("settings-color-style"),
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_color_style),
value = colorStyleLabel(state.preferences.colorStyle),
values = ColorStyle.entries,
label = { colorStyleLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStyle(it)) },
)
EnumSettingRow(
modifier = Modifier.testTag("settings-color-standard"),
icon = ABoxIconKey.SETTINGS,
title = stringResource(R.string.settings_color_standard),
value = colorStandardLabel(state.preferences.colorStandard),
values = ColorStandard.entries,
label = { colorStandardLabel(it) },
onSelected = { onAction(SettingsUiAction.SetColorStandard(it)) },
)
PredictiveBackSetting(state, onAction)
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@Composable
private fun ColumnScope.MiuixThemeOptions(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val renderEffectsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
val floatingEnabled = state.preferences.navigationPresentation == NavigationPresentation.FLOATING
ThemeModeSelector(state, onAction, Modifier.testTag("settings-theme-mode"))
SettingsGroup(modifier = Modifier.testTag("settings-miuix-accent")) {
SettingsSwitchRow(
icon = ABoxIconKey.THEME,
title = stringResource(R.string.settings_dynamic_color),
description = stringResource(R.string.settings_dynamic_color_description),
checked = state.preferences.dynamicColorEnabled,
tag = "settings-dynamic-color",
) { onAction(SettingsUiAction.SetDynamicColor(it)) }
GroupDivider()
AccentSelector(state, onAction)
}
SettingsGroup(modifier = Modifier.testTag("settings-blur")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_blur),
description = stringResource(R.string.settings_blur_description),
checked = state.preferences.blurEnabled,
tag = "settings-blur-switch",
enabled = renderEffectsSupported,
unavailableDescription = if (renderEffectsSupported) null else stringResource(R.string.settings_requires_android_13),
) { onAction(SettingsUiAction.SetBlur(it)) }
}
SettingsGroup(modifier = Modifier.testTag("settings-floating-navigation")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_navigation_floating),
description = stringResource(R.string.settings_navigation_floating_description),
checked = state.preferences.navigationPresentation == NavigationPresentation.FLOATING,
tag = "settings-floating-navigation-switch",
) { onAction(SettingsUiAction.SetNavigation(if (it) NavigationPresentation.FLOATING else NavigationPresentation.STANDARD)) }
}
SettingsGroup(modifier = Modifier.testTag("settings-liquid-glass")) {
SettingsSwitchRow(
icon = ABoxIconKey.EFFECTS,
title = stringResource(R.string.settings_liquid_glass),
description = stringResource(R.string.settings_liquid_glass_description),
checked = state.preferences.liquidGlassEnabled,
tag = "settings-liquid-glass-switch",
enabled = renderEffectsSupported && floatingEnabled,
unavailableDescription = when {
!floatingEnabled -> stringResource(R.string.settings_requires_floating_navigation)
!renderEffectsSupported -> stringResource(R.string.settings_requires_android_13)
else -> null
},
) { onAction(SettingsUiAction.SetLiquidGlass(it)) }
}
PredictiveBackSetting(state, onAction)
SettingsGroup(modifier = Modifier.testTag("settings-interface-scale")) { ScaleSelector(state, onAction) }
}
@Composable
private fun PredictiveBackSetting(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
val supported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
SettingsGroup(modifier = Modifier.testTag("settings-predictive-back")) {
SettingsSwitchRow(
icon = ABoxIconKey.BACK,
title = stringResource(R.string.settings_predictive_back),
description = stringResource(R.string.settings_predictive_back_description),
checked = state.preferences.predictiveBackEnabled,
tag = "settings-predictive-back-switch",
enabled = supported,
unavailableDescription = if (supported) null else stringResource(R.string.settings_requires_android_13),
) { onAction(SettingsUiAction.SetPredictiveBack(it)) }
}
}
@Composable
private fun SettingsPage(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(modifier = modifier.fillMaxSize().statusBarsPadding()) {
Row(
modifier = Modifier.fillMaxWidth().height(72.dp).padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Outlined.ArrowBack, stringResource(R.string.action_back)) }
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold)
}
Column(
modifier = Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}
@Composable
private fun SettingsGroup(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
color = MaterialTheme.colorScheme.surfaceContainer,
content = { Column(content = content) },
)
}
@Composable
private fun ThemePreview(state: SettingsUiState) {
Surface(
modifier = Modifier.fillMaxWidth().height(190.dp),
shape = RoundedCornerShape(LocalABoxShapes.current.largeCornerRadius),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Surface(
modifier = Modifier.width(184.dp).height(142.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
) {
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(22.dp).background(MaterialTheme.colorScheme.primary, CircleShape))
Spacer(Modifier.width(8.dp))
Box(Modifier.height(10.dp).weight(1f).background(MaterialTheme.colorScheme.surfaceVariant, CircleShape))
}
Box(Modifier.fillMaxWidth().height(58.dp).background(MaterialTheme.colorScheme.primaryContainer, RoundedCornerShape(14.dp)))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
repeat(3) { Box(Modifier.weight(1f).height(18.dp).background(if (it == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, CircleShape)) }
}
}
}
Text(
text = "${rendererLabel(state.preferences.renderer)} · ${state.preferences.interfaceScale.percent}%",
modifier = Modifier.align(Alignment.BottomCenter).padding(bottom = 8.dp),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun RendererSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
RendererOption(DesignRenderer.MATERIAL3, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-primary"), onAction)
RendererOption(DesignRenderer.MIUIX, state.preferences.renderer, Modifier.weight(1f).testTag("settings-renderer-secondary"), onAction)
}
}
@Composable
private fun RendererOption(value: DesignRenderer, selected: DesignRenderer, modifier: Modifier, onAction: (SettingsUiAction) -> Unit) {
val active = value == selected
Surface(
modifier = modifier.clickable { onAction(SettingsUiAction.SetRenderer(value)) },
shape = RoundedCornerShape(18.dp),
color = if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
border = if (active) androidx.compose.foundation.BorderStroke(2.dp, MaterialTheme.colorScheme.primary) else null,
) {
Row(Modifier.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) {
Text(rendererLabel(value), modifier = Modifier.weight(1f), fontWeight = FontWeight.SemiBold)
if (active) Icon(Icons.Outlined.Check, null, tint = MaterialTheme.colorScheme.primary)
}
}
}
@Composable
private fun ThemeModeSelector(
state: SettingsUiState,
onAction: (SettingsUiAction) -> Unit,
modifier: Modifier = Modifier,
) {
Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
AppThemeMode.entries.forEach { mode ->
val selected = state.preferences.themeMode == mode
Surface(
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetThemeMode(mode)) },
shape = RoundedCornerShape(14.dp),
color = if (selected) MaterialTheme.colorScheme.primaryContainer else Color.Transparent,
) {
Text(themeLabel(mode), Modifier.padding(vertical = 14.dp), textAlign = TextAlign.Center, fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal)
}
}
}
}
@Composable
private fun <T> EnumSettingRow(
modifier: Modifier,
icon: ABoxIconKey,
title: String,
value: String,
values: List<T>,
label: @Composable (T) -> String,
onSelected: (T) -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
SettingsGroup(modifier = modifier) {
Box(Modifier.fillMaxWidth()) {
SettingsLinkRow(
icon = icon,
title = title,
description = stringResource(R.string.settings_tap_to_choose),
value = value,
onClick = { expanded = true },
)
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
values.forEach { option ->
val optionLabel = label(option)
DropdownMenuItem(
text = { Text(optionLabel) },
trailingIcon = { if (optionLabel == value) Icon(Icons.Outlined.Check, null) },
onClick = {
expanded = false
onSelected(option)
},
)
}
}
}
}
}
@Composable
private fun AccentSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.settings_accent_label), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Row(
modifier = Modifier.fillMaxWidth().alpha(if (state.preferences.dynamicColorEnabled) 0.42f else 1f),
horizontalArrangement = Arrangement.SpaceBetween,
) {
AccentPreference.entries.forEach { accent ->
val color = accentColor(accent)
Box(
modifier = Modifier.size(44.dp).background(color, CircleShape)
.then(if (state.preferences.accentPreference == accent) Modifier.border(3.dp, MaterialTheme.colorScheme.onSurface, CircleShape) else Modifier)
.clickable(enabled = !state.preferences.dynamicColorEnabled) { onAction(SettingsUiAction.SetAccent(accent)) },
contentAlignment = Alignment.Center,
) {
if (state.preferences.accentPreference == accent) Icon(Icons.Outlined.Check, null, tint = Color.White)
}
}
}
}
}
@Composable
private fun ScaleSelector(state: SettingsUiState, onAction: (SettingsUiAction) -> Unit) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
SettingsRowHeader(ABoxIconKey.SCALE, stringResource(R.string.settings_scale), stringResource(R.string.settings_scale_description))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
InterfaceScale.entries.forEach { scale ->
val selected = state.preferences.interfaceScale == scale
Surface(
modifier = Modifier.weight(1f).clickable { onAction(SettingsUiAction.SetInterfaceScale(scale)) },
shape = RoundedCornerShape(14.dp),
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surface,
contentColor = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface,
) { Text("${scale.percent}%", Modifier.padding(vertical = 12.dp), textAlign = TextAlign.Center, fontWeight = FontWeight.Bold) }
}
}
}
}
@Composable
private fun SettingsLinkRow(icon: ABoxIconKey, title: String, description: String, value: String?, modifier: Modifier = Modifier, onClick: () -> Unit) {
Row(modifier.fillMaxWidth().clickable(onClick = onClick).padding(14.dp), verticalAlignment = Alignment.CenterVertically) {
SettingsRowHeader(icon, title, description, Modifier.weight(1f))
if (value != null) Text(value, color = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(Icons.Outlined.ChevronRight, null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
@Composable
private fun SettingsSwitchRow(
icon: ABoxIconKey,
title: String,
description: String,
checked: Boolean,
tag: String,
enabled: Boolean = true,
unavailableDescription: String? = null,
onChange: (Boolean) -> Unit,
) {
Row(
Modifier.fillMaxWidth().clickable(enabled = enabled) { onChange(!checked) }.padding(14.dp).alpha(if (enabled) 1f else 0.5f),
verticalAlignment = Alignment.CenterVertically,
) {
SettingsRowHeader(icon, title, unavailableDescription ?: description, Modifier.weight(1f))
Switch(checked, onCheckedChange = onChange, enabled = enabled, modifier = Modifier.testTag(tag))
}
}
@Composable
private fun SettingsInfoRow(icon: ABoxIconKey, title: String, description: String) {
SettingsRowHeader(icon, title, description, Modifier.padding(16.dp))
}
@Composable
private fun SettingsRowHeader(icon: ABoxIconKey, title: String, description: String, modifier: Modifier = Modifier) {
Row(modifier, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp)) {
ABoxIcon(icon, title, Modifier.size(28.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable private fun GroupDivider() = Box(Modifier.fillMaxWidth().padding(start = 58.dp).height(1.dp).background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)))
@Composable private fun SettingsError(state: SettingsUiState) { state.error?.let { ABoxErrorState(stringResource(R.string.settings_error_save), it.asString()) } }
@Composable private fun rendererLabel(renderer: DesignRenderer) = stringResource(if (renderer == DesignRenderer.MATERIAL3) R.string.settings_renderer_primary else R.string.settings_renderer_secondary)
@Composable private fun themeLabel(mode: AppThemeMode) = stringResource(when (mode) { AppThemeMode.SYSTEM -> R.string.settings_theme_system; AppThemeMode.LIGHT -> R.string.settings_theme_light; AppThemeMode.DARK -> R.string.settings_theme_dark })
@Composable private fun colorStyleLabel(value: ColorStyle) = stringResource(when (value) { ColorStyle.TONAL_SPOT -> R.string.settings_color_style_tonal; ColorStyle.EXPRESSIVE -> R.string.settings_color_style_expressive; ColorStyle.FIDELITY -> R.string.settings_color_style_fidelity })
@Composable private fun colorStandardLabel(value: ColorStandard) = stringResource(when (value) { ColorStandard.SPEC_2021 -> R.string.settings_color_standard_2021; ColorStandard.SPEC_2025 -> R.string.settings_color_standard_2025 })
private fun accentColor(accent: AccentPreference) = when (accent) { AccentPreference.BLUE -> Color(0xFF4F6FA9); AccentPreference.GREEN -> Color(0xFF2F7D57); AccentPreference.AMBER -> Color(0xFFA66700); AccentPreference.ROSE -> Color(0xFFA94C68); AccentPreference.VIOLET -> Color(0xFF7659A8) }
@@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.R
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.InterfaceScale
import com.flagship.abox.manager.preferences.ColorStandard
import com.flagship.abox.manager.preferences.ColorStyle
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.PreferenceField
import com.flagship.abox.manager.preferences.UiPreferences
@@ -62,6 +66,30 @@ class SettingsViewModel @Inject constructor(
field = PreferenceField.HAPTICS,
transform = { it.copy(hapticsEnabled = action.value) },
)
is SettingsUiAction.SetInterfaceScale -> update(
field = PreferenceField.INTERFACE_SCALE,
transform = { it.copy(interfaceScale = action.value) },
)
is SettingsUiAction.SetAccent -> update(
field = PreferenceField.ACCENT,
transform = { it.copy(accentPreference = action.value) },
)
is SettingsUiAction.SetColorStyle -> update(
field = PreferenceField.COLOR_STYLE,
transform = { it.copy(colorStyle = action.value) },
)
is SettingsUiAction.SetColorStandard -> update(
field = PreferenceField.COLOR_STANDARD,
transform = { it.copy(colorStandard = action.value) },
)
is SettingsUiAction.SetLiquidGlass -> update(
field = PreferenceField.LIQUID_GLASS,
transform = { it.copy(liquidGlassEnabled = action.value) },
)
is SettingsUiAction.SetPredictiveBack -> update(
field = PreferenceField.PREDICTIVE_BACK,
transform = { it.copy(predictiveBackEnabled = action.value) },
)
SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null)
}
}
@@ -118,5 +146,11 @@ sealed interface SettingsUiAction {
data class SetBlur(val value: Boolean) : SettingsUiAction
data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction
data class SetHaptics(val value: Boolean) : SettingsUiAction
data class SetInterfaceScale(val value: InterfaceScale) : SettingsUiAction
data class SetAccent(val value: AccentPreference) : SettingsUiAction
data class SetColorStyle(val value: ColorStyle) : SettingsUiAction
data class SetColorStandard(val value: ColorStandard) : SettingsUiAction
data class SetLiquidGlass(val value: Boolean) : SettingsUiAction
data class SetPredictiveBack(val value: Boolean) : SettingsUiAction
data object DismissError : SettingsUiAction
}
@@ -0,0 +1,94 @@
package com.flagship.abox.manager.ui.scene
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import com.flagship.abox.manager.domain.ActivitySource
import com.flagship.abox.manager.domain.ActivityTarget
import com.flagship.abox.manager.domain.ActivityTargetType
import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.Scene
import com.flagship.abox.manager.domain.SceneAction
import com.flagship.abox.manager.domain.SceneActionExecutionResult
import com.flagship.abox.manager.domain.SceneActionOutcome
import com.flagship.abox.manager.domain.SceneExecutionId
import com.flagship.abox.manager.domain.SceneExecutionResult
import com.flagship.abox.manager.domain.SceneExecutionSource
import com.flagship.abox.manager.domain.SceneExecutionUpdate
import com.flagship.abox.manager.domain.SceneExecutor
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onEach
class RecordingSceneExecutor(
private val delegate: SceneExecutor,
private val writer: ActivityEventWriter,
) : SceneExecutor {
override suspend fun execute(scene: Scene): DomainResult<SceneExecutionResult> {
val result = delegate.execute(scene)
if (scene.enableLogging) {
when (result) {
is DomainResult.Success -> {
result.value.results.forEach { recordStep(scene, it, ActivitySource.USER) }
recordScene(scene, if (result.value.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED, ActivitySource.USER)
}
is DomainResult.Failure -> recordScene(scene, ActivityOutcome.FAILED, ActivitySource.USER, result.error.externalCode)
}
}
return result
}
override fun execute(
scene: Scene,
executionId: SceneExecutionId,
source: SceneExecutionSource,
): Flow<SceneExecutionUpdate> {
val activitySource = if (source == SceneExecutionSource.AUTOMATION) ActivitySource.AUTOMATION else ActivitySource.USER
return delegate.execute(scene, executionId, source).onEach { update ->
if (!scene.enableLogging) return@onEach
when (update) {
is SceneExecutionUpdate.StepFinished -> recordStep(scene, update.result, activitySource)
is SceneExecutionUpdate.Completed -> recordScene(
scene,
if (update.result.failedCount == 0) ActivityOutcome.SUCCEEDED else ActivityOutcome.FAILED,
activitySource,
)
is SceneExecutionUpdate.Rejected -> recordScene(scene, ActivityOutcome.FAILED, activitySource, update.error.externalCode)
else -> Unit
}
}
}
private fun recordScene(scene: Scene, outcome: ActivityOutcome, source: ActivitySource, errorCode: String? = null) {
writer.record(
scene.profileId,
ActivityEventKind.SCENE_EXECUTION,
source,
outcome,
ActivityTarget(ActivityTargetType.SCENE, scene.id.value, scene.name),
errorCode,
)
}
private fun recordStep(scene: Scene, result: SceneActionExecutionResult, source: ActivitySource) {
writer.record(
scene.profileId,
ActivityEventKind.SCENE_ACTION,
source,
when (result.outcome) {
SceneActionOutcome.SUCCESS -> ActivityOutcome.SUCCEEDED
SceneActionOutcome.FAILURE -> ActivityOutcome.FAILED
SceneActionOutcome.SKIPPED -> ActivityOutcome.CANCELLED
},
result.action.activityTarget(),
result.error?.externalCode,
)
}
}
private fun SceneAction.activityTarget(): ActivityTarget = when (this) {
is SceneAction.SocketPower -> ActivityTarget(ActivityTargetType.DEVICE, deviceName.value, deviceName.value)
is SceneAction.InfraredKey -> ActivityTarget(ActivityTargetType.INFRARED_KEY, key, deviceName.value)
is SceneAction.VirtualRemoteButton -> ActivityTarget(ActivityTargetType.REMOTE, remoteId, "$remoteDisplayName · $buttonDisplayName")
is SceneAction.Delay -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, "Delay ${durationSeconds}s")
is SceneAction.Notification -> ActivityTarget(ActivityTargetType.SCENE_ACTION, id.value, title)
}
@@ -6,6 +6,9 @@ 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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.FlowRowScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -504,7 +507,7 @@ private fun SceneEditorContent(
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(stringResource(R.string.scene_concurrency_mode), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SceneChoiceFlow {
FilterChip(
selected = state.concurrencyMode == ConcurrencyMode.SEQUENTIAL,
onClick = { viewModel.updateConcurrencyMode(ConcurrencyMode.SEQUENTIAL) },
@@ -520,7 +523,7 @@ private fun SceneEditorContent(
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(stringResource(R.string.scene_error_policy), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SceneChoiceFlow {
FilterChip(
selected = state.strategy == ExecutionStrategy.CONTINUE_ON_ERROR,
onClick = { viewModel.updateStrategy(ExecutionStrategy.CONTINUE_ON_ERROR) },
@@ -770,6 +773,20 @@ private fun SceneAction.describe(): String = when (this) {
is SceneAction.Notification -> stringResource(R.string.scene_action_notification, title)
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SceneChoiceFlow(
modifier: Modifier = Modifier,
content: @Composable FlowRowScope.() -> Unit,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
content = content,
)
}
@Composable
private fun AddTriggerDialog(
availableDevices: List<DeviceDescriptor>,
@@ -796,7 +813,7 @@ private fun AddTriggerDialog(
title = { Text(stringResource(R.string.scene_dialog_add_trigger_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_time)) })
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
@@ -817,7 +834,7 @@ private fun AddTriggerDialog(
}
1, 2 -> {
Text(stringResource(R.string.scene_device_name_label))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
matchingDevices.forEach { device ->
FilterChip(
selected = deviceName == device.name.value,
@@ -826,7 +843,7 @@ private fun AddTriggerDialog(
)
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
}
@@ -896,7 +913,7 @@ private fun AddConditionDialog(
title = { Text(stringResource(R.string.scene_dialog_add_condition_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
FilterChip(selected = selectedType == 0, onClick = { selectedType = 0 }, label = { Text(stringResource(R.string.scene_chip_temp)) })
FilterChip(selected = selectedType == 1, onClick = { selectedType = 1 }, label = { Text(stringResource(R.string.scene_chip_humidity)) })
FilterChip(selected = selectedType == 2, onClick = { selectedType = 2 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
@@ -905,12 +922,12 @@ private fun AddConditionDialog(
when (selectedType) {
0 -> {
Text(stringResource(R.string.scene_device_name_label))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
sensors.forEach { device ->
FilterChip(selected = tempDeviceName == device.name.value, onClick = { tempDeviceName = device.name.value }, label = { Text(device.name.value) })
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
}
@@ -919,12 +936,12 @@ private fun AddConditionDialog(
}
1 -> {
Text(stringResource(R.string.scene_device_name_label))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
sensors.forEach { device ->
FilterChip(selected = humDeviceName == device.name.value, onClick = { humDeviceName = device.name.value }, label = { Text(device.name.value) })
}
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
FilterChip(selected = operator == ComparisonOperator.GREATER_THAN, onClick = { operator = ComparisonOperator.GREATER_THAN }, label = { Text(stringResource(R.string.scene_chip_above)) })
FilterChip(selected = operator == ComparisonOperator.LESS_THAN, onClick = { operator = ComparisonOperator.LESS_THAN }, label = { Text(stringResource(R.string.scene_chip_below)) })
}
@@ -933,13 +950,13 @@ private fun AddConditionDialog(
}
2 -> {
Text(stringResource(R.string.scene_device_name_label))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
sockets.forEach { device ->
FilterChip(selected = socketDeviceName == device.name.value, onClick = { socketDeviceName = device.name.value }, label = { Text(device.name.value) })
}
}
Text(stringResource(R.string.scene_target_socket_state))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SceneChoiceFlow {
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
}
@@ -1044,7 +1061,7 @@ private fun AddActionDialog(
title = { Text(stringResource(R.string.scene_dialog_add_action_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
FilterChip(selected = actionType == 0, onClick = { actionType = 0 }, label = { Text(stringResource(R.string.scene_chip_socket)) })
FilterChip(selected = actionType == 1, onClick = { actionType = 1 }, label = { Text(stringResource(R.string.scene_chip_remote)) })
FilterChip(selected = actionType == 2, onClick = { actionType = 2 }, label = { Text(stringResource(R.string.scene_chip_delay)) })
@@ -1053,7 +1070,7 @@ private fun AddActionDialog(
when (actionType) {
0 -> {
Text(stringResource(R.string.scene_select_socket))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
availableDevices.filter { it.type == DeviceType.SOCKET }.forEach { device ->
FilterChip(
selected = socketName == device.name.value,
@@ -1063,7 +1080,7 @@ private fun AddActionDialog(
}
}
Text(stringResource(R.string.scene_socket_power_state))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
SceneChoiceFlow {
FilterChip(selected = socketPower == SocketPowerState.ON, onClick = { socketPower = SocketPowerState.ON }, label = { Text(stringResource(R.string.scene_chip_power_on)) })
FilterChip(selected = socketPower == SocketPowerState.OFF, onClick = { socketPower = SocketPowerState.OFF }, label = { Text(stringResource(R.string.scene_chip_power_off)) })
}
@@ -1073,7 +1090,7 @@ private fun AddActionDialog(
Text(stringResource(R.string.scene_no_virtual_remotes))
} else {
Text(stringResource(R.string.scene_select_virtual_remote))
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
availableRemotes.forEachIndexed { idx, remote ->
FilterChip(
selected = selectedRemoteIndex == idx,
@@ -1084,7 +1101,7 @@ private fun AddActionDialog(
}
Text(stringResource(R.string.scene_select_remote_button))
if (keyBindings.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SceneChoiceFlow {
keyBindings.take(4).forEach { binding ->
val label = binding.displayName.ifBlank { binding.buttonId }
FilterChip(
@@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
@@ -28,10 +29,11 @@ import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -49,6 +51,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -71,6 +74,7 @@ import com.flagship.abox.manager.domain.SceneId
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -86,6 +90,23 @@ fun SceneListScreen(
val selectedTab by viewModel.selectedTab.collectAsStateWithLifecycle()
val pendingDelete by viewModel.pendingDelete.collectAsStateWithLifecycle()
var showFabMenu by remember { mutableStateOf(false) }
var fabExpanded by remember { mutableStateOf(true) }
val listState = rememberLazyListState()
LaunchedEffect(listState) {
var previousIndex = 0
var previousOffset = 0
snapshotFlow { listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
.distinctUntilChanged()
.collect { (index, offset) ->
val movingUp = index > previousIndex || (index == previousIndex && offset > previousOffset)
val movingDown = index < previousIndex || (index == previousIndex && offset < previousOffset)
if (movingUp) fabExpanded = false
if (movingDown || (index == 0 && offset == 0)) fabExpanded = true
previousIndex = index
previousOffset = offset
}
}
val context = LocalContext.current
LaunchedEffect(Unit) {
@@ -99,37 +120,30 @@ fun SceneListScreen(
Scaffold(
containerColor = Color.Transparent,
floatingActionButton = {
Box {
ExtendedFloatingActionButton(
onClick = { showFabMenu = true },
icon = { Icon(Icons.Filled.Add, contentDescription = null) },
text = { Text(stringResource(R.string.scene_create_action)) },
)
DropdownMenu(
expanded = showFabMenu,
onDismissRequest = { showFabMenu = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_manual)) },
leadingIcon = { Icon(Icons.Outlined.Bolt, contentDescription = null) },
onClick = {
showFabMenu = false
onAddManual()
},
ExtendedFloatingActionButton(
onClick = { showFabMenu = true },
expanded = fabExpanded,
icon = {
Icon(
Icons.Filled.Add,
contentDescription = stringResource(R.string.scene_create_action),
)
DropdownMenuItem(
text = { Text(stringResource(R.string.scene_create_automatic)) },
leadingIcon = { Icon(Icons.Outlined.Schedule, contentDescription = null) },
onClick = {
showFabMenu = false
onAddAutomatic()
},
)
}
}
},
text = { Text(stringResource(R.string.scene_create_action), fontWeight = FontWeight.Bold) },
shape = RoundedCornerShape(18.dp),
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 6.dp,
pressedElevation = 10.dp,
focusedElevation = 8.dp,
hoveredElevation = 8.dp,
),
)
},
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
start = 16.dp,
@@ -213,6 +227,68 @@ fun SceneListScreen(
},
)
}
if (showFabMenu) {
ModalBottomSheet(onDismissRequest = { showFabMenu = false }) {
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(stringResource(R.string.scene_create_sheet_title), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
Text(
stringResource(R.string.scene_create_sheet_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SceneCreationOption(
icon = ABoxIconKey.SCENES,
title = stringResource(R.string.scene_create_manual),
description = stringResource(R.string.scene_create_manual_description),
onClick = {
showFabMenu = false
onAddManual()
},
)
SceneCreationOption(
icon = ABoxIconKey.PENDING,
title = stringResource(R.string.scene_create_automatic),
description = stringResource(R.string.scene_create_automatic_description),
onClick = {
showFabMenu = false
onAddAutomatic()
},
)
Spacer(Modifier.height(20.dp))
}
}
}
}
@Composable
private fun SceneCreationOption(
icon: ABoxIconKey,
title: String,
description: String,
onClick: () -> Unit,
) {
Surface(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primaryContainer) {
ABoxIcon(icon, title, modifier = Modifier.padding(10.dp).size(24.dp))
}
Column(Modifier.weight(1f).padding(start = 14.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
@Composable
@@ -3,20 +3,27 @@ package com.flagship.abox.manager.ui.shell
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.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.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Logout
import androidx.compose.material.icons.outlined.CloudDone
import androidx.compose.material.icons.outlined.Logout
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -25,17 +32,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.res.stringResource
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxUserAvatar
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
private enum class DestructiveAction {
END_SESSION,
FORGET_CREDENTIALS,
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountProviderDialog(
userName: String,
@@ -47,136 +51,106 @@ fun AccountProviderDialog(
onForgetCredentials: () -> Unit,
modifier: Modifier = Modifier,
) {
var confirmation by remember { mutableStateOf<DestructiveAction?>(null) }
var confirmLogout by remember { mutableStateOf(false) }
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
if (confirmation == null) {
AlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier.testTag("account_dialog"),
title = {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
sheetState = sheetState,
modifier = modifier.testTag("account_dialog"),
dragHandle = {
Surface(
modifier = Modifier.padding(top = 10.dp, bottom = 6.dp).size(width = 40.dp, height = 4.dp),
shape = RoundedCornerShape(2.dp),
color = MaterialTheme.colorScheme.outlineVariant,
) {}
},
) {
Column(
modifier = Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ABoxUserAvatar(
name = userName,
onClick = {},
size = 76.dp,
contentDescription = stringResource(R.string.account_current),
)
Text(
text = stringResource(R.string.account_greeting, userName.ifBlank { stringResource(R.string.account_current) }),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
)
Text(
text = userName.ifBlank { stringResource(R.string.account_current) },
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text("账户与提供方")
ABoxIconButton(
key = ABoxIconKey.CLOSE,
contentDescription = "关闭账户对话框",
onClick = onDismissRequest,
modifier = Modifier.testTag("account_dialog_close"),
)
}
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxUserAvatar(name = userName, onClick = {}, size = 64.dp)
Text(
text = userName.ifBlank { "当前账户" },
style = MaterialTheme.typography.titleMedium,
)
Text(
text = providerName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f),
shape = MaterialTheme.shapes.medium,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Icon(
imageVector = Icons.Outlined.CloudDone,
contentDescription = "连接方式",
modifier = Modifier.size(24.dp),
)
Column(modifier = Modifier.weight(1f)) {
Text("当前连接方式", style = MaterialTheme.typography.labelMedium)
Text(providerName, style = MaterialTheme.typography.bodyLarge)
}
TextButton(
onClick = onSwitchProvider,
modifier = Modifier.testTag("account_dialog_switch_provider"),
) {
Icon(Icons.Outlined.Refresh, "切换提供方")
Text("切换")
}
}
}
AccountActionButton(
icon = Icons.Outlined.Settings,
label = "界面设置",
description = "调整主题、渲染器和交互偏好",
tag = "account_dialog_open_settings",
onClick = onOpenSettings,
)
AccountActionButton(
icon = Icons.Outlined.Logout,
label = "退出当前会话",
description = "结束当前提供方会话",
tag = "account_dialog_end_session",
onClick = { confirmation = DestructiveAction.END_SESSION },
)
TextButton(
onClick = { confirmation = DestructiveAction.FORGET_CREDENTIALS },
modifier = Modifier.fillMaxWidth().testTag("account_dialog_forget_credentials"),
) {
Text("清除保存的凭据", color = MaterialTheme.colorScheme.error)
Icon(Icons.Outlined.CloudDone, contentDescription = stringResource(R.string.account_connection_method), modifier = Modifier.size(26.dp))
Column {
Text(stringResource(R.string.account_connection_method), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(providerName, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
}
}
},
confirmButton = {},
)
} else {
val action = confirmation ?: return
val isForget = action == DestructiveAction.FORGET_CREDENTIALS
}
AccountSheetAction(
icon = Icons.Outlined.Settings,
label = stringResource(R.string.account_interface_settings),
description = stringResource(R.string.account_interface_settings_desc),
tag = "account_dialog_open_settings",
onClick = onOpenSettings,
)
AccountSheetAction(
icon = Icons.AutoMirrored.Outlined.Logout,
label = stringResource(R.string.account_logout),
description = stringResource(R.string.account_logout_desc),
tag = "account_dialog_end_session",
dangerous = true,
onClick = { confirmLogout = true },
)
Spacer(Modifier.height(28.dp))
}
}
if (confirmLogout) {
AlertDialog(
onDismissRequest = { confirmation = null },
title = { Text(if (isForget) "清除保存的凭据" else "退出当前会话") },
text = {
Text(
if (isForget) {
"确定清除当前提供方保存的登录凭据吗?"
} else {
"确定退出当前会话吗?"
},
)
},
onDismissRequest = { confirmLogout = false },
title = { Text(stringResource(R.string.account_logout)) },
text = { Text(stringResource(R.string.account_logout_confirm)) },
confirmButton = {
ABoxButton(
onClick = {
confirmation = null
if (isForget) onForgetCredentials() else onEndSession()
confirmLogout = false
onEndSession()
},
modifier = Modifier.testTag("account_dialog_confirm"),
) {
Text("确定")
}
},
dismissButton = {
TextButton(onClick = { confirmation = null }) { Text("取消") }
) { Text(stringResource(R.string.account_logout)) }
},
dismissButton = { TextButton(onClick = { confirmLogout = false }) { Text(stringResource(R.string.action_cancel)) } },
)
}
}
@Composable
private fun AccountActionButton(
private fun AccountSheetAction(
icon: androidx.compose.ui.graphics.vector.ImageVector,
label: String,
description: String,
tag: String,
dangerous: Boolean = false,
onClick: () -> Unit,
) {
TextButton(
@@ -184,14 +158,18 @@ private fun AccountActionButton(
modifier = Modifier.fillMaxWidth().testTag(tag),
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Icon(icon, contentDescription = label)
Column(modifier = Modifier.weight(1f)) {
Text(label)
Text(description, style = MaterialTheme.typography.bodySmall)
Icon(
icon,
contentDescription = label,
tint = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
)
Column(Modifier.weight(1f)) {
Text(label, color = if (dangerous) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@@ -25,6 +25,7 @@ import androidx.compose.material3.Text
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.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
@@ -35,6 +36,10 @@ 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.ABoxUserAvatar
import com.flagship.abox.manager.designsystem.LocalABoxNavigationPresentation
import com.flagship.abox.manager.designsystem.LocalABoxRenderer
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.NavigationPresentation
/** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
@Composable
@@ -46,59 +51,103 @@ fun MainAppShell(
onTabClick: (MainTab) -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
showNavigationChrome: Boolean = true,
content: @Composable (PaddingValues) -> Unit,
) {
val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
val renderer = LocalABoxRenderer.current
val floating = renderer == DesignRenderer.MIUIX &&
LocalABoxNavigationPresentation.current == NavigationPresentation.FLOATING && !useRail
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
ProviderTopBar(
currentTab = currentTab,
providerName = providerName,
userName = userName,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
)
if (showNavigationChrome) {
ProviderTopBar(
currentTab = currentTab,
providerName = providerName,
userName = userName,
onProviderClick = onProviderClick,
onAvatarClick = onAvatarClick,
)
}
},
bottomBar = {
if (!useRail) {
ABoxNavigationBar {
MainTab.entries.forEach { tab ->
val selected = tab == currentTab
ABoxNavigationBarItem(
selected = selected,
onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = {
Icon(
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes),
)
},
label = { Text(stringResource(tab.labelRes)) },
)
}
}
if (showNavigationChrome && !useRail) {
MainBottomBar(currentTab, onTabClick, renderer, floating)
}
},
) { innerPadding ->
Row(
modifier = Modifier.fillMaxSize(),
) {
if (useRail) {
if (showNavigationChrome && useRail) {
MainNavigationRail(
currentTab = currentTab,
onTabClick = onTabClick,
)
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
Box(
modifier = Modifier.weight(1f).fillMaxHeight(),
) {
content(innerPadding)
}
}
}
}
@Composable
private fun MainBottomBar(
currentTab: MainTab,
onTabClick: (MainTab) -> Unit,
renderer: DesignRenderer,
floating: Boolean,
) {
if (renderer == DesignRenderer.MIUIX) {
Surface(
modifier = if (floating) {
Modifier.padding(horizontal = 20.dp, vertical = 8.dp).clip(RoundedCornerShape(36.dp))
} else {
Modifier
},
shape = if (floating) RoundedCornerShape(36.dp) else RoundedCornerShape(0.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = if (floating) 3.dp else 0.dp,
) {
ABoxNavigationBar(containerColor = Color.Transparent) {
MainTab.entries.forEach { tab ->
val selected = tab == currentTab
ABoxNavigationBarItem(
selected = tab == currentTab,
onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = {
Icon(
imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes),
)
},
label = { Text(stringResource(tab.labelRes)) },
)
}
}
}
} else {
ABoxNavigationBar {
MainTab.entries.forEach { tab ->
val selected = tab == currentTab
ABoxNavigationBarItem(
selected = selected,
onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = { Icon(if (selected) tab.selectedIcon else tab.unselectedIcon, stringResource(tab.labelRes)) },
label = { Text(stringResource(tab.labelRes)) },
)
}
}
}
}
@Composable
private fun ProviderTopBar(
currentTab: MainTab,
@@ -108,24 +157,24 @@ private fun ProviderTopBar(
onAvatarClick: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp,
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(32.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
@@ -170,8 +219,8 @@ private fun ProviderTopBar(
contentDescription = stringResource(R.string.action_account),
)
}
}
}
}
}
}
@@ -2,6 +2,7 @@ package com.flagship.abox.manager.ui.socket
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -38,6 +39,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
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
@@ -50,13 +53,14 @@ import com.flagship.abox.manager.designsystem.ABoxButton
import com.flagship.abox.manager.designsystem.ABoxDeviceDetailScaffold
import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxStatus
import com.flagship.abox.manager.designsystem.ABoxStatusBanner
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.SocketPowerState
import java.text.SimpleDateFormat
import java.text.DateFormat
import java.util.Date
import java.util.Locale
@Composable
fun SocketRoute(
@@ -83,10 +87,30 @@ fun SocketScreen(
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val configuration = LocalConfiguration.current
var showDeviceNameDialog by remember { mutableStateOf(false) }
ABoxDeviceDetailScaffold(
modifier = modifier.fillMaxSize(),
title = state.deviceName?.value ?: stringResource(R.string.socket_title),
onBack = onBackClick,
subtitle = stringResource(R.string.socket_detail_subtitle),
icon = ABoxIconKey.SOCKET,
action = {
Row {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = stringResource(R.string.socket_name_settings),
onClick = { showDeviceNameDialog = true },
enabled = !state.isOperating,
)
ABoxIconButton(
key = ABoxIconKey.REFRESH,
contentDescription = stringResource(R.string.action_refresh),
onClick = { onEvent(SocketUiEvent.Refresh) },
enabled = !state.isLoading && !state.isOperating,
)
}
},
) { paddingValues ->
Column(
modifier = Modifier
@@ -96,17 +120,6 @@ fun SocketScreen(
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
ABoxIconButton(
key = ABoxIconKey.REFRESH,
contentDescription = "刷新插座状态",
onClick = { onEvent(SocketUiEvent.Refresh) },
enabled = !state.isLoading && !state.isOperating,
)
}
if (state.isOffline) {
OfflineBanner(
onRetry = { onEvent(SocketUiEvent.Refresh) },
@@ -122,20 +135,28 @@ fun SocketScreen(
)
}
Box(modifier = Modifier.fillMaxWidth().height(64.dp)) {
SocketOperationBanner(operation = state.operation)
}
// Central Status Card
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
.padding(top = 8.dp, bottom = 18.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
containerColor = if (state.isPowerOn) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.72f)
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
shape = MaterialTheme.shapes.large,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
.padding(horizontal = 24.dp, vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@@ -143,13 +164,16 @@ fun SocketScreen(
powerState = state.powerState,
isOffline = state.isOffline,
isLoading = state.isLoading,
isOperating = state.isOperating,
enabled = state.isActionEnabled,
onClick = { onEvent(SocketUiEvent.TogglePower) },
)
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.socket_status_label),
style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -160,7 +184,7 @@ fun SocketScreen(
state.powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
else -> stringResource(R.string.socket_status_unknown)
},
style = MaterialTheme.typography.headlineMedium,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = when {
state.isOffline -> MaterialTheme.colorScheme.error
@@ -171,7 +195,9 @@ fun SocketScreen(
)
state.lastConfirmedTimeMillis?.let { timeMillis ->
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
val formattedTime = remember(timeMillis, configuration) {
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
}
Text(
text = stringResource(R.string.socket_confirmed_at, formattedTime),
style = MaterialTheme.typography.bodySmall,
@@ -182,35 +208,12 @@ fun SocketScreen(
}
}
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
Text(
text = stringResource(R.string.socket_direct_control),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth().padding(bottom = 10.dp),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -218,22 +221,76 @@ fun SocketScreen(
OutlinedButton(
onClick = { onEvent(SocketUiEvent.SetPower(SocketPowerState.ON)) },
enabled = state.isActionEnabled && state.powerState != SocketPowerState.ON,
modifier = Modifier.weight(1f).height(48.dp),
modifier = Modifier.weight(1f).height(56.dp),
) {
ABoxIcon(ABoxIconKey.POWER, "开启插座", modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.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),
modifier = Modifier.weight(1f).height(56.dp),
) {
ABoxIcon(ABoxIconKey.POWER, "关闭插座", modifier = Modifier.size(20.dp))
Spacer(Modifier.width(8.dp))
Text(stringResource(R.string.socket_action_turn_off))
}
}
Text(
text = stringResource(R.string.socket_confirmation_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.fillMaxWidth().padding(top = 14.dp),
)
}
}
if (showDeviceNameDialog) {
DeviceNameDialog(
currentName = state.deviceName?.value.orEmpty(),
title = stringResource(R.string.socket_name_settings),
description = stringResource(R.string.gateway_device_name_socket_desc),
onDismiss = { showDeviceNameDialog = false },
onConfirm = { name ->
showDeviceNameDialog = false
onEvent(SocketUiEvent.ChangeDeviceName(DeviceName(name)))
},
)
}
}
@Composable
private fun DeviceNameDialog(
currentName: String,
title: String,
description: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var value by remember(currentName) { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(description, style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(stringResource(R.string.gateway_device_name)) },
singleLine = true,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
)
}
@Composable
@@ -241,6 +298,9 @@ private fun PowerIndicatorBadge(
powerState: SocketPowerState?,
isOffline: Boolean,
isLoading: Boolean,
isOperating: Boolean,
enabled: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val indicatorColor = when {
@@ -252,24 +312,74 @@ private fun PowerIndicatorBadge(
Box(
modifier = modifier
.size(96.dp)
.size(160.dp)
.clip(CircleShape)
.background(indicatorColor.copy(alpha = 0.15f))
.border(3.dp, indicatorColor, CircleShape),
.border(4.dp, indicatorColor, CircleShape)
.clickable(enabled = enabled, onClickLabel = "切换插座电源", onClick = onClick)
.testTag("socket_power_dial"),
contentAlignment = Alignment.Center,
) {
if (isLoading) {
ABoxLoadingIndicator(modifier = Modifier.size(36.dp))
} else {
ABoxIcon(
key = if (powerState == SocketPowerState.ON) ABoxIconKey.POWER else ABoxIconKey.SOCKET,
contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭",
modifier = Modifier.size(40.dp),
Column(horizontalAlignment = Alignment.CenterHorizontally) {
if (isLoading || isOperating) {
ABoxLoadingIndicator(modifier = Modifier.size(44.dp))
} else {
ABoxIcon(
key = ABoxIconKey.POWER,
contentDescription = if (powerState == SocketPowerState.ON) "插座已开启" else "插座已关闭",
modifier = Modifier.size(52.dp),
)
}
Spacer(Modifier.height(10.dp))
Text(
text = when {
isOperating -> stringResource(R.string.device_status_switching)
powerState == SocketPowerState.ON -> stringResource(R.string.socket_status_on)
powerState == SocketPowerState.OFF -> stringResource(R.string.socket_status_off)
else -> stringResource(R.string.device_status_unavailable)
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
}
}
@Composable
private fun SocketOperationBanner(
operation: SocketOperationState,
modifier: Modifier = Modifier,
) {
when (operation) {
is SocketOperationState.PendingCommand -> ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在发送电源指令",
modifier = modifier,
)
is SocketOperationState.RefreshingConfirmation -> ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在确认插座状态",
modifier = modifier,
)
is SocketOperationState.AwaitingConfirmation -> ABoxStatusBanner(
state = ABoxStatus.WARNING,
contentDescription = "指令已发送,等待状态确认",
modifier = modifier.testTag("socket_awaiting_confirmation"),
)
is SocketOperationState.Failed -> ABoxStatusBanner(
state = ABoxStatus.ERROR,
contentDescription = "插座操作失败",
modifier = modifier,
)
SocketOperationState.Unavailable -> ABoxStatusBanner(
state = ABoxStatus.OFFLINE,
contentDescription = "插座当前不可用",
modifier = modifier,
)
SocketOperationState.IdleConfirmed -> Unit
}
}
@Composable
private fun OfflineBanner(
onRetry: () -> Unit,
@@ -9,13 +9,18 @@ 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.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.ui.common.DomainErrorMapper
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
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 kotlinx.coroutines.delay
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
import javax.inject.Inject
@@ -23,6 +28,8 @@ import javax.inject.Inject
@HiltViewModel
class SocketViewModel @Inject constructor(
private val socketGateway: SocketGateway,
private val configuredDeviceRepository: ConfiguredDeviceRepository,
private val activityWriter: ActivityEventWriter,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -64,16 +71,19 @@ class SocketViewModel @Inject constructor(
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,
)
val current = _state.value
val profileId = current.profileId
val oldName = current.deviceName
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
viewModelScope.launch {
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
is DomainResult.Success -> {
_state.update { it.copy(deviceName = DeviceName(trimmed), powerState = null, errorMessage = null, lastConfirmedTimeMillis = null) }
loadStatus()
}
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
}
}
loadStatus()
}
}
@@ -146,24 +156,63 @@ class SocketViewModel @Inject constructor(
return
}
_state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
viewModelScope.launch {
_state.update { it.copy(isOperating = true, errorMessage = null, operation = SocketOperationState.PendingCommand(targetState)) }
when (val result = socketGateway.commandPower(profileId, deviceName, targetState)) {
is DomainResult.Success -> {
_state.update { it.copy(operation = SocketOperationState.RefreshingConfirmation(targetState)) }
when (val confirmation = socketGateway.getStatus(profileId, deviceName)) {
is DomainResult.Success -> _state.update {
it.copy(powerState = confirmation.value.powerState, isOperating = false, isOffline = false,
errorMessage = null, lastConfirmedTimeMillis = System.currentTimeMillis(),
operation = SocketOperationState.IdleConfirmed)
}
is DomainResult.Failure -> _state.update {
it.copy(isOperating = false, errorMessage = DomainErrorMapper.map(confirmation.error),
operation = SocketOperationState.AwaitingConfirmation(targetState))
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.SUCCEEDED)
_state.update {
it.copy(
isOperating = true,
isOffline = false,
errorMessage = null,
operation = SocketOperationState.RefreshingConfirmation(targetState),
)
}
var confirmationError: DomainError? = null
repeat(CONFIRMATION_ATTEMPTS) { attempt ->
if (attempt > 0) delay(CONFIRMATION_RETRY_DELAY_MILLIS)
when (val confirmation = socketGateway.getStatus(profileId, deviceName)) {
is DomainResult.Success -> if (confirmation.value.powerState == targetState) {
activityWriter.recordDevice(
profileId,
deviceName,
ActivityEventKind.SOCKET_CONFIRMATION,
ActivityOutcome.SUCCEEDED,
)
_state.update {
it.copy(
powerState = targetState,
isOperating = false,
isOffline = false,
errorMessage = null,
lastConfirmedTimeMillis = System.currentTimeMillis(),
operation = SocketOperationState.IdleConfirmed,
)
}
return@launch
}
is DomainResult.Failure -> confirmationError = confirmation.error
}
}
activityWriter.recordDevice(
profileId,
deviceName,
ActivityEventKind.SOCKET_CONFIRMATION,
ActivityOutcome.AWAITING_CONFIRMATION,
confirmationError?.externalCode,
)
_state.update {
it.copy(
isOperating = false,
isOffline = confirmationError is DomainError.DeviceOffline,
errorMessage = confirmationError?.let(DomainErrorMapper::map),
operation = SocketOperationState.AwaitingConfirmation(targetState),
)
}
}
is DomainResult.Failure -> {
activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SOCKET_COMMAND, ActivityOutcome.FAILED, result.error.externalCode)
val isOffline = result.error is DomainError.DeviceOffline
_state.update {
it.copy(
@@ -177,4 +226,9 @@ class SocketViewModel @Inject constructor(
}
}
}
private companion object {
const val CONFIRMATION_ATTEMPTS = 4
const val CONFIRMATION_RETRY_DELAY_MILLIS = 500L
}
}
@@ -31,6 +31,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@@ -45,9 +47,8 @@ import com.flagship.abox.manager.designsystem.ABoxIconButton
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.domain.DeviceName
import java.text.SimpleDateFormat
import java.text.DateFormat
import java.util.Date
import java.util.Locale
@Composable
fun TemperatureHumidityRoute(
@@ -74,10 +75,22 @@ fun TemperatureHumidityScreen(
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val configuration = LocalConfiguration.current
var showDeviceNameDialog by remember { mutableStateOf(false) }
ABoxDeviceDetailScaffold(
modifier = modifier.fillMaxSize(),
title = state.deviceName?.value ?: stringResource(R.string.th_title),
onBack = onBackClick,
subtitle = stringResource(R.string.device_sensor_subtitle),
icon = ABoxIconKey.SENSOR,
action = {
ABoxIconButton(
key = ABoxIconKey.EDIT,
contentDescription = stringResource(R.string.sensor_name_settings),
onClick = { showDeviceNameDialog = true },
enabled = !state.isLoading && !state.isRefreshing,
)
},
) { paddingValues ->
Column(
modifier = Modifier
@@ -87,17 +100,6 @@ fun TemperatureHumidityScreen(
.verticalScroll(scrollState),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
ABoxIconButton(
key = ABoxIconKey.REFRESH,
contentDescription = "刷新温湿度数据",
onClick = { onEvent(TemperatureHumidityUiEvent.Refresh) },
enabled = !state.isLoading && !state.isRefreshing,
)
}
if (state.isOffline) {
OfflineBanner(
onRetry = { onEvent(TemperatureHumidityUiEvent.Refresh) },
@@ -137,10 +139,16 @@ fun TemperatureHumidityScreen(
)
} else {
// Telemetry Cards Row / Column
Text(
text = stringResource(R.string.sensor_current_readings),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp, bottom = 10.dp),
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp),
.padding(bottom = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
TelemetryMetricCard(
@@ -148,6 +156,7 @@ fun TemperatureHumidityScreen(
value = state.temperature,
unit = stringResource(R.string.th_unit_celsius),
icon = ABoxIconKey.TEMPERATURE,
testTag = "sensor_metric_temperature",
modifier = Modifier.weight(1f),
)
@@ -156,12 +165,15 @@ fun TemperatureHumidityScreen(
value = state.humidity,
unit = stringResource(R.string.th_unit_percent),
icon = ABoxIconKey.HUMIDITY,
testTag = "sensor_metric_humidity",
modifier = Modifier.weight(1f),
)
}
state.lastUpdatedTimeMillis?.let { timeMillis ->
val formattedTime = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timeMillis))
val formattedTime = remember(timeMillis, configuration) {
DateFormat.getTimeInstance(DateFormat.MEDIUM).format(Date(timeMillis))
}
Text(
text = stringResource(R.string.th_last_updated, formattedTime),
style = MaterialTheme.typography.bodySmall,
@@ -175,7 +187,7 @@ fun TemperatureHumidityScreen(
// Read-Only Informational Card
Card(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().testTag("sensor_monitoring_notice"),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
),
@@ -187,13 +199,13 @@ fun TemperatureHumidityScreen(
.padding(16.dp),
) {
Text(
text = stringResource(R.string.th_read_only_notice),
text = stringResource(R.string.sensor_monitoring_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
text = stringResource(R.string.th_read_only_description),
text = stringResource(R.string.sensor_monitoring_description),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.85f),
modifier = Modifier.padding(top = 4.dp),
@@ -201,7 +213,7 @@ fun TemperatureHumidityScreen(
}
}
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
// Refresh Button
ABoxButton(
@@ -209,7 +221,7 @@ fun TemperatureHumidityScreen(
enabled = !state.isLoading && !state.isRefreshing && state.profileId != null,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
.height(56.dp),
) {
if (state.isRefreshing || state.isLoading) {
ABoxLoadingIndicator(modifier = Modifier.size(22.dp))
@@ -225,6 +237,45 @@ fun TemperatureHumidityScreen(
}
}
if (showDeviceNameDialog) {
SensorDeviceNameDialog(
currentName = state.deviceName?.value.orEmpty(),
onDismiss = { showDeviceNameDialog = false },
onConfirm = { name ->
showDeviceNameDialog = false
onEvent(TemperatureHumidityUiEvent.ChangeDeviceName(DeviceName(name)))
},
)
}
}
@Composable
private fun SensorDeviceNameDialog(
currentName: String,
onDismiss: () -> Unit,
onConfirm: (String) -> Unit,
) {
var value by remember(currentName) { mutableStateOf(currentName) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.sensor_name_settings)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.gateway_device_name_sensor_desc), style = MaterialTheme.typography.bodySmall)
OutlinedTextField(
value = value,
onValueChange = { value = it },
label = { Text(stringResource(R.string.gateway_device_name)) },
singleLine = true,
)
}
},
confirmButton = {
TextButton(onClick = { onConfirm(value.trim()) }, enabled = value.isNotBlank()) { Text(stringResource(R.string.action_save_device_name)) }
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.action_cancel)) } },
)
}
@Composable
@@ -233,11 +284,12 @@ private fun TelemetryMetricCard(
value: String?,
unit: String,
icon: ABoxIconKey,
testTag: String,
modifier: Modifier = Modifier,
) {
if (value == null) return
Card(
modifier = modifier,
modifier = modifier.testTag(testTag),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
@@ -8,7 +8,11 @@ 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.devicecatalog.data.ConfiguredDeviceRepository
import com.flagship.abox.manager.ui.common.DomainErrorMapper
import com.flagship.abox.manager.domain.ActivityEventWriter
import com.flagship.abox.manager.domain.ActivityEventKind
import com.flagship.abox.manager.domain.ActivityOutcome
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -22,6 +26,8 @@ import javax.inject.Inject
@HiltViewModel
class TemperatureHumidityViewModel @Inject constructor(
private val thGateway: TemperatureHumidityGateway,
private val configuredDeviceRepository: ConfiguredDeviceRepository,
private val activityWriter: ActivityEventWriter,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
@@ -62,18 +68,19 @@ class TemperatureHumidityViewModel @Inject constructor(
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,
)
val current = _state.value
val profileId = current.profileId
val oldName = current.deviceName
if (trimmed.isNotBlank() && trimmed != oldName?.value && profileId != null && oldName != null) {
viewModelScope.launch {
when (val result = configuredDeviceRepository.rename(profileId, oldName, DeviceName(trimmed))) {
is DomainResult.Success -> {
_state.update { it.copy(deviceName = DeviceName(trimmed), temperature = null, humidity = null, hasNoData = false, errorMessage = null, lastUpdatedTimeMillis = null) }
loadReading(isRefresh = false)
}
is DomainResult.Failure -> _state.update { it.copy(errorMessage = DomainErrorMapper.map(result.error)) }
}
}
loadReading(isRefresh = false)
}
}
@@ -100,6 +107,7 @@ class TemperatureHumidityViewModel @Inject constructor(
when (val result = thGateway.getReading(profileId, deviceName)) {
is DomainResult.Success -> {
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.SUCCEEDED)
val reading = result.value
val isBlankData = reading.temperature.isNullOrBlank() && reading.humidity.isNullOrBlank()
_state.update {
@@ -117,6 +125,7 @@ class TemperatureHumidityViewModel @Inject constructor(
}
is DomainResult.Failure -> {
if (isRefresh) activityWriter.recordDevice(profileId, deviceName, ActivityEventKind.SENSOR_REFRESH, ActivityOutcome.FAILED, result.error.externalCode)
val isOffline = result.error is DomainError.DeviceOffline
val hasNoData = result.error is DomainError.NoData
_state.update {
@@ -259,4 +259,127 @@
<string name="error_operation_failed">操作执行失败,请重试。</string>
<string name="error_no_data">暂无可用数据。</string>
<string name="error_unknown">发生未知错误。</string>
<!-- Interface settings -->
<string name="settings_title">界面设置</string>
<string name="settings_style_section">样式</string>
<string name="settings_renderer_label">渲染器</string>
<string name="settings_renderer_description">选择应用使用的界面渲染方式</string>
<string name="settings_renderer_material3">Material 3</string>
<string name="settings_renderer_miuix">Miuix</string>
<string name="settings_theme_section">主题</string>
<string name="settings_theme_label">主题模式</string>
<string name="settings_theme_system">跟随系统</string>
<string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string>
<string name="settings_dynamic_color">动态取色</string>
<string name="settings_dynamic_color_description">使用系统壁纸颜色生成界面配色</string>
<string name="settings_surface_section">界面效果</string>
<string name="settings_blur">模糊效果</string>
<string name="settings_blur_description">在支持的界面中启用背景模糊</string>
<string name="settings_navigation">导航样式</string>
<string name="settings_navigation_standard">标准导航栏</string>
<string name="settings_navigation_floating">悬浮导航栏</string>
<string name="settings_feedback_section">交互反馈</string>
<string name="settings_haptics">触觉反馈</string>
<string name="settings_haptics_description">操作时提供轻微震动反馈</string>
<string name="settings_accessibility_section">系统辅助功能</string>
<string name="settings_accessibility_card_title">遵循系统辅助功能设置</string>
<string name="settings_accessibility_description">动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。</string>
<string name="settings_error_save">无法保存界面设置,请重试</string>
<string name="settings_style_entry">界面风格</string>
<string name="settings_theme_entry">主题</string>
<string name="settings_theme_entry_description">配色、明暗、视觉效果与缩放</string>
<string name="settings_theme_detail_title">主题</string>
<string name="settings_renderer_primary">Material 3</string>
<string name="settings_renderer_secondary">Miuix</string>
<string name="settings_navigation_floating_description">让底部导航独立悬浮在页面内容上方</string>
<string name="settings_accent_label">强调色</string>
<string name="settings_scale">界面缩放</string>
<string name="settings_scale_description">调整界面元素的显示比例</string>
<string name="settings_color_style">色彩风格</string>
<string name="settings_color_style_tonal">Tonal Spot</string>
<string name="settings_color_style_expressive">Expressive</string>
<string name="settings_color_style_fidelity">Fidelity</string>
<string name="settings_color_standard">色彩规范</string>
<string name="settings_color_standard_2021">Spec 2021</string>
<string name="settings_color_standard_2025">Spec 2025</string>
<string name="settings_liquid_glass">液态玻璃</string>
<string name="settings_liquid_glass_description">为悬浮底栏启用半透明玻璃质感</string>
<string name="settings_predictive_back">预测性返回手势</string>
<string name="settings_predictive_back_description">侧滑返回时预览即将返回的页面</string>
<string name="settings_tap_to_choose">点击选择</string>
<string name="settings_requires_android_13">需要 Android 13 或更高版本</string>
<string name="settings_requires_floating_navigation">请先启用悬浮底栏</string>
<!-- Redesigned device center and account surfaces -->
<string name="device_center_heading">已连接终端设备</string>
<string name="device_center_description">选择需要控制或查看状态的终端设备</string>
<string name="device_center_loading">正在加载设备</string>
<string name="device_center_empty_title">尚未配置可管理设备</string>
<string name="device_center_empty_description">请先配置网关设备名称和类型</string>
<string name="device_center_configure">配置设备</string>
<string name="device_center_error">设备列表加载失败</string>
<string name="device_center_configure_title">配置设备</string>
<string name="device_center_device_name">设备名称</string>
<string name="device_status_unavailable">状态不可用</string>
<string name="device_status_offline">离线</string>
<string name="device_status_switching">正在切换</string>
<string name="device_status_awaiting_confirmation">等待状态确认</string>
<string name="device_status_on">已开启</string>
<string name="device_status_off">已关闭</string>
<string name="device_status_confirmed">状态已确认</string>
<string name="device_status_waiting_first_confirmation">等待首次确认</string>
<string name="device_open_control_panel">控制面板</string>
<string name="device_sensor_subtitle">环境传感器 · 只读监控</string>
<string name="device_live_reading">实时设备读数</string>
<string name="device_details">详情</string>
<string name="device_metric_no_data">无数据</string>
<string name="device_infrared_subtitle">红外控制器 · %1$d 个虚拟遥控器</string>
<string name="device_infrared_mounted">已挂载:%1$s</string>
<string name="device_infrared_no_remotes">暂无遥控器</string>
<string name="device_infrared_slots">硬件槽位 %1$d/%2$d</string>
<string name="device_infrared_slots_unavailable">槽位状态不可用</string>
<string name="device_infrared_center">红外中枢</string>
<string name="account_current">当前账户</string>
<string name="account_greeting">%1$s,您好</string>
<string name="account_connection_method">连接方式</string>
<string name="account_interface_settings">界面设置</string>
<string name="account_interface_settings_desc">主题、渲染风格与交互偏好</string>
<string name="account_logout">退出当前会话</string>
<string name="account_logout_desc">结束当前连接并返回登录流程</string>
<string name="account_logout_confirm">确定退出当前会话吗?</string>
<string name="scene_create_sheet_title">新建指令</string>
<string name="scene_create_sheet_description">选择指令的启动方式</string>
<string name="scene_create_manual_description">手动点击运行,适合常用的一键设备组合。</string>
<string name="scene_create_automatic_description">根据时间、设备状态或传感器条件自动执行。</string>
<string name="socket_detail_subtitle">智能插座 · 状态确认控制</string>
<string name="socket_name_settings">设置插座设备名称</string>
<string name="sensor_name_settings">设置传感器设备名称</string>
<string name="gateway_device_name">网关设备名称</string>
<string name="gateway_device_name_socket_desc">用于匹配和控制网关设备,必须与网关中的名称完全一致。</string>
<string name="gateway_device_name_sensor_desc">用于匹配和读取网关设备,必须与网关中的名称完全一致。</string>
<string name="action_save_device_name">保存设备名称</string>
<string name="socket_direct_control">直接控制</string>
<string name="socket_confirmation_hint">指令只有在网关回读到结果状态后才会确认。等待确认时可使用刷新操作。</string>
<string name="sensor_current_readings">当前读数</string>
<string name="sensor_monitoring_title">实时环境监控</string>
<string name="sensor_monitoring_description">数据直接来自当前设备。缺失指标保持不显示,不使用估算值或占位数据。</string>
<string name="infrared_center_title">红外遥控中心</string>
<string name="infrared_settings_title">红外设备设置</string>
<string name="infrared_settings_subtitle">遥控器、槽位与网关设备名称</string>
<string name="infrared_manage_title">虚拟遥控器</string>
<string name="infrared_remote_count">%1$d 个遥控器</string>
<string name="infrared_add_remote">添加遥控器</string>
<string name="infrared_hardware_slots_title">硬件槽位</string>
<string name="infrared_gateway_name_title">网关设备名称</string>
<string name="infrared_gateway_name_desc">用于在网关中匹配红外控制器,必须与网关中的设备名称完全一致。</string>
<string name="infrared_save_name">保存设备名称</string>
<string name="infrared_edit_remote">编辑遥控器</string>
<string name="infrared_move_remote">调整遥控器顺序</string>
<string name="infrared_move_up">上移</string>
<string name="infrared_move_down">下移</string>
<string name="infrared_delete_remote">删除遥控器</string>
<string name="infrared_layout">面板布局</string>
<string name="infrared_transmission_mode">传输模式</string>
</resources>
+119 -24
View File
@@ -261,30 +261,125 @@
<string name="error_unknown">An unexpected error occurred.</string>
<!-- Interface settings -->
<string name="settings_title">界面设置</string>
<string name="settings_style_section">样式</string>
<string name="settings_renderer_label">渲染器</string>
<string name="settings_renderer_description">选择应用使用的界面渲染方式</string>
<string name="settings_title">Interface settings</string>
<string name="settings_style_section">Style</string>
<string name="settings_renderer_label">Renderer</string>
<string name="settings_renderer_description">Choose the interface renderer used by the app</string>
<string name="settings_renderer_material3">Material 3</string>
<string name="settings_renderer_miuix">Miuix</string>
<string name="settings_theme_section">主题</string>
<string name="settings_theme_label">主题模式</string>
<string name="settings_theme_system">跟随系统</string>
<string name="settings_theme_light">浅色</string>
<string name="settings_theme_dark">深色</string>
<string name="settings_dynamic_color">动态取色</string>
<string name="settings_dynamic_color_description">使用系统壁纸颜色生成界面配色</string>
<string name="settings_surface_section">界面效果</string>
<string name="settings_blur">模糊效果</string>
<string name="settings_blur_description">在支持的界面中启用背景模糊</string>
<string name="settings_navigation">导航样式</string>
<string name="settings_navigation_standard">标准导航栏</string>
<string name="settings_navigation_floating">悬浮导航栏</string>
<string name="settings_feedback_section">交互反馈</string>
<string name="settings_haptics">触觉反馈</string>
<string name="settings_haptics_description">操作时提供轻微震动反馈</string>
<string name="settings_accessibility_section">系统辅助功能</string>
<string name="settings_accessibility_card_title">遵循系统辅助功能设置</string>
<string name="settings_accessibility_description">动画、字体大小和对比度等辅助功能由系统设置控制,应用会遵循系统偏好。</string>
<string name="settings_error_save">无法保存界面设置,请重试</string>
<string name="settings_theme_section">Theme</string>
<string name="settings_theme_label">Theme mode</string>
<string name="settings_theme_system">Follow system</string>
<string name="settings_theme_light">Light</string>
<string name="settings_theme_dark">Dark</string>
<string name="settings_dynamic_color">Dynamic color</string>
<string name="settings_dynamic_color_description">Generate interface colors from the system wallpaper</string>
<string name="settings_surface_section">Surface effects</string>
<string name="settings_blur">Blur effects</string>
<string name="settings_blur_description">Enable background blur on supported screens</string>
<string name="settings_navigation">Navigation style</string>
<string name="settings_navigation_standard">Standard navigation</string>
<string name="settings_navigation_floating">Floating navigation</string>
<string name="settings_feedback_section">Interaction feedback</string>
<string name="settings_haptics">Haptic feedback</string>
<string name="settings_haptics_description">Provide subtle vibration feedback for actions</string>
<string name="settings_accessibility_section">System accessibility</string>
<string name="settings_accessibility_card_title">Follow system accessibility settings</string>
<string name="settings_accessibility_description">Animation, font size, contrast and other accessibility options are controlled by the system.</string>
<string name="settings_error_save">Unable to save interface settings. Try again.</string>
<string name="settings_style_entry">Interface style</string>
<string name="settings_theme_entry">Theme</string>
<string name="settings_theme_entry_description">Colors, brightness, effects and scale</string>
<string name="settings_theme_detail_title">Theme</string>
<string name="settings_renderer_primary">Material 3</string>
<string name="settings_renderer_secondary">Miuix</string>
<string name="settings_navigation_floating_description">Use a detached navigation surface above page content</string>
<string name="settings_accent_label">Accent color</string>
<string name="settings_scale">Interface scale</string>
<string name="settings_scale_description">Adjust the size of interface elements</string>
<string name="settings_color_style">Color style</string>
<string name="settings_color_style_tonal">Tonal Spot</string>
<string name="settings_color_style_expressive">Expressive</string>
<string name="settings_color_style_fidelity">Fidelity</string>
<string name="settings_color_standard">Color standard</string>
<string name="settings_color_standard_2021">Spec 2021</string>
<string name="settings_color_standard_2025">Spec 2025</string>
<string name="settings_liquid_glass">Liquid glass</string>
<string name="settings_liquid_glass_description">Apply a translucent glass surface to floating navigation</string>
<string name="settings_predictive_back">Predictive back gesture</string>
<string name="settings_predictive_back_description">Preview the destination while swiping back</string>
<string name="settings_tap_to_choose">Tap to choose</string>
<string name="settings_requires_android_13">Requires Android 13 or later</string>
<string name="settings_requires_floating_navigation">Enable floating navigation first</string>
<!-- Redesigned device center and account surfaces -->
<string name="device_center_heading">Connected devices</string>
<string name="device_center_description">Select a device to control it or view its current state</string>
<string name="device_center_loading">Loading devices</string>
<string name="device_center_empty_title">No manageable devices configured</string>
<string name="device_center_empty_description">Configure a gateway device name and type first</string>
<string name="device_center_configure">Configure device</string>
<string name="device_center_error">Unable to load devices</string>
<string name="device_center_configure_title">Configure device</string>
<string name="device_center_device_name">Device name</string>
<string name="device_status_unavailable">Status unavailable</string>
<string name="device_status_offline">Offline</string>
<string name="device_status_switching">Switching</string>
<string name="device_status_awaiting_confirmation">Awaiting confirmation</string>
<string name="device_status_on">On</string>
<string name="device_status_off">Off</string>
<string name="device_status_confirmed">State confirmed</string>
<string name="device_status_waiting_first_confirmation">Waiting for first confirmation</string>
<string name="device_open_control_panel">Control panel</string>
<string name="device_sensor_subtitle">Environment sensor · Read-only monitoring</string>
<string name="device_live_reading">Live device reading</string>
<string name="device_details">Details</string>
<string name="device_metric_no_data">No data</string>
<string name="device_infrared_subtitle">Infrared controller · %1$d virtual remotes</string>
<string name="device_infrared_mounted">Mounted: %1$s</string>
<string name="device_infrared_no_remotes">No remotes</string>
<string name="device_infrared_slots">Hardware slots %1$d/%2$d</string>
<string name="device_infrared_slots_unavailable">Slot state unavailable</string>
<string name="device_infrared_center">Infrared center</string>
<string name="account_current">Current account</string>
<string name="account_greeting">Hello, %1$s</string>
<string name="account_connection_method">Connection method</string>
<string name="account_interface_settings">Interface settings</string>
<string name="account_interface_settings_desc">Theme, renderer and interaction preferences</string>
<string name="account_logout">Sign out</string>
<string name="account_logout_desc">End the current connection and return to sign-in</string>
<string name="account_logout_confirm">Sign out of the current session?</string>
<string name="scene_create_sheet_title">New command</string>
<string name="scene_create_sheet_description">Choose how the command starts</string>
<string name="scene_create_manual_description">Run manually for frequently used device combinations.</string>
<string name="scene_create_automatic_description">Run automatically from time, device, or sensor conditions.</string>
<string name="socket_detail_subtitle">Smart socket · confirmed control</string>
<string name="socket_name_settings">Set socket device name</string>
<string name="sensor_name_settings">Set sensor device name</string>
<string name="gateway_device_name">Gateway device name</string>
<string name="gateway_device_name_socket_desc">Used to match and control the gateway device. It must exactly match the gateway name.</string>
<string name="gateway_device_name_sensor_desc">Used to match and read the gateway device. It must exactly match the gateway name.</string>
<string name="action_save_device_name">Save device name</string>
<string name="socket_direct_control">Direct control</string>
<string name="socket_confirmation_hint">A command is confirmed only after the gateway reports the resulting state. Refresh while confirmation is pending.</string>
<string name="sensor_current_readings">Current readings</string>
<string name="sensor_monitoring_title">Live environment monitoring</string>
<string name="sensor_monitoring_description">Values come directly from the current device. Missing metrics remain absent; no estimates or placeholders are used.</string>
<string name="infrared_center_title">Infrared remote center</string>
<string name="infrared_settings_title">Infrared device settings</string>
<string name="infrared_settings_subtitle">Remotes, slots, and gateway device name</string>
<string name="infrared_manage_title">Virtual remotes</string>
<string name="infrared_remote_count">%1$d remotes</string>
<string name="infrared_add_remote">Add remote</string>
<string name="infrared_hardware_slots_title">Hardware slots</string>
<string name="infrared_gateway_name_title">Gateway device name</string>
<string name="infrared_gateway_name_desc">Used to match the infrared controller in the gateway. It must exactly match the gateway name.</string>
<string name="infrared_save_name">Save device name</string>
<string name="infrared_edit_remote">Edit remote</string>
<string name="infrared_move_remote">Reorder remote</string>
<string name="infrared_move_up">Move up</string>
<string name="infrared_move_down">Move down</string>
<string name="infrared_delete_remote">Delete remote</string>
<string name="infrared_layout">Panel layout</string>
<string name="infrared_transmission_mode">Transmission mode</string>
</resources>
@@ -16,6 +16,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
@@ -54,6 +55,16 @@ class DeviceCenterViewModelTest {
assertEquals(listOf("new"), viewModel.uiState.value.cards.map { it.displayName })
}
@Test
fun deviceCardKeyHasBundleCompatibleStableRepresentation() {
val first = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1"))
val same = DeviceCardKey(ProviderProfileId("profile-a"), DeviceName("socket-1"))
val differentProfile = DeviceCardKey(ProviderProfileId("profile-b"), DeviceName("socket-1"))
assertEquals(first.saveableKey(), same.saveableKey())
assertNotEquals(first.saveableKey(), differentProfile.saveableKey())
}
private fun descriptor(profile: ProviderProfileId, name: String, type: DeviceType) =
DeviceDescriptor(DeviceId("${profile.value}:$name"), profile, DeviceName(name), type)
@@ -2,7 +2,11 @@ package com.flagship.abox.manager.ui.preferences
import com.flagship.abox.manager.R
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.AccentPreference
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.InterfaceScale
import com.flagship.abox.manager.preferences.ColorStandard
import com.flagship.abox.manager.preferences.ColorStyle
import com.flagship.abox.manager.ui.common.UiText.StringResource
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.UiPreferences
@@ -83,6 +87,39 @@ class SettingsViewModelTest {
assertEquals(repository.value, viewModel.uiState.value.preferences)
}
@Test
fun scaleAndAccentUpdatesPersistThroughRepository() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetInterfaceScale(InterfaceScale.COMPACT))
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetAccent(AccentPreference.ROSE))
advanceUntilIdle()
assertEquals(InterfaceScale.COMPACT, repository.value.interfaceScale)
assertEquals(AccentPreference.ROSE, repository.value.accentPreference)
}
@Test
fun styleSpecificThemeOptionsPersistThroughRepository() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetColorStyle(ColorStyle.FIDELITY))
viewModel.onAction(SettingsUiAction.SetColorStandard(ColorStandard.SPEC_2025))
viewModel.onAction(SettingsUiAction.SetLiquidGlass(true))
viewModel.onAction(SettingsUiAction.SetPredictiveBack(false))
advanceUntilIdle()
assertEquals(ColorStyle.FIDELITY, repository.value.colorStyle)
assertEquals(ColorStandard.SPEC_2025, repository.value.colorStandard)
assertTrue(repository.value.liquidGlassEnabled)
assertEquals(false, repository.value.predictiveBackEnabled)
}
private class RecordingUiPreferencesRepository(
initial: UiPreferences = UiPreferences(),