Compare commits

..
10 Commits
52 changed files with 4022 additions and 371 deletions
+1
View File
@@ -66,6 +66,7 @@ kapt {
dependencies { dependencies {
implementation(project(":domain")) implementation(project(":domain"))
implementation(project(":android:core:designsystem")) implementation(project(":android:core:designsystem"))
implementation(project(":android:core:preferences"))
implementation(project(":android:core:session")) implementation(project(":android:core:session"))
implementation(project(":android:feature:infrared")) implementation(project(":android:feature:infrared"))
implementation(project(":absdk-bridge")) implementation(project(":absdk-bridge"))
@@ -0,0 +1,215 @@
package com.flagship.abox.manager.ui.navigation
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
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.assertTextEquals
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.designsystem.ABoxTheme
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.ui.shell.ABoxWindowSizeClass
import com.flagship.abox.manager.ui.shell.DeviceSelection
import com.flagship.abox.manager.ui.shell.DetailPresentation
import com.flagship.abox.manager.ui.shell.MainTab
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AuthenticatedNavigationRoutingTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun compactDetailBackPopsDetailBeforeParentDestination() {
composeRule.setContent { ABoxTheme { RoutingFixture() } }
composeRule.onNodeWithTag("routing_select_device").performClick()
composeRule.onNodeWithTag("routing_full_screen_detail").assertExists()
composeRule.runOnUiThread {
composeRule.activity.onBackPressedDispatcher.onBackPressed()
}
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_devices_root").assertExists()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE")
}
@Test
fun reselectingTabPopsActualNavControllerToItsRoot() {
composeRule.setContent { ABoxTheme { RoutingFixture() } }
composeRule.onNodeWithTag("routing_scenes_tab").performClick()
composeRule.onNodeWithTag("routing_scene_child").performClick()
composeRule.onNodeWithTag("routing_scenes_tab").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_scenes_root").assertExists()
composeRule.onNodeWithTag("routing_scene_child_destination").assertDoesNotExist()
}
@Test
fun resizeReconcilesActualRouteAndPaneFromOneSavedSelection() {
composeRule.setContent {
ABoxTheme {
RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED)
}
}
composeRule.onNodeWithTag("routing_select_device").performClick()
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_toggle_size").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_full_screen_detail").assertExists()
composeRule.onNodeWithTag("routing_route").assertTextEquals("test/device/socket")
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.FULL_SCREEN.name)
composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_toggle_size").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
composeRule.onNodeWithTag("routing_full_screen_detail").assertDoesNotExist()
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
}
@Test
fun savedSelectionSurvivesStateRestorationAndProfileReplacementClearsIt() {
val restorationTester = StateRestorationTester(composeRule)
restorationTester.setContent {
ABoxTheme {
RoutingFixture(initialWindowSizeClass = ABoxWindowSizeClass.EXPANDED)
}
}
composeRule.onNodeWithTag("routing_select_device").performClick()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
restorationTester.emulateSavedInstanceStateRestore()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals(DetailPresentation.PANE.name)
composeRule.onNodeWithTag("routing_pane_detail").assertExists()
composeRule.onNodeWithTag("routing_replace_profile").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithTag("routing_presentation").assertTextEquals("NONE")
composeRule.onNodeWithTag("routing_route").assertTextEquals(MainTab.DEVICES.route)
composeRule.onNodeWithTag("routing_pane_detail").assertDoesNotExist()
}
}
@Composable
private fun RoutingFixture(
initialWindowSizeClass: ABoxWindowSizeClass = ABoxWindowSizeClass.COMPACT,
) {
var windowSizeClass by remember { mutableStateOf(initialWindowSizeClass) }
var profileId by remember { mutableStateOf(ProviderProfileId("profile")) }
Column(modifier = Modifier.fillMaxSize()) {
Button(
onClick = {
windowSizeClass = if (windowSizeClass == ABoxWindowSizeClass.COMPACT) {
ABoxWindowSizeClass.EXPANDED
} else {
ABoxWindowSizeClass.COMPACT
}
},
modifier = Modifier.testTag("routing_toggle_size"),
) { Text("Resize") }
Button(
onClick = { profileId = ProviderProfileId("replacement") },
modifier = Modifier.testTag("routing_replace_profile"),
) { Text("Replace profile") }
key(profileId.value) {
val navController = rememberNavController()
AuthenticatedNavigationStateHost(
profileId = profileId,
windowSizeClass = windowSizeClass,
navController = navController,
deviceRoute = { "test/device/${it.deviceName.value}" },
selectionForRoute = { route ->
route
?.takeIf { it.startsWith("test/device/") }
?.removePrefix("test/device/")
?.let { deviceName ->
DeviceSelection(
profileId = profileId,
deviceName = DeviceName(deviceName),
type = DeviceType.SOCKET,
)
}
},
) { snapshot, onTabClick, onSelectDevice, _ ->
Text(snapshot.currentRoute.orEmpty(), modifier = Modifier.testTag("routing_route"))
Text(
snapshot.detailPresentation?.name ?: "NONE",
modifier = Modifier.testTag("routing_presentation"),
)
Button(
onClick = { onSelectDevice(DeviceName("socket"), DeviceType.SOCKET) },
modifier = Modifier.testTag("routing_select_device"),
) { Text("Select") }
Button(
onClick = { onTabClick(MainTab.SCENES) },
modifier = Modifier.testTag("routing_scenes_tab"),
) { Text("Scenes") }
Button(
onClick = { navController.navigate("scene/child") },
modifier = Modifier.testTag("routing_scene_child"),
) { Text("Child") }
if (snapshot.detailPresentation == DetailPresentation.PANE) {
Text("Pane detail", modifier = Modifier.testTag("routing_pane_detail"))
}
NavHost(
navController = navController,
startDestination = MainTab.DEVICES.route,
) {
composable(MainTab.DEVICES.route) {
Text("Devices", modifier = Modifier.testTag("routing_devices_root"))
}
composable(MainTab.SCENES.route) {
Text("Scenes", modifier = Modifier.testTag("routing_scenes_root"))
}
composable("scene/child") {
Text("Child", modifier = Modifier.testTag("routing_scene_child_destination"))
}
composable(
route = "test/device/{deviceName}",
arguments = listOf(navArgument("deviceName") { type = NavType.StringType }),
) {
Text("Full screen detail", modifier = Modifier.testTag("routing_full_screen_detail"))
}
}
}
}
}
}
@@ -0,0 +1,90 @@
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.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
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.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class SettingsScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun settingsUsesChineseLabelsSelectedStateAndAccessibleControlBounds() {
composeRule.setContent {
ABoxTheme {
SettingsScreen(
state = SettingsUiState(
preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3),
loaded = true,
),
onAction = {},
)
}
}
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)
}
@Test
fun switchingRendererKeepsCurrentlyRenderedTestScreenPresent() {
var preferences by mutableStateOf(UiPreferences(renderer = DesignRenderer.MATERIAL3))
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),
)
}
}
}
composeRule.onNodeWithText("Miuix").performClick()
composeRule.waitForIdle()
composeRule.onNodeWithText("当前测试内容").assertIsDisplayed()
composeRule.onNodeWithText("Miuix").assertIsSelected()
}
}
@@ -0,0 +1,92 @@
package com.flagship.abox.manager.ui.shell
import androidx.activity.ComponentActivity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.material3.Text
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.ABoxTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class MainAppShellTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun compactUsesBottomNavigationAndDelegatesTabClick() {
var selectedTab: MainTab? = null
composeRule.setContent {
ABoxTheme {
MainAppShell(
currentTab = MainTab.DEVICES,
windowSizeClass = ABoxWindowSizeClass.COMPACT,
userName = "user",
providerName = "ABox",
onTabClick = { selectedTab = it },
onProviderClick = {},
onAvatarClick = {},
content = { _: PaddingValues -> Text("content") },
)
}
}
composeRule.onNodeWithTag("main_tab_devices").assertIsDisplayed()
composeRule.onNodeWithTag("main_tab_scenes").performClick()
assertEquals(MainTab.SCENES, selectedTab)
}
@Test
fun mediumAndExpandedExposeRailNavigation() {
composeRule.setContent {
ABoxTheme {
MainAppShell(
currentTab = MainTab.DEVICES,
windowSizeClass = ABoxWindowSizeClass.MEDIUM,
userName = "user",
providerName = "ABox",
onTabClick = {},
onProviderClick = {},
onAvatarClick = {},
content = { _: PaddingValues -> Text("content") },
)
}
}
composeRule.onNodeWithText("Scenes").assertIsDisplayed()
}
@Test
fun topAreaDelegatesProviderAndAvatarClicks() {
var providerClicks = 0
var avatarClicks = 0
composeRule.setContent {
ABoxTheme {
MainAppShell(
currentTab = MainTab.DEVICES,
windowSizeClass = ABoxWindowSizeClass.COMPACT,
userName = "user",
providerName = "ABox",
onTabClick = {},
onProviderClick = { providerClicks++ },
onAvatarClick = { avatarClicks++ },
content = { _: PaddingValues -> Text("content") },
)
}
}
composeRule.onNodeWithTag("main_provider_pill").performClick()
composeRule.onNodeWithTag("main_avatar").performClick()
assertEquals(1, providerClicks)
assertEquals(1, avatarClicks)
}
}
@@ -0,0 +1,21 @@
package com.flagship.abox.manager.di
import android.content.Context
import com.flagship.abox.manager.preferences.DataStoreUiPreferencesRepository
import com.flagship.abox.manager.preferences.UiPreferencesRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object UiPreferencesModule {
@Provides
@Singleton
fun provideUiPreferencesRepository(
@ApplicationContext context: Context,
): UiPreferencesRepository = DataStoreUiPreferencesRepository(context)
}
@@ -5,58 +5,53 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator import com.flagship.abox.manager.designsystem.ABoxLoadingIndicator
import com.flagship.abox.manager.designsystem.ABoxSurface import com.flagship.abox.manager.designsystem.ABoxSurface
import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.ProviderProfile import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
import com.flagship.abox.manager.ui.login.LoginRoute import com.flagship.abox.manager.ui.login.LoginRoute
import com.flagship.abox.manager.ui.login.LoginViewModel import com.flagship.abox.manager.ui.login.LoginViewModel
import com.flagship.abox.manager.ui.navigation.deviceDetailGraph import com.flagship.abox.manager.ui.navigation.AuthenticatedNavigation
import com.flagship.abox.manager.ui.navigation.navigateToSocket import com.flagship.abox.manager.ui.preferences.SettingsViewModel
import com.flagship.abox.manager.ui.navigation.navigateToTemperatureHumidity
import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute import com.flagship.abox.manager.ui.provider.ProviderSelectionRoute
import com.flagship.abox.manager.ui.scene.SceneListScreen
import com.flagship.abox.manager.ui.scene.SceneRoutes
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
import com.flagship.abox.manager.ui.session.AppSessionUiState import com.flagship.abox.manager.ui.session.AppSessionUiState
import com.flagship.abox.manager.ui.session.AppSessionViewModel import com.flagship.abox.manager.ui.session.AppSessionViewModel
import com.flagship.abox.manager.ui.session.SessionActionsViewModel import com.flagship.abox.manager.ui.session.SessionActionsViewModel
import com.flagship.abox.manager.ui.shell.MainAppShell import com.flagship.abox.manager.ui.shell.AccountProviderBottomSheet
@Composable @Composable
fun ABoxManagerApp( fun ABoxManagerApp(
appSessionViewModel: AppSessionViewModel = hiltViewModel(), appSessionViewModel: AppSessionViewModel = hiltViewModel(),
sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(), sessionActionsViewModel: SessionActionsViewModel = hiltViewModel(),
loginViewModel: LoginViewModel = hiltViewModel(), loginViewModel: LoginViewModel = hiltViewModel(),
settingsViewModel: SettingsViewModel = hiltViewModel(),
) { ) {
val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value val actionState = sessionActionsViewModel.uiState.collectAsStateWithLifecycle().value
val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value val sessionState = appSessionViewModel.uiState.collectAsStateWithLifecycle().value
val settingsState = settingsViewModel.uiState.collectAsStateWithLifecycle().value
val actionError = actionState.errorMessage?.asString()
val screenKey = sessionState.screenKey() val screenKey = sessionState.screenKey()
LaunchedEffect(screenKey) { LaunchedEffect(screenKey) {
sessionActionsViewModel.dismissError() sessionActionsViewModel.dismissError()
if (sessionState !is AppSessionUiState.Login) { if (sessionState !is AppSessionUiState.Login) {
loginViewModel.clearSensitiveState() loginViewModel.clearSensitiveState()
} }
} }
ABoxTheme {
ABoxTheme(preferences = settingsState.preferences) {
ABoxSurface(modifier = Modifier.fillMaxSize()) { ABoxSurface(modifier = Modifier.fillMaxSize()) {
when (val state = sessionState) { when (val state = sessionState) {
AppSessionUiState.Loading -> Box( AppSessionUiState.Loading -> Box(
@@ -71,7 +66,7 @@ fun ABoxManagerApp(
is AppSessionUiState.Login -> LoginRoute( is AppSessionUiState.Login -> LoginRoute(
profile = state.profile, profile = state.profile,
bootstrapError = state.bootstrapError, bootstrapError = state.bootstrapError,
actionError = actionState.errorMessage, actionError = actionError,
actionInProgress = actionState.inProgress, actionInProgress = actionState.inProgress,
onSwitchProvider = sessionActionsViewModel::switchProvider, onSwitchProvider = sessionActionsViewModel::switchProvider,
viewModel = loginViewModel, viewModel = loginViewModel,
@@ -80,7 +75,7 @@ fun ABoxManagerApp(
username = state.username, username = state.username,
profile = state.profile, profile = state.profile,
actionInProgress = actionState.inProgress, actionInProgress = actionState.inProgress,
errorMessage = actionState.errorMessage, errorMessage = actionError,
onLogout = sessionActionsViewModel::logout, onLogout = sessionActionsViewModel::logout,
) )
} }
@@ -88,6 +83,7 @@ fun ABoxManagerApp(
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AuthenticatedNavHost( private fun AuthenticatedNavHost(
username: Username, username: Username,
@@ -96,100 +92,25 @@ private fun AuthenticatedNavHost(
errorMessage: String?, errorMessage: String?,
onLogout: () -> Unit, onLogout: () -> Unit,
) { ) {
val navController = rememberNavController() var showAccountSheet by remember { mutableStateOf(false) }
NavHost(
navController = navController, AuthenticatedNavigation(
startDestination = "home", username = username,
) { profile = profile,
composable("home") { onLogout = onLogout,
MainAppShell( onProviderClick = { showAccountSheet = true },
onAvatarClick = { showAccountSheet = true },
)
if (showAccountSheet) {
AccountProviderBottomSheet(
userName = username.value, userName = username.value,
providerName = profile.displayName, providerName = profile.displayName,
onDismissRequest = { showAccountSheet = false },
onLogout = onLogout, onLogout = onLogout,
actionInProgress = actionInProgress, actionInProgress = actionInProgress,
errorMessage = errorMessage, errorMessage = errorMessage,
devicesSlot = {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
DeviceListScreen(
onSocketClick = {
navController.navigateToSocket(
profileId = profile.id,
deviceName = DeviceName("Zigbee插座控制器"),
) )
},
onTemperatureHumidityClick = {
navController.navigateToTemperatureHumidity(
profileId = profile.id,
deviceName = DeviceName("温湿度传感器"),
)
},
onInfraredClick = {
navController.navigate("infrared_hub/$encodedProfileId")
},
)
},
scenesSlot = {
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)) },
)
},
)
}
deviceDetailGraph(
onBackClick = navController::popBackStack,
)
sceneEditorDestination(
onBack = { navController.popBackStack() },
onSaved = { navController.popBackStack() },
)
sceneExecutionDestination(
onBack = { navController.popBackStack() },
)
composable(
route = "infrared_hub/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
val encodedProfileId = android.net.Uri.encode(profile.id.value)
InfraredHubRoute(
onBack = { navController.popBackStack() },
onOpenRemote = { remoteId ->
val encodedRemoteId = android.net.Uri.encode(remoteId)
navController.navigate("infrared_panel/$encodedProfileId/$encodedRemoteId")
},
onOpenSettings = {
navController.navigate("infrared_settings/$encodedProfileId")
},
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_panel/{profileId}/{remoteId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("remoteId") { type = NavType.StringType },
),
) {
RemotePanelRoute(
onBack = { navController.popBackStack() },
onSessionInvalid = onLogout,
)
}
composable(
route = "infrared_settings/{profileId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
),
) {
InfraredControlRoute(
onBack = { navController.popBackStack() },
onSessionInvalid = onLogout,
)
}
} }
} }
@@ -13,13 +13,6 @@ object DomainErrorMapper {
is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request) is DomainError.InvalidRequest -> UiText.StringResource(R.string.error_invalid_request)
is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed) is DomainError.OperationFailed -> UiText.StringResource(R.string.error_operation_failed)
is DomainError.NoData -> UiText.StringResource(R.string.error_no_data) is DomainError.NoData -> UiText.StringResource(R.string.error_no_data)
is DomainError.Unknown -> { is DomainError.Unknown -> UiText.StringResource(R.string.error_unknown)
val detail = error.detail
if (!detail.isNullOrBlank()) {
UiText.DynamicString(detail)
} else {
UiText.StringResource(R.string.error_unknown)
}
}
} }
} }
@@ -0,0 +1,499 @@
package com.flagship.abox.manager.ui.navigation
import android.net.Uri
import androidx.activity.compose.BackHandler
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
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.platform.LocalConfiguration
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.infrared.hub.InfraredHubRoute
import com.flagship.abox.manager.infrared.panel.RemotePanelRoute
import com.flagship.abox.manager.infrared.ui.InfraredControlRoute
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
import com.flagship.abox.manager.ui.scene.SceneListScreen
import com.flagship.abox.manager.ui.scene.SceneRoutes
import com.flagship.abox.manager.ui.scene.sceneEditorDestination
import com.flagship.abox.manager.ui.scene.sceneExecutionDestination
import com.flagship.abox.manager.ui.shell.ABoxWindowSizeClass
import com.flagship.abox.manager.ui.shell.DetailPresentation
import com.flagship.abox.manager.ui.shell.DeviceSelection
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.ui.socket.SocketScreen
import com.flagship.abox.manager.ui.socket.SocketUiEvent
import com.flagship.abox.manager.ui.socket.SocketViewModel
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityScreen
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityUiEvent
import com.flagship.abox.manager.ui.temperaturehumidity.TemperatureHumidityViewModel
import java.net.URLDecoder
import java.nio.charset.StandardCharsets
/** Pure authenticated graph state for tab, detail, and profile continuity tests. */
data class AuthenticatedGraphState(
val profileId: ProviderProfileId,
val currentTab: MainTab = MainTab.DEVICES,
private val backStacks: Map<MainTab, List<String>> = MainTab.entries.associateWith { listOf(it.route) },
val selectedDevice: DeviceSelection? = null,
) {
fun selectTab(tab: MainTab): AuthenticatedGraphState {
val nextStack = if (tab == currentTab) {
listOf(tab.route)
} else {
backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) }
}
return copy(
currentTab = tab,
backStacks = backStacks + (tab to nextStack),
selectedDevice = null,
)
}
fun pushChild(route: String): AuthenticatedGraphState = copy(
backStacks = backStacks + (currentTab to (backStacks[currentTab].orEmpty() + route)),
)
fun tabBackStack(tab: MainTab): List<String> =
backStacks[tab].orEmpty().ifEmpty { listOf(tab.route) }
fun recreateForProfile(newProfileId: ProviderProfileId): AuthenticatedGraphState =
AuthenticatedGraphState(profileId = newProfileId)
fun selectDevice(deviceName: DeviceName, type: DeviceType): AuthenticatedGraphState = copy(
selectedDevice = DeviceSelection(profileId, deviceName, type),
)
fun handleBack(): AuthenticatedGraphState = when {
selectedDevice != null -> copy(selectedDevice = null)
tabBackStack(currentTab).size > 1 -> copy(
backStacks = backStacks + (currentTab to tabBackStack(currentTab).dropLast(1)),
)
else -> this
}
}
data class AuthenticatedNavigationSnapshot(
val currentRoute: String?,
val selectedDevice: DeviceSelection?,
val detailPresentation: DetailPresentation?,
)
private val NullableDeviceSelectionSaver: Saver<DeviceSelection?, Any> = DeviceSelection.Saver
/**
* Navigation-owned state host. The authenticated graph and routing tests share this exact state
* machine so saved selection, route transitions, resize reconciliation, and BackHandler behavior
* cannot drift apart.
*/
@Composable
fun AuthenticatedNavigationStateHost(
profileId: ProviderProfileId,
windowSizeClass: ABoxWindowSizeClass,
navController: NavHostController,
deviceRoute: (DeviceSelection) -> String,
selectionForRoute: (String?) -> DeviceSelection? = { null },
content: @Composable (
snapshot: AuthenticatedNavigationSnapshot,
onTabClick: (MainTab) -> Unit,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
) -> Unit,
) {
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.actualRoute()
val routeSelection = selectionForRoute(currentRoute)?.takeIf { it.profileId == profileId }
var savedSelection by rememberSaveable(
profileId.value,
stateSaver = NullableDeviceSelectionSaver,
) {
mutableStateOf<DeviceSelection?>(null)
}
val currentTab = MainTab.fromRoute(currentRoute) ?: MainTab.DEVICES
val selectedDevice = when {
routeSelection != null -> routeSelection
currentRoute == MainTab.DEVICES.route -> savedSelection?.takeIf { it.profileId == profileId }
else -> null
}
val selectedPresentation = selectedDevice?.let {
detailPresentation(windowSizeClass, it)
}
LaunchedEffect(profileId, currentRoute, selectedDevice, windowSizeClass) {
if (savedSelection?.profileId != null && savedSelection?.profileId != profileId) {
savedSelection = null
return@LaunchedEffect
}
val selection = selectedDevice ?: return@LaunchedEffect
if (savedSelection != selection) {
savedSelection = selection
}
when (detailPresentation(windowSizeClass, selection)) {
DetailPresentation.PANE -> {
if (currentRoute != MainTab.DEVICES.route) {
navController.popBackStack(MainTab.DEVICES.route, inclusive = false)
}
}
DetailPresentation.FULL_SCREEN -> {
if (currentRoute == MainTab.DEVICES.route) {
navController.navigate(deviceRoute(selection)) {
launchSingleTop = true
}
}
}
}
}
val snapshot = AuthenticatedNavigationSnapshot(
currentRoute = currentRoute,
selectedDevice = selectedDevice,
detailPresentation = selectedPresentation,
)
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 },
)
BackHandler(enabled = selectedDevice != null || navController.previousBackStackEntry != null) {
savedSelection = null
if (currentRoute != MainTab.DEVICES.route) {
navController.popBackStack()
}
}
}
@Composable
fun AuthenticatedNavigation(
username: Username,
profile: ProviderProfile,
onLogout: () -> Unit,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
windowSizeClass: ABoxWindowSizeClass? = null,
) {
val resolvedWindowSizeClass = windowSizeClass ?: widthClass(LocalConfiguration.current.screenWidthDp)
key(profile.id.value) {
val navController = rememberNavController()
AuthenticatedNavigationStateHost(
profileId = profile.id,
windowSizeClass = resolvedWindowSizeClass,
navController = navController,
deviceRoute = { selection ->
when (selection.type) {
DeviceType.SOCKET -> DeviceDetailRoutes.socketRoute(profile.id, selection.deviceName)
DeviceType.TEMPERATURE_HUMIDITY -> DeviceDetailRoutes.temperatureHumidityRoute(
profile.id,
selection.deviceName,
)
DeviceType.INFRARED -> "${Routes.INFRARED_HUB}/${Uri.encode(profile.id.value)}"
}
},
selectionForRoute = { route -> profile.selectionForRoute(route) },
) { snapshot, onTabClick, onSelectDevice, onClearDeviceSelection ->
val currentTab = MainTab.fromRoute(snapshot.currentRoute) ?: MainTab.DEVICES
MainAppShell(
currentTab = currentTab,
windowSizeClass = resolvedWindowSizeClass,
userName = username.value,
providerName = profile.displayName,
onTabClick = onTabClick,
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,
)
},
)
}
}
}
@Composable
private fun AuthenticatedGraphContent(
navController: NavHostController,
profile: ProviderProfile,
paddingValues: PaddingValues,
isExpandedDevicePane: Boolean,
selectedDevice: DeviceSelection?,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
onLogout: () -> 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,
)
DeviceDetailPane(
modifier = Modifier.weight(1f),
selection = selectedDevice,
onBack = onClearDeviceSelection,
)
}
} else {
AuthenticatedNavHost(
modifier = Modifier.fillMaxSize(),
navController = navController,
profile = profile,
onSelectDevice = onSelectDevice,
onClearDeviceSelection = onClearDeviceSelection,
onLogout = onLogout,
)
}
}
}
@Composable
private fun AuthenticatedNavHost(
modifier: Modifier,
navController: NavHostController,
profile: ProviderProfile,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
onClearDeviceSelection: () -> Unit,
onLogout: () -> Unit,
) {
NavHost(
navController = navController,
startDestination = MainTab.DEVICES.route,
modifier = modifier,
) {
composable(MainTab.DEVICES.route) {
DeviceRoot(onSelectDevice = onSelectDevice)
}
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)) },
)
}
composable(MainTab.ACTIVITY.route) {
ActivityDestination()
}
deviceDetailGraph(
onBackClick = {
onClearDeviceSelection()
navController.popBackStack()
},
)
sceneEditorDestination(
onBack = navController::popBackStack,
onSaved = { navController.popBackStack(MainTab.SCENES.route, inclusive = false) },
)
sceneExecutionDestination(onBack = navController::popBackStack)
composable(
route = "${Routes.INFRARED_HUB}/{profileId}",
arguments = listOf(navArgument("profileId") { type = NavType.StringType }),
) {
InfraredHubRoute(
onBack = {
onClearDeviceSelection()
navController.popBackStack()
},
onOpenRemote = { remoteId ->
navController.navigate(
"${Routes.INFRARED_PANEL}/${Uri.encode(profile.id.value)}/${Uri.encode(remoteId)}",
)
},
onOpenSettings = {
navController.navigate("${Routes.INFRARED_SETTINGS}/${Uri.encode(profile.id.value)}")
},
onSessionInvalid = onLogout,
)
}
composable(
route = "${Routes.INFRARED_PANEL}/{profileId}/{remoteId}",
arguments = listOf(
navArgument("profileId") { type = NavType.StringType },
navArgument("remoteId") { type = NavType.StringType },
),
) {
RemotePanelRoute(
onBack = navController::popBackStack,
onSessionInvalid = onLogout,
)
}
composable(
route = "${Routes.INFRARED_SETTINGS}/{profileId}",
arguments = listOf(navArgument("profileId") { type = NavType.StringType }),
) {
InfraredControlRoute(
onBack = navController::popBackStack,
onSessionInvalid = onLogout,
)
}
}
}
@Composable
private fun DeviceRoot(
modifier: Modifier = Modifier,
onSelectDevice: (DeviceName, DeviceType) -> Unit,
) {
DeviceListScreen(
modifier = modifier,
onSocketClick = { onSelectDevice(DeviceName("Zigbee插座控制器"), DeviceType.SOCKET) },
onTemperatureHumidityClick = {
onSelectDevice(DeviceName("温湿度传感器"), DeviceType.TEMPERATURE_HUMIDITY)
},
onInfraredClick = { onSelectDevice(DeviceName("红外遥控器"), DeviceType.INFRARED) },
)
}
@Composable
private fun DeviceDetailPane(
selection: DeviceSelection,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
val viewModelKey = "${selection.profileId.value}:${selection.deviceName.value}:${selection.type.name}"
when (selection.type) {
DeviceType.SOCKET -> {
val viewModel: SocketViewModel = hiltViewModel(key = viewModelKey)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(selection) {
viewModel.onEvent(SocketUiEvent.Initialize(selection.profileId, selection.deviceName))
}
SocketScreen(
state = state,
onEvent = viewModel::onEvent,
onBackClick = onBack,
modifier = modifier,
)
}
DeviceType.TEMPERATURE_HUMIDITY -> {
val viewModel: TemperatureHumidityViewModel = hiltViewModel(key = viewModelKey)
val state by viewModel.state.collectAsStateWithLifecycle()
LaunchedEffect(selection) {
viewModel.onEvent(
TemperatureHumidityUiEvent.Initialize(selection.profileId, selection.deviceName),
)
}
TemperatureHumidityScreen(
state = state,
onEvent = viewModel::onEvent,
onBackClick = onBack,
modifier = modifier,
)
}
DeviceType.INFRARED -> Unit
}
}
@Composable
private fun ActivityDestination() {
Box(modifier = Modifier.fillMaxSize().padding(24.dp)) {
Text(
text = androidx.compose.ui.res.stringResource(R.string.activity_placeholder_title),
style = MaterialTheme.typography.headlineMedium,
)
}
}
private fun ProviderProfile.selectionForRoute(route: String?): DeviceSelection? {
val parts = route?.split('/') ?: return null
return when {
parts.size >= 4 && parts[0] == "device" && parts[1] == "socket" -> DeviceSelection(
profileId = id,
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
type = DeviceType.SOCKET,
)
parts.size >= 4 && parts[0] == "device" && parts[1] == "th" -> DeviceSelection(
profileId = id,
deviceName = DeviceName(parts.drop(3).joinToString("/").decodeRoutePart()),
type = DeviceType.TEMPERATURE_HUMIDITY,
)
parts.size >= 2 && parts[0] == Routes.INFRARED_HUB -> DeviceSelection(
profileId = id,
deviceName = DeviceName("红外遥控器"),
type = DeviceType.INFRARED,
)
else -> null
}
}
private fun String.decodeRoutePart(): String = runCatching {
URLDecoder.decode(this, StandardCharsets.UTF_8.name())
}.getOrDefault(this)
private fun NavBackStackEntry.actualRoute(): String? {
var route = destination.route ?: return null
destination.arguments.keys.forEach { argumentName ->
arguments?.getString(argumentName)?.let { value ->
route = route.replace("{$argumentName}", Uri.encode(value))
}
}
return route
}
@@ -4,7 +4,9 @@ object Routes {
const val STARTUP = "startup" const val STARTUP = "startup"
const val PROVIDER_SELECTION = "provider_selection" const val PROVIDER_SELECTION = "provider_selection"
const val LOGIN = "login" const val LOGIN = "login"
/** Login-complete route contract; the device navigation owner replaces its placeholder. */
const val AUTHENTICATED = "authenticated" const val AUTHENTICATED = "authenticated"
const val INFRARED_HUB = "infrared_hub"
const val INFRARED_PANEL = "infrared_panel"
const val INFRARED_SETTINGS = "infrared_settings"
} }
@@ -0,0 +1,227 @@
package com.flagship.abox.manager.ui.preferences
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.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.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.NavigationPresentation
@Composable
fun SettingsRoute(
viewModel: SettingsViewModel = hiltViewModel(),
) {
val state = viewModel.uiState.collectAsStateWithLifecycle().value
SettingsScreen(state = state, onAction = viewModel::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(),
)
}
}
}
}
@Composable
private fun ThemeChip(
mode: AppThemeMode,
state: SettingsUiState,
onAction: (SettingsUiAction) -> Unit,
labelRes: Int,
) {
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)
}
content()
}
}
@Composable
private fun SwitchPreference(
title: String,
description: String,
checked: Boolean,
modifier: Modifier,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
ABoxSwitch(
checked = checked,
onCheckedChange = onCheckedChange,
contentDescription = title,
modifier = modifier,
)
}
}
@@ -0,0 +1,122 @@
package com.flagship.abox.manager.ui.preferences
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.DesignRenderer
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.PreferenceField
import com.flagship.abox.manager.preferences.UiPreferences
import com.flagship.abox.manager.preferences.UiPreferencesRepository
import com.flagship.abox.manager.preferences.UiPreferencesWriteResult
import com.flagship.abox.manager.ui.common.UiText
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val repository: UiPreferencesRepository,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
private val observeJob: Job = viewModelScope.launch {
repository.observe().collect { preferences ->
_uiState.value = _uiState.value.copy(
preferences = preferences,
loaded = true,
)
}
}
fun onAction(action: SettingsUiAction) {
when (action) {
is SettingsUiAction.SetRenderer -> update(
field = PreferenceField.RENDERER,
transform = { it.copy(renderer = action.value) },
)
is SettingsUiAction.SetThemeMode -> update(
field = PreferenceField.THEME_MODE,
transform = { it.copy(themeMode = action.value) },
)
is SettingsUiAction.SetDynamicColor -> update(
field = PreferenceField.DYNAMIC_COLOR,
transform = { it.copy(dynamicColorEnabled = action.value) },
)
is SettingsUiAction.SetBlur -> update(
field = PreferenceField.BLUR,
transform = { it.copy(blurEnabled = action.value) },
)
is SettingsUiAction.SetNavigation -> update(
field = PreferenceField.NAVIGATION,
transform = { it.copy(navigationPresentation = action.value) },
)
is SettingsUiAction.SetHaptics -> update(
field = PreferenceField.HAPTICS,
transform = { it.copy(hapticsEnabled = action.value) },
)
SettingsUiAction.DismissError -> _uiState.value = _uiState.value.copy(error = null)
}
}
private fun update(
field: PreferenceField,
transform: (UiPreferences) -> UiPreferences,
) {
if (field in _uiState.value.saving) return
_uiState.value = _uiState.value.copy(
saving = _uiState.value.saving + field,
error = null,
)
viewModelScope.launch {
try {
when (repository.update(transform)) {
UiPreferencesWriteResult.Success -> Unit
UiPreferencesWriteResult.Failure -> showWriteFailure(field)
}
} catch (cause: CancellationException) {
throw cause
} catch (_: Throwable) {
showWriteFailure(field)
} finally {
_uiState.value = _uiState.value.copy(saving = _uiState.value.saving - field)
}
}
}
private fun showWriteFailure(field: PreferenceField) {
_uiState.value = _uiState.value.copy(
error = UiText.StringResource(R.string.settings_error_save),
saving = _uiState.value.saving - field,
)
}
override fun onCleared() {
observeJob.cancel()
super.onCleared()
}
}
data class SettingsUiState(
val preferences: UiPreferences = UiPreferences(),
val loaded: Boolean = false,
val saving: Set<PreferenceField> = emptySet(),
val error: UiText? = null,
)
sealed interface SettingsUiAction {
data class SetRenderer(val value: DesignRenderer) : SettingsUiAction
data class SetThemeMode(val value: AppThemeMode) : SettingsUiAction
data class SetDynamicColor(val value: Boolean) : SettingsUiAction
data class SetBlur(val value: Boolean) : SettingsUiAction
data class SetNavigation(val value: NavigationPresentation) : SettingsUiAction
data class SetHaptics(val value: Boolean) : SettingsUiAction
data object DismissError : SettingsUiAction
}
@@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.flagship.abox.manager.domain.DomainResult import com.flagship.abox.manager.domain.DomainResult
import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.ui.common.DomainErrorMapper
import com.flagship.abox.manager.ui.common.UiText
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -18,9 +21,21 @@ class SessionActionsViewModel @Inject constructor(
private val _uiState = MutableStateFlow(SessionActionsUiState()) private val _uiState = MutableStateFlow(SessionActionsUiState())
val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SessionActionsUiState> = _uiState.asStateFlow()
fun onAction(action: SessionUiAction) {
when (action) {
SessionUiAction.EndSession -> runAction(coordinator::endActiveSession)
SessionUiAction.ForgetSavedCredentials -> runAction(coordinator::forgetSavedCredentials)
SessionUiAction.SwitchProvider -> runAction(coordinator::switchProvider)
SessionUiAction.ValidateConnection -> runValidation()
SessionUiAction.DismissError -> dismissError()
}
}
fun dispatch(action: SessionUiAction) = onAction(action)
fun logout() = runAction(coordinator::logout) fun logout() = runAction(coordinator::logout)
fun switchProvider() = runAction(coordinator::switchProvider) fun switchProvider() = onAction(SessionUiAction.SwitchProvider)
fun dismissError() { fun dismissError() {
_uiState.value = _uiState.value.copy(errorMessage = null) _uiState.value = _uiState.value.copy(errorMessage = null)
@@ -32,7 +47,19 @@ class SessionActionsViewModel @Inject constructor(
_uiState.value = SessionActionsUiState(inProgress = true) _uiState.value = SessionActionsUiState(inProgress = true)
val result = action() val result = action()
_uiState.value = SessionActionsUiState( _uiState.value = SessionActionsUiState(
errorMessage = (result as? DomainResult.Failure)?.error?.detail, errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
)
}
}
private fun runValidation() {
if (_uiState.value.inProgress) return
viewModelScope.launch {
_uiState.value = SessionActionsUiState(inProgress = true)
val result = coordinator.validateActiveSession()
_uiState.value = SessionActionsUiState(
validity = (result as? DomainResult.Success)?.value,
errorMessage = (result as? DomainResult.Failure)?.error?.let(DomainErrorMapper::map),
) )
} }
} }
@@ -40,5 +67,14 @@ class SessionActionsViewModel @Inject constructor(
data class SessionActionsUiState( data class SessionActionsUiState(
val inProgress: Boolean = false, val inProgress: Boolean = false,
val errorMessage: String? = null, val validity: SessionValidity? = null,
val errorMessage: UiText? = null,
) )
sealed interface SessionUiAction {
data object EndSession : SessionUiAction
data object ForgetSavedCredentials : SessionUiAction
data object SwitchProvider : SessionUiAction
data object ValidateConnection : SessionUiAction
data object DismissError : SessionUiAction
}
@@ -0,0 +1,79 @@
package com.flagship.abox.manager.ui.shell
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.saveable.Saver
import com.flagship.abox.manager.domain.DeviceName
import com.flagship.abox.manager.domain.DeviceType
import com.flagship.abox.manager.domain.ProviderProfileId
/** Width buckets used by the authenticated shell. */
enum class ABoxWindowSizeClass {
COMPACT,
MEDIUM,
EXPANDED,
}
fun widthClass(widthDp: Int): ABoxWindowSizeClass = when {
widthDp < 600 -> ABoxWindowSizeClass.COMPACT
widthDp < 840 -> ABoxWindowSizeClass.MEDIUM
else -> ABoxWindowSizeClass.EXPANDED
}
enum class MainNavigationPresentation {
BOTTOM_NAVIGATION,
NAVIGATION_RAIL,
}
fun navigationPresentation(windowSizeClass: ABoxWindowSizeClass): MainNavigationPresentation = when (windowSizeClass) {
ABoxWindowSizeClass.COMPACT -> MainNavigationPresentation.BOTTOM_NAVIGATION
ABoxWindowSizeClass.MEDIUM,
ABoxWindowSizeClass.EXPANDED,
-> MainNavigationPresentation.NAVIGATION_RAIL
}
@Immutable
data class DeviceSelection(
val profileId: ProviderProfileId,
val deviceName: DeviceName,
val type: DeviceType,
) {
companion object {
/** Saver used by navigation-owned state so selection survives recreation and resize. */
val Saver: Saver<DeviceSelection?, Any> = Saver(
save = { selection ->
selection?.let {
listOf(it.profileId.value, it.deviceName.value, it.type.name)
}
},
restore = { saved ->
val values = saved as? List<*> ?: return@Saver null
if (values.size != 3) return@Saver null
val profileId = values[0] as? String ?: return@Saver null
val deviceName = values[1] as? String ?: return@Saver null
val type = (values[2] as? String)?.let { value ->
runCatching { DeviceType.valueOf(value) }.getOrNull()
} ?: return@Saver null
DeviceSelection(
profileId = ProviderProfileId(profileId),
deviceName = DeviceName(deviceName),
type = type,
)
},
)
}
}
enum class DetailPresentation {
FULL_SCREEN,
PANE,
}
fun detailPresentation(
windowSizeClass: ABoxWindowSizeClass,
selection: DeviceSelection,
): DetailPresentation = when {
windowSizeClass == ABoxWindowSizeClass.EXPANDED && selection.type != DeviceType.INFRARED -> {
DetailPresentation.PANE
}
else -> DetailPresentation.FULL_SCREEN
}
@@ -1,179 +1,204 @@
package com.flagship.abox.manager.ui.shell package com.flagship.abox.manager.ui.shell
import androidx.compose.animation.Crossfade import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
import com.flagship.abox.manager.designsystem.ABoxNavigationBar import com.flagship.abox.manager.designsystem.ABoxNavigationBar
import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem import com.flagship.abox.manager.designsystem.ABoxNavigationBarItem
import com.flagship.abox.manager.designsystem.ABoxTopPillBar import com.flagship.abox.manager.designsystem.ABoxUserAvatar
import com.flagship.abox.manager.ui.devicecontrollist.DeviceListScreen
/** /** Stateless authenticated shell; navigation state belongs to AuthenticatedNavigation. */
* Main authenticated application shell.
*
* Provides:
* 1. A floating/pinned pill top app bar with provider context, '+' action, and avatar account sheet.
* 2. Material 3 bottom navigation bar with pill indicator for Devices, Scenes, and Activity tabs.
* 3. Clear composable slot boundaries for team member integrations.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainAppShell( fun MainAppShell(
currentTab: MainTab,
windowSizeClass: ABoxWindowSizeClass,
userName: String, userName: String,
providerName: String, providerName: String,
onLogout: () -> Unit, onTabClick: (MainTab) -> Unit,
modifier: Modifier = Modifier, onProviderClick: () -> Unit,
initialTab: MainTab = MainTab.DEVICES, onAvatarClick: () -> Unit,
onTabSelected: (MainTab) -> Unit = {}, content: @Composable (PaddingValues) -> Unit,
onAddClick: (() -> Unit)? = null,
actionInProgress: Boolean = false,
errorMessage: String? = null,
devicesSlot: @Composable () -> Unit = { DeviceListScreen() },
scenesSlot: @Composable () -> Unit = { DefaultScenesSlot() },
activitySlot: @Composable () -> Unit = { DefaultActivitySlot() },
) { ) {
var activeTab by rememberSaveable { mutableStateOf(initialTab) } val useRail = navigationPresentation(windowSizeClass) == MainNavigationPresentation.NAVIGATION_RAIL
var showAccountSheet by remember { mutableStateOf(false) }
val tabStateHolder = rememberSaveableStateHolder()
Scaffold( Scaffold(
modifier = modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
topBar = { topBar = {
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)) },
)
}
}
}
},
) { innerPadding ->
Row(
modifier = Modifier.fillMaxSize(),
) {
if (useRail) {
MainNavigationRail(
currentTab = currentTab,
onTabClick = onTabClick,
)
}
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
content(innerPadding)
}
}
}
}
@Composable
private fun ProviderTopBar(
currentTab: MainTab,
providerName: String,
userName: String,
onProviderClick: () -> Unit,
onAvatarClick: () -> Unit,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.statusBarsPadding() .statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
ABoxTopPillBar( Surface(
title = stringResource(R.string.top_bar_prompt_format, providerName), modifier = Modifier.fillMaxWidth(),
userName = userName, shape = RoundedCornerShape(32.dp),
onAvatarClick = { showAccountSheet = true }, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
onAddClick = onAddClick, tonalElevation = 2.dp,
addActionContentDescription = stringResource(R.string.action_add), ) {
avatarContentDescription = stringResource(R.string.action_account), Row(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.testTag("main_provider_pill")
.clickable(
onClickLabel = stringResource(R.string.account_sheet_provider),
onClick = onProviderClick,
)
.padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Outlined.Bolt,
contentDescription = providerName,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.width(22.dp),
)
Text(
text = providerName,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 8.dp),
) )
} }
}, Text(
bottomBar = { text = stringResource(currentTab.labelRes),
ABoxNavigationBar { style = MaterialTheme.typography.titleMedium,
MainTab.values().forEach { tab -> color = MaterialTheme.colorScheme.onSurfaceVariant,
val isSelected = (tab == activeTab) maxLines = 1,
ABoxNavigationBarItem( overflow = TextOverflow.Ellipsis,
selected = isSelected, modifier = Modifier
onClick = { .weight(1f)
activeTab = tab .padding(horizontal = 8.dp),
onTabSelected(tab) )
}, Box(modifier = Modifier.testTag("main_avatar")) {
ABoxUserAvatar(
name = userName,
onClick = onAvatarClick,
size = 36.dp,
contentDescription = stringResource(R.string.action_account),
)
}
}
}
}
}
@Composable
private fun MainNavigationRail(
currentTab: MainTab,
onTabClick: (MainTab) -> Unit,
) {
NavigationRail(
modifier = Modifier.fillMaxHeight(),
containerColor = MaterialTheme.colorScheme.surface,
) {
Spacer(modifier = Modifier.height(8.dp))
MainTab.entries.forEach { tab ->
val selected = tab == currentTab
NavigationRailItem(
selected = selected,
onClick = { onTabClick(tab) },
modifier = Modifier.testTag("main_tab_${tab.name.lowercase()}"),
icon = { icon = {
Icon( Icon(
imageVector = if (isSelected) tab.selectedIcon else tab.unselectedIcon, imageVector = if (selected) tab.selectedIcon else tab.unselectedIcon,
contentDescription = stringResource(tab.labelRes), contentDescription = stringResource(tab.labelRes),
) )
}, },
label = { label = { Text(stringResource(tab.labelRes)) },
Text(text = stringResource(tab.labelRes))
},
) )
} }
} }
},
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
) {
Crossfade(
targetState = activeTab,
label = "MainTabCrossfade",
) { tab ->
tabStateHolder.SaveableStateProvider(tab) {
when (tab) {
MainTab.DEVICES -> devicesSlot()
MainTab.SCENES -> scenesSlot()
MainTab.ACTIVITY -> activitySlot()
}
}
}
}
}
if (showAccountSheet) {
AccountProviderBottomSheet(
userName = userName,
providerName = providerName,
onDismissRequest = { showAccountSheet = false },
onLogout = onLogout,
actionInProgress = actionInProgress,
errorMessage = errorMessage,
)
}
}
@Composable
private fun DefaultScenesSlot() {
SlotPlaceholder(
title = stringResource(R.string.scenes_placeholder_title),
description = stringResource(R.string.scenes_placeholder_description),
)
}
@Composable
private fun DefaultActivitySlot() {
SlotPlaceholder(
title = stringResource(R.string.activity_placeholder_title),
description = stringResource(R.string.activity_placeholder_description),
)
}
@Composable
private fun SlotPlaceholder(
title: String,
description: String,
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 8.dp),
)
}
} }
@@ -11,9 +11,7 @@ import androidx.compose.material.icons.outlined.ViewAgenda
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import com.flagship.abox.manager.R import com.flagship.abox.manager.R
/** /** Main destinations available while authenticated. */
* Main bottom navigation tabs for the authenticated application shell.
*/
enum class MainTab( enum class MainTab(
@StringRes val labelRes: Int, @StringRes val labelRes: Int,
val selectedIcon: ImageVector, val selectedIcon: ImageVector,
@@ -24,18 +22,30 @@ enum class MainTab(
labelRes = R.string.tab_devices, labelRes = R.string.tab_devices,
selectedIcon = Icons.Filled.Home, selectedIcon = Icons.Filled.Home,
unselectedIcon = Icons.Outlined.Home, unselectedIcon = Icons.Outlined.Home,
route = "shell/devices", route = "tab/devices",
), ),
SCENES( SCENES(
labelRes = R.string.tab_scenes, labelRes = R.string.tab_scenes,
selectedIcon = Icons.Filled.AutoAwesome, selectedIcon = Icons.Filled.AutoAwesome,
unselectedIcon = Icons.Outlined.AutoAwesome, unselectedIcon = Icons.Outlined.AutoAwesome,
route = "shell/scenes", route = "tab/scenes",
), ),
ACTIVITY( ACTIVITY(
labelRes = R.string.tab_activity, labelRes = R.string.tab_activity,
selectedIcon = Icons.Filled.ViewAgenda, selectedIcon = Icons.Filled.ViewAgenda,
unselectedIcon = Icons.Outlined.ViewAgenda, unselectedIcon = Icons.Outlined.ViewAgenda,
route = "shell/activity", route = "tab/activity",
), ),
;
companion object {
fun fromRoute(route: String?): MainTab? = when {
route == null -> null
route == DEVICES.route || route.startsWith("device/") ||
route.startsWith("infrared_") -> DEVICES
route == SCENES.route || route.startsWith("scene/") -> SCENES
route == ACTIVITY.route -> ACTIVITY
else -> null
}
}
} }
@@ -259,4 +259,32 @@
<string name="error_operation_failed">Operation failed. Please try again.</string> <string name="error_operation_failed">Operation failed. Please try again.</string>
<string name="error_no_data">No sensor data available.</string> <string name="error_no_data">No sensor data available.</string>
<string name="error_unknown">An unexpected error occurred.</string> <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_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>
</resources> </resources>
@@ -5,10 +5,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.android.tools.screenshot.PreviewTest import com.android.tools.screenshot.PreviewTest
import com.flagship.abox.manager.designsystem.ABoxTheme import com.flagship.abox.manager.designsystem.ABoxTheme
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@PreviewTest @PreviewTest
@Preview @Preview
@Composable @Composable
fun AppSmokePreview() { fun AppSmokePreview() {
ABoxTheme(darkTheme = false) { Text("ABox Manager") } ABoxTheme(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
Text("ABox Manager")
}
} }
@@ -0,0 +1,91 @@
package com.flagship.abox.manager.ui.navigation
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.ui.shell.ABoxWindowSizeClass
import com.flagship.abox.manager.ui.shell.DetailPresentation
import com.flagship.abox.manager.ui.shell.MainNavigationPresentation
import com.flagship.abox.manager.ui.shell.MainTab
import com.flagship.abox.manager.ui.shell.DeviceSelection
import com.flagship.abox.manager.ui.shell.detailPresentation
import com.flagship.abox.manager.ui.shell.navigationPresentation
import com.flagship.abox.manager.ui.shell.widthClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AuthenticatedNavigationTest {
@Test
fun `profile key change recreates authenticated graph`() {
val firstProfile = ProviderProfileId("first")
val secondProfile = ProviderProfileId("second")
val firstGraph = AuthenticatedGraphState(firstProfile)
.selectTab(MainTab.SCENES)
.selectDevice(DeviceName("socket"), DeviceType.SOCKET)
val recreated = firstGraph.recreateForProfile(secondProfile)
assertEquals(secondProfile, recreated.profileId)
assertEquals(MainTab.DEVICES, recreated.currentTab)
assertNull(recreated.selectedDevice)
}
@Test
fun `reselecting main tab pops to its root`() {
val state = AuthenticatedGraphState(ProviderProfileId("profile"))
.selectTab(MainTab.SCENES)
.pushChild("scene/editor/manual")
val reselected = state.selectTab(MainTab.SCENES)
assertEquals(MainTab.SCENES, reselected.currentTab)
assertEquals(listOf(MainTab.SCENES.route), reselected.tabBackStack(MainTab.SCENES))
}
@Test
fun `compact uses bottom navigation`() {
assertEquals(MainNavigationPresentation.BOTTOM_NAVIGATION, navigationPresentation(widthClass(599)))
}
@Test
fun `medium and expanded use rail`() {
assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(600)))
assertEquals(MainNavigationPresentation.NAVIGATION_RAIL, navigationPresentation(widthClass(840)))
}
@Test
fun `profile replacement clears previously selected device`() {
val state = AuthenticatedGraphState(ProviderProfileId("first"))
.selectDevice(DeviceName("sensor"), DeviceType.TEMPERATURE_HUMIDITY)
val replaced = state.recreateForProfile(ProviderProfileId("second"))
assertNull(replaced.selectedDevice)
}
@Test
fun `selected device uses pane on expanded layouts and full screen otherwise`() {
val selection = DeviceSelection(
profileId = ProviderProfileId("profile"),
deviceName = DeviceName("sensor"),
type = DeviceType.TEMPERATURE_HUMIDITY,
)
assertEquals(DetailPresentation.FULL_SCREEN, detailPresentation(widthClass(599), selection))
assertEquals(DetailPresentation.PANE, detailPresentation(widthClass(840), selection))
}
@Test
fun `back closes child route before leaving its main tab`() {
val state = AuthenticatedGraphState(ProviderProfileId("profile"))
.selectTab(MainTab.SCENES)
.pushChild("scene/editor/manual")
val afterBack = state.handleBack()
assertEquals(MainTab.SCENES, afterBack.currentTab)
assertEquals(listOf(MainTab.SCENES.route), afterBack.tabBackStack(MainTab.SCENES))
}
}
@@ -0,0 +1,111 @@
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.DesignRenderer
import com.flagship.abox.manager.ui.common.UiText.StringResource
import com.flagship.abox.manager.preferences.NavigationPresentation
import com.flagship.abox.manager.preferences.UiPreferences
import com.flagship.abox.manager.preferences.UiPreferencesRepository
import com.flagship.abox.manager.preferences.UiPreferencesWriteResult
import com.flagship.abox.manager.testing.MainDispatcherRule
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SettingsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
@Test
fun rendererUpdatePersistsWithoutCallingSessionCoordinator() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX))
advanceUntilIdle()
assertEquals(1, repository.updateCalls.size)
assertEquals(DesignRenderer.MIUIX, repository.value.renderer)
assertEquals(DesignRenderer.MIUIX, viewModel.uiState.value.preferences.renderer)
}
@Test
fun writeFailureLeavesLastValueAndShowsLocalizedError() = runTest {
val repository = RecordingUiPreferencesRepository(
initial = UiPreferences(renderer = DesignRenderer.MATERIAL3),
writeResult = UiPreferencesWriteResult.Failure,
)
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
viewModel.onAction(SettingsUiAction.SetRenderer(DesignRenderer.MIUIX))
advanceUntilIdle()
assertEquals(1, repository.updateCalls.size)
assertEquals(DesignRenderer.MATERIAL3, viewModel.uiState.value.preferences.renderer)
assertTrue(viewModel.uiState.value.error is StringResource)
assertEquals(
R.string.settings_error_save,
(viewModel.uiState.value.error as StringResource).resId,
)
assertTrue(viewModel.uiState.value.saving.isEmpty())
}
@Test
fun collectsRepositoryChanges() = runTest {
val repository = RecordingUiPreferencesRepository()
val viewModel = SettingsViewModel(repository)
advanceUntilIdle()
repository.emit(
UiPreferences(
renderer = DesignRenderer.MIUIX,
themeMode = AppThemeMode.DARK,
dynamicColorEnabled = false,
blurEnabled = true,
navigationPresentation = NavigationPresentation.FLOATING,
hapticsEnabled = false,
),
)
advanceUntilIdle()
assertTrue(viewModel.uiState.value.loaded)
assertEquals(repository.value, viewModel.uiState.value.preferences)
}
private class RecordingUiPreferencesRepository(
initial: UiPreferences = UiPreferences(),
private val writeResult: UiPreferencesWriteResult = UiPreferencesWriteResult.Success,
) : UiPreferencesRepository {
private val state = MutableStateFlow(initial)
val updateCalls = mutableListOf<(UiPreferences) -> UiPreferences>()
val value: UiPreferences get() = state.value
override fun observe(): Flow<UiPreferences> = state.asStateFlow()
override suspend fun update(
transform: (UiPreferences) -> UiPreferences,
): UiPreferencesWriteResult {
updateCalls += transform
if (writeResult is UiPreferencesWriteResult.Success) {
state.value = transform(state.value)
}
return writeResult
}
fun emit(value: UiPreferences) {
state.value = value
}
}
}
@@ -0,0 +1,197 @@
package com.flagship.abox.manager.ui.session
import com.flagship.abox.manager.R
import com.flagship.abox.manager.domain.DomainError
import com.flagship.abox.manager.domain.LoginCredentials
import com.flagship.abox.manager.domain.Password
import com.flagship.abox.manager.domain.ProviderProfile
import com.flagship.abox.manager.domain.ProviderProfileId
import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.Username
import com.flagship.abox.manager.testing.MainDispatcherRule
import com.flagship.abox.manager.testing.fakes.FakeCredentialStore
import com.flagship.abox.manager.testing.fakes.FakeLocalSessionGateway
import com.flagship.abox.manager.testing.fakes.FakeProviderProfileStore
import com.flagship.abox.manager.testing.fakes.FakeSessionGateway
import com.flagship.abox.manager.ui.common.UiText
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class SessionActionsViewModelTest {
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
private val profile = ProviderProfile(
id = ProviderProfileId("abox"),
type = ProviderType.ABOX,
displayName = "ABox",
)
private val credentials = LoginCredentials(Username("demo"), Password("secret"))
@Test
fun endSessionActionEndsSessionWithoutForgettingCredentials() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.EndSession)
advanceUntilIdle()
assertTrue(fixture.remote.session(profile.id) == null)
assertEquals(
credentials,
fixture.credentials.loadCredentials(profile.id).successValue(),
)
assertNull(viewModel.uiState.value.errorMessage)
}
@Test
fun forgetCredentialsActionKeepsSessionAndDisablesAutoLogin() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.coordinator.login(profile.id, credentials, rememberUsername = true, autoLogin = true)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.ForgetSavedCredentials)
advanceUntilIdle()
assertEquals(profile.id, fixture.remote.session(profile.id)?.profileId)
assertNull(fixture.credentials.loadCredentials(profile.id).successValue())
assertTrue(!fixture.profiles.loginPreferences(profile.id).autoLoginEnabled)
}
@Test
fun switchProviderActionClearsProfileOnlyAfterLogout() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.login(profile.id, credentials)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.SwitchProvider)
advanceUntilIdle()
assertEquals(profile.id, fixture.remote.logoutProfileId)
assertNull(fixture.profiles.activeProfile())
}
@Test
fun validateConnectionActionDelegatesWithoutRelogin() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.setValidationResult(
profile.id,
com.flagship.abox.manager.domain.DomainResult.Success(SessionValidity.INVALID),
)
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.ValidateConnection)
advanceUntilIdle()
assertEquals(SessionValidity.INVALID, fixture.remote.lastValidation)
assertEquals(0, fixture.remote.loginCalls)
}
@Test
fun mappedActionErrorNeverExposesDomainDetail() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.logoutError = DomainError.Unknown(detail = "keystore secret detail")
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.dispatch(SessionUiAction.EndSession)
advanceUntilIdle()
assertEquals(
UiText.StringResource(R.string.error_unknown),
viewModel.uiState.value.errorMessage,
)
}
@Test
fun dismissErrorActionClearsMappedError() = runTest {
val fixture = Fixture()
fixture.activateAuthenticatedProfile()
fixture.remote.logoutError = DomainError.Network(detail = "private network detail")
val viewModel = SessionActionsViewModel(fixture.coordinator)
viewModel.onAction(SessionUiAction.EndSession)
advanceUntilIdle()
viewModel.onAction(SessionUiAction.DismissError)
assertNull(viewModel.uiState.value.errorMessage)
}
private class Fixture {
val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore()
val remote = RecordingSessionGateway(profiles)
val local = FakeLocalSessionGateway()
val coordinator = SessionCoordinator(profiles, credentials, remote, local)
suspend fun activateAuthenticatedProfile() {
profiles.saveProfile(
ProviderProfile(
id = ProviderProfileId("abox"),
type = ProviderType.ABOX,
displayName = "ABox",
),
)
profiles.setActiveProfile(ProviderProfileId("abox"))
}
}
private class RecordingSessionGateway(
private val profiles: FakeProviderProfileStore,
private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : com.flagship.abox.manager.domain.SessionGateway by delegate {
var loginCalls = 0
private set
var logoutProfileId: ProviderProfileId? = null
private set
var lastValidation: SessionValidity? = null
private set
var logoutError: DomainError? = null
override suspend fun login(
profileId: ProviderProfileId,
credentials: LoginCredentials,
) = delegate.login(profileId, credentials).also { loginCalls++ }
override suspend fun validateSession(profileId: ProviderProfileId) = delegate
.validateSession(profileId)
.also { result ->
lastValidation = (result as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
}
override suspend fun logout(profileId: ProviderProfileId) = logoutError?.let {
logoutProfileId = profiles.activeProfile()?.id
com.flagship.abox.manager.domain.DomainResult.Failure(it)
} ?: delegate.logout(profileId).also {
logoutProfileId = profiles.activeProfile()?.id
}
suspend fun session(profileId: ProviderProfileId) = delegate.observeSession(profileId)
.let { flow -> flow.first() }
fun setValidationResult(
profileId: ProviderProfileId,
result: com.flagship.abox.manager.domain.DomainResult<SessionValidity>,
) {
delegate.setValidationResult(profileId, result)
}
}
}
private fun <T> com.flagship.abox.manager.domain.DomainResult<T>.successValue(): T? =
(this as? com.flagship.abox.manager.domain.DomainResult.Success)?.value
@@ -9,7 +9,7 @@ class MainTabTest {
@Test @Test
fun `all main tabs have distinct non-empty routes and valid label resources`() { fun `all main tabs have distinct non-empty routes and valid label resources`() {
val tabs = MainTab.values() val tabs = MainTab.entries
assertEquals(3, tabs.size) assertEquals(3, tabs.size)
val routes = tabs.map { it.route } val routes = tabs.map { it.route }
@@ -32,11 +32,20 @@ kotlin {
} }
dependencies { dependencies {
implementation(project(":android:core:preferences"))
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.material.icons.extended) implementation(libs.compose.material.icons.extended)
implementation(libs.miuix.ui.android)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
screenshotTestImplementation(libs.compose.screenshot.test) screenshotTestImplementation(libs.compose.screenshot.test)
testImplementation(libs.junit)
androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation(libs.androidx.activity.compose)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.compose.ui.test.junit4)
debugImplementation(libs.compose.ui.test.manifest)
} }
@@ -0,0 +1,183 @@
package com.flagship.abox.manager.designsystem
import android.os.Build
import androidx.activity.ComponentActivity
import androidx.compose.material3.Text
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.assertContentDescriptionEquals
import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.assertHeightIsAtLeast
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertWidthIsAtLeast
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AppThemeMode
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
@RunWith(AndroidJUnit4::class)
class ABoxComponentSemanticsTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Test
fun allInteractiveControlsHaveAtLeast48DpBounds() {
composeRule.setContent {
ABoxTheme {
ABoxButton(onClick = {}, modifier = Modifier.testTag("button")) {
Text("确定")
}
ABoxIconButton(
key = ABoxIconKey.ADD,
contentDescription = "添加设备",
onClick = {},
modifier = Modifier.testTag("icon"),
)
ABoxSwitch(
checked = false,
onCheckedChange = {},
contentDescription = "启用设备",
modifier = Modifier.testTag("switch"),
)
}
}
listOf("button", "icon", "switch").forEach { tag ->
composeRule.onNodeWithTag(tag)
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
}
}
@Test
fun operableSwitchExposesChineseLabelRoleStateAndToggleAction() {
var checked = false
composeRule.setContent {
ABoxTheme {
ABoxSwitch(
checked = checked,
onCheckedChange = { checked = it },
contentDescription = "启用设备",
modifier = Modifier.testTag("operable-switch"),
)
}
}
val switchNode = composeRule.onNodeWithTag("operable-switch")
.assertContentDescriptionEquals("启用设备")
.assertIsOff()
.assertHasClickAction()
.assertWidthIsAtLeast(48.dp)
.assertHeightIsAtLeast(48.dp)
.fetchSemanticsNode()
assertEquals(Role.Switch, switchNode.config[SemanticsProperties.Role])
assertTrue(switchNode.config.contains(SemanticsProperties.ToggleableState))
composeRule.onNodeWithTag("operable-switch").performClick()
composeRule.waitForIdle()
assertEquals(true, checked)
}
@Test
fun cardBodyAndQuickActionExposeIndependentSemanticNodes() {
composeRule.setContent {
ABoxTheme {
ABoxCard(
title = "客厅插座",
subtitle = "在线",
onClick = {},
quickAction = ABoxCardQuickAction(
key = ABoxIconKey.REFRESH,
contentDescription = "刷新设备",
onClick = {},
),
)
}
}
composeRule.onNodeWithText("客厅插座").assertIsDisplayed().assertHasClickAction()
composeRule.onNodeWithContentDescription("刷新设备").assertIsDisplayed().assertHasClickAction()
}
@Test
fun renderersExposeSameChineseStateDescription() {
var renderer by mutableStateOf(DesignRenderer.MATERIAL3)
composeRule.setContent {
ABoxTheme(
preferences = UiPreferences(
renderer = renderer,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在处理",
)
}
}
composeRule.onNodeWithContentDescription("正在处理").assertExists()
composeRule.runOnUiThread { renderer = DesignRenderer.MIUIX }
composeRule.waitForIdle()
composeRule.onNodeWithContentDescription("正在处理").assertExists()
}
@Test
fun reducedMotionExposesStaticLoadingStateThroughThemeSeam() {
composeRule.setContent {
ABoxThemeContent(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
systemInDarkTheme = false,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = null,
reducedMotion = true,
) {
ABoxLoadingState(contentDescription = "正在加载")
}
}
val loadingNode = composeRule.onNodeWithContentDescription("正在加载")
.assertExists()
.fetchSemanticsNode()
assertEquals("静态加载", loadingNode.config[SemanticsProperties.StateDescription])
}
@Test
fun interactiveIconRequiresNonBlankChineseDescription() {
composeRule.setContent {
ABoxTheme {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = {},
)
}
}
composeRule.onNodeWithContentDescription("返回")
.assertContentDescriptionEquals("返回")
}
}
@@ -5,47 +5,87 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.designsystem.icon.ABoxIcon
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
import androidx.compose.material3.Icon as MaterialIcon
@Composable
fun ABoxAppSurface(
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
val colors = LocalABoxColors.current
val blurEnabled = LocalABoxBlurEnabled.current
Surface(
modifier = modifier,
color = if (blurEnabled) colors.surfaceContainer.copy(alpha = 0.92f) else colors.surface,
contentColor = MaterialTheme.colorScheme.onSurface,
content = content,
)
}
/** Compatibility surface retained for existing feature modules. */
@Composable @Composable
fun ABoxSurface( fun ABoxSurface(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) = ABoxAppSurface(modifier = modifier, content = content)
Surface(modifier = modifier, content = content)
}
@Composable @Composable
fun ABoxButton( fun ABoxButton(
@@ -56,7 +96,7 @@ fun ABoxButton(
) { ) {
Button( Button(
onClick = onClick, onClick = onClick,
modifier = modifier, modifier = modifier.defaultMinSize(minHeight = 48.dp),
enabled = enabled, enabled = enabled,
colors = ButtonDefaults.buttonColors(), colors = ButtonDefaults.buttonColors(),
content = content, content = content,
@@ -64,14 +104,404 @@ fun ABoxButton(
} }
@Composable @Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) { fun ABoxIconButton(
CircularProgressIndicator(modifier = modifier) key: ABoxIconKey,
contentDescription: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
require(contentDescription.isNotBlank()) {
"ABoxIconButton contentDescription must not be blank"
}
IconButton(
onClick = onClick,
enabled = enabled,
modifier = modifier.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp),
) {
ABoxIcon(key = key, contentDescription = contentDescription)
}
} }
/** @Composable
* Circular user avatar displaying the initial character of username or a default person icon. fun ABoxSwitch(
* Styled with an accented profile ring. checked: Boolean,
*/ onCheckedChange: ((Boolean) -> Unit)?,
contentDescription: String,
modifier: Modifier = Modifier,
enabled: Boolean = true,
) {
require(contentDescription.isNotBlank()) {
"ABoxSwitch contentDescription must not be blank"
}
Box(
modifier = modifier
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.toggleable(
value = checked,
enabled = enabled && onCheckedChange != null,
role = Role.Switch,
onValueChange = { onCheckedChange?.invoke(it) },
)
.semantics {
this.contentDescription = contentDescription
},
contentAlignment = Alignment.Center,
) {
Switch(
checked = checked,
onCheckedChange = null,
enabled = enabled,
modifier = Modifier.clearAndSetSemantics { },
)
}
}
@Composable
fun ABoxFilterChip(
selected: Boolean,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
leadingIcon: (@Composable () -> Unit)? = null,
) {
FilterChip(
selected = selected,
onClick = onClick,
label = { Text(label) },
modifier = modifier.defaultMinSize(minHeight = 48.dp),
leadingIcon = leadingIcon,
colors = FilterChipDefaults.filterChipColors(),
)
}
@Composable
fun ABoxLoadingIndicator(modifier: Modifier = Modifier) {
val reducedMotion = LocalABoxReducedMotion.current
if (reducedMotion) {
CircularProgressIndicator(
progress = { 0.5f },
modifier = modifier,
)
} else {
CircularProgressIndicator(modifier = modifier)
}
}
@Composable
fun ABoxLoadingState(
contentDescription: String,
modifier: Modifier = Modifier,
) {
require(contentDescription.isNotBlank()) {
"ABoxLoadingState contentDescription must not be blank"
}
val reducedMotion = LocalABoxReducedMotion.current
Column(
modifier = modifier
.fillMaxWidth()
.semantics {
this.contentDescription = contentDescription
stateDescription = if (reducedMotion) "静态加载" else "动态加载"
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxLoadingIndicator(modifier = Modifier.size(32.dp))
Text(text = contentDescription)
}
}
enum class ABoxStatus {
ONLINE,
OFFLINE,
PENDING,
WARNING,
ERROR,
}
@Composable
fun ABoxStatusBanner(
state: ABoxStatus,
contentDescription: String,
modifier: Modifier = Modifier,
message: String = contentDescription,
) {
require(contentDescription.isNotBlank()) {
"ABoxStatusBanner contentDescription must not be blank"
}
val colors = LocalABoxColors.current
val (iconKey, color) = when (state) {
ABoxStatus.ONLINE -> ABoxIconKey.ONLINE to colors.success
ABoxStatus.OFFLINE -> ABoxIconKey.OFFLINE to colors.offline
ABoxStatus.PENDING -> ABoxIconKey.PENDING to colors.pending
ABoxStatus.WARNING -> ABoxIconKey.WARNING to colors.warning
ABoxStatus.ERROR -> ABoxIconKey.ERROR to colors.error
}
Surface(
modifier = modifier
.fillMaxWidth()
.semantics { this.contentDescription = contentDescription },
color = color.copy(alpha = 0.14f),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
ABoxIcon(key = iconKey, contentDescription = "${contentDescription}图标", modifier = Modifier.size(22.dp))
Text(text = message, color = color, style = MaterialTheme.typography.labelLarge)
}
}
}
@Composable
fun ABoxEmptyState(
title: String,
description: String,
modifier: Modifier = Modifier,
action: (@Composable () -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxIcon(ABoxIconKey.DEVICES, contentDescription = title, modifier = Modifier.size(40.dp))
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium)
action?.invoke()
}
}
@Composable
fun ABoxErrorState(
title: String,
description: String,
onRetry: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth().padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ABoxIcon(ABoxIconKey.ERROR, contentDescription = title, modifier = Modifier.size(40.dp))
Text(text = title, style = MaterialTheme.typography.titleMedium)
Text(text = description, style = MaterialTheme.typography.bodyMedium)
if (onRetry != null) {
ABoxButton(onClick = onRetry) { Text("重试") }
}
}
}
@Composable
fun ABoxCard(
title: String,
subtitle: String? = null,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier,
leadingContent: (@Composable () -> Unit)? = null,
quickAction: ABoxCardQuickAction? = null,
content: (@Composable ColumnScope.() -> Unit)? = null,
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(LocalABoxShapes.current.mediumCornerRadius),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingContent?.let {
Box(modifier = Modifier.padding(end = 12.dp), contentAlignment = Alignment.Center) {
it()
}
}
Column(
modifier = Modifier
.weight(1f)
.then(
if (onClick != null) {
Modifier
.clickable(
role = Role.Button,
onClickLabel = "打开$title",
onClick = onClick,
)
.semantics { role = Role.Button }
} else {
Modifier
},
)
.padding(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
content?.invoke(this)
}
if (quickAction != null) {
Spacer(Modifier.width(8.dp))
ABoxIconButton(
key = quickAction.key,
contentDescription = quickAction.contentDescription,
onClick = quickAction.onClick,
)
}
}
}
}
data class ABoxCardQuickAction(
val key: ABoxIconKey,
val contentDescription: String,
val onClick: () -> Unit,
)
@Composable
fun ABoxDialog(
visible: Boolean,
title: String,
onDismissRequest: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
confirmText: String = "确定",
dismissText: String = "取消",
content: @Composable () -> Unit,
) {
if (visible) {
AlertDialog(
onDismissRequest = onDismissRequest,
modifier = modifier,
title = { Text(title) },
text = content,
confirmButton = {
ABoxButton(onClick = onConfirm) { Text(confirmText) }
},
dismissButton = {
TextButton(onClick = onDismissRequest) { Text(dismissText) }
},
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ABoxBottomSheet(
visible: Boolean,
onDismissRequest: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
if (visible) {
ModalBottomSheet(
onDismissRequest = onDismissRequest,
modifier = modifier,
) {
Column(
modifier = Modifier.fillMaxWidth().padding(16.dp),
content = content,
)
}
}
}
@Composable
fun ABoxPreferenceGroup(
title: String,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
) {
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp),
)
content()
}
}
data class ABoxNavigationItem(
val key: ABoxIconKey,
val label: String,
val contentDescription: String = label,
)
@Composable
fun ABoxNavigationSuite(
items: List<ABoxNavigationItem>,
selectedKey: ABoxIconKey,
onItemSelected: (ABoxIconKey) -> Unit,
modifier: Modifier = Modifier,
) {
ABoxNavigationBar(modifier = modifier) {
items.forEach { item ->
ABoxNavigationBarItem(
selected = item.key == selectedKey,
onClick = { onItemSelected(item.key) },
icon = {
ABoxIcon(
key = item.key,
contentDescription = item.contentDescription,
)
},
label = { Text(item.label) },
)
}
}
}
@Composable
fun ABoxDeviceDetailScaffold(
title: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable (PaddingValues) -> Unit,
) {
Scaffold(
modifier = modifier.fillMaxSize(),
topBar = {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ABoxIconButton(
key = ABoxIconKey.BACK,
contentDescription = "返回",
onClick = onBack,
)
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(start = 8.dp),
)
}
},
content = content,
)
}
/** Circular user avatar displaying the initial character of username or a default person icon. */
@Composable @Composable
fun ABoxUserAvatar( fun ABoxUserAvatar(
name: String?, name: String?,
@@ -83,6 +513,12 @@ fun ABoxUserAvatar(
val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase() val initial = name?.trim()?.takeIf { it.isNotEmpty() }?.take(1)?.uppercase()
Box( Box(
modifier = modifier modifier = modifier
.defaultMinSize(minWidth = 48.dp, minHeight = 48.dp)
.clickable(onClickLabel = contentDescription, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer) .background(MaterialTheme.colorScheme.primaryContainer)
@@ -90,8 +526,7 @@ fun ABoxUserAvatar(
width = 2.dp, width = 2.dp,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.8f),
shape = CircleShape, shape = CircleShape,
) ),
.clickable(onClickLabel = contentDescription, onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
if (initial != null) { if (initial != null) {
@@ -103,53 +538,48 @@ fun ABoxUserAvatar(
), ),
) )
} else { } else {
Icon( MaterialIcon(
imageVector = Icons.Default.Person, imageVector = ABoxIconMapperForAccount,
contentDescription = contentDescription, contentDescription = contentDescription,
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(size * 0.6f), modifier = Modifier.size(size * 0.6f),
) )
} }
} }
}
} }
/** private val ABoxIconMapperForAccount = Icons.Outlined.AccountCircle
* Material 3 pill top bar containing leading icon, title/provider label,
* an optional '+' action button, and user avatar. /** Material 3 pill top bar containing leading icon, title/provider label, actions, and user avatar. */
*/
@Composable @Composable
fun ABoxTopPillBar( fun ABoxTopPillBar(
title: String, title: String,
onAvatarClick: () -> Unit, onAvatarClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
userName: String? = null, userName: String? = null,
leadingIcon: ImageVector = Icons.Default.LocationOn, leadingIcon: ImageVector = Icons.Outlined.Bolt,
onAddClick: (() -> Unit)? = null, onAddClick: (() -> Unit)? = null,
addActionContentDescription: String? = "Add", addActionContentDescription: String? = "添加",
avatarContentDescription: String? = "Account", avatarContentDescription: String? = "账户",
) { ) {
Surface( Surface(
modifier = modifier modifier = modifier.fillMaxWidth().height(64.dp),
.fillMaxWidth() shape = RoundedCornerShape(32.dp),
.height(56.dp),
shape = RoundedCornerShape(28.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
tonalElevation = 2.dp, tonalElevation = 2.dp,
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
Icon( MaterialIcon(
imageVector = leadingIcon, imageVector = leadingIcon,
contentDescription = null, contentDescription = title,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(22.dp), modifier = Modifier.padding(start = 8.dp).size(22.dp),
) )
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
@@ -158,20 +588,13 @@ fun ABoxTopPillBar(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
if (onAddClick != null) { if (onAddClick != null) {
IconButton( ABoxIconButton(
key = ABoxIconKey.ADD,
contentDescription = addActionContentDescription ?: "添加",
onClick = onAddClick, onClick = onAddClick,
modifier = Modifier.size(36.dp),
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = addActionContentDescription,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
}
ABoxUserAvatar( ABoxUserAvatar(
name = userName, name = userName,
onClick = onAvatarClick, onClick = onAvatarClick,
@@ -182,9 +605,6 @@ fun ABoxTopPillBar(
} }
} }
/**
* ABox standard Material 3 Navigation Bar with pill indicator styling.
*/
@Composable @Composable
fun ABoxNavigationBar( fun ABoxNavigationBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -198,9 +618,6 @@ fun ABoxNavigationBar(
) )
} }
/**
* ABox standard Navigation Bar Item.
*/
@Composable @Composable
fun RowScope.ABoxNavigationBarItem( fun RowScope.ABoxNavigationBarItem(
selected: Boolean, selected: Boolean,
@@ -2,10 +2,11 @@ package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp import com.flagship.abox.manager.designsystem.tokens.ABoxShapeTokens
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
val ABoxShapes = Shapes( fun ABoxShapes(tokens: ABoxShapeTokens = ABoxThemeDefaults.material3Shapes) = Shapes(
small = RoundedCornerShape(8.dp), small = RoundedCornerShape(tokens.smallCornerRadius),
medium = RoundedCornerShape(16.dp), medium = RoundedCornerShape(tokens.mediumCornerRadius),
large = RoundedCornerShape(24.dp), large = RoundedCornerShape(tokens.largeCornerRadius),
) )
@@ -1,41 +1,190 @@
package com.flagship.abox.manager.designsystem package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.isSystemInDarkTheme import android.os.Build
import androidx.compose.material3.MaterialTheme import android.provider.Settings
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.darkColorScheme import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import com.flagship.abox.manager.designsystem.renderer.ABoxRendererAdapters
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.designsystem.tokens.ABoxColorTokens
import com.flagship.abox.manager.designsystem.tokens.ABoxSpacing
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeDefaults
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolver
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.UiPreferences
private val LightColorScheme = lightColorScheme( val LocalABoxColors = staticCompositionLocalOf { ABoxThemeDefaults.lightColors }
primary = Color(0xFF006780), val LocalABoxSpacing = staticCompositionLocalOf { ABoxSpacing() }
onPrimary = Color.White, val LocalABoxTypography = staticCompositionLocalOf { ABoxTypography }
primaryContainer = Color(0xFFB8EAFF), val LocalABoxShapes = staticCompositionLocalOf { ABoxThemeDefaults.material3Shapes }
onPrimaryContainer = Color(0xFF001F29), val LocalABoxRenderer = staticCompositionLocalOf { DesignRenderer.MATERIAL3 }
secondary = Color(0xFF4D616A), val LocalABoxReducedMotion = staticCompositionLocalOf { false }
surface = Color(0xFFF8FAFC), val LocalABoxBlurEnabled = staticCompositionLocalOf { false }
background = Color(0xFFF8FAFC),
)
private val DarkColorScheme = darkColorScheme(
primary = Color(0xFF5DD5F8),
onPrimary = Color(0xFF003544),
primaryContainer = Color(0xFF004D61),
onPrimaryContainer = Color(0xFFB8EAFF),
secondary = Color(0xFFB4CBD4),
surface = Color(0xFF101416),
background = Color(0xFF101416),
)
@Composable @Composable
fun ABoxTheme( fun ABoxTheme(
darkTheme: Boolean = isSystemInDarkTheme(), preferences: UiPreferences,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
MaterialTheme( val systemInDarkTheme = androidx.compose.foundation.isSystemInDarkTheme()
colorScheme = if (darkTheme) DarkColorScheme else LightColorScheme, val context = LocalContext.current
typography = ABoxTypography, val isDarkTheme = when (preferences.themeMode) {
shapes = ABoxShapes, AppThemeMode.SYSTEM -> systemInDarkTheme
AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true
}
val dynamicTokens = if (preferences.dynamicColorEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val dynamicColorScheme = if (isDarkTheme) {
dynamicDarkColorScheme(context)
} else {
dynamicLightColorScheme(context)
}
dynamicColorScheme.toABoxColorTokens()
} else {
null
}
ABoxThemeContent(
preferences = preferences,
systemInDarkTheme = systemInDarkTheme,
sdkInt = Build.VERSION.SDK_INT,
dynamicColorTokens = dynamicTokens,
reducedMotion = systemAnimatorDurationScale(context) == 0f,
content = content, content = content,
) )
} }
@Composable
internal fun ABoxThemeContent(
preferences: UiPreferences,
systemInDarkTheme: Boolean,
sdkInt: Int,
dynamicColorTokens: ABoxColorTokens?,
reducedMotion: Boolean,
content: @Composable () -> Unit,
) {
val resolution = ABoxThemeResolver.resolve(
preferences = preferences,
systemInDarkTheme = systemInDarkTheme,
sdkInt = sdkInt,
dynamicColorTokens = dynamicColorTokens,
)
val renderer = ABoxRendererAdapters.forRenderer(resolution.renderer)
CompositionLocalProvider(
LocalABoxColors provides resolution.colorTokens,
LocalABoxSpacing provides ABoxSpacing(),
LocalABoxTypography provides ABoxTypography,
LocalABoxShapes provides resolution.shapeTokens,
LocalABoxRenderer provides resolution.renderer,
LocalABoxReducedMotion provides reducedMotion,
LocalABoxBlurEnabled provides resolution.blurEnabled,
) {
renderer.Render(
resolution = resolution,
content = content,
)
}
}
private fun systemAnimatorDurationScale(context: android.content.Context): Float =
runCatching {
Settings.Global.getFloat(
context.contentResolver,
Settings.Global.ANIMATOR_DURATION_SCALE,
1f,
)
}.getOrDefault(1f)
@Composable
fun ABoxTheme(
darkTheme: Boolean = androidx.compose.foundation.isSystemInDarkTheme(),
content: @Composable () -> Unit,
) {
ABoxTheme(
preferences = UiPreferences(
themeMode = if (darkTheme) AppThemeMode.DARK else AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
content = content,
)
}
internal fun materialColorScheme(resolution: ABoxThemeResolution): ColorScheme {
val colors = resolution.colorTokens
val onPrimary = if (resolution.isDarkTheme) Color(0xFF003544) else Color.White
val onSurface = if (resolution.isDarkTheme) Color(0xFFE0E3E5) else Color(0xFF191C1E)
val onSurfaceVariant = if (resolution.isDarkTheme) Color(0xFFC0C8CC) else Color(0xFF40484C)
val primaryContainer = colors.primary.copy(alpha = if (resolution.isDarkTheme) 0.35f else 0.20f)
val secondaryContainer = colors.surfaceContainer
return if (resolution.isDarkTheme) {
darkColorScheme(
primary = colors.primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = colors.primary,
secondary = colors.pending,
onSecondary = Color(0xFF3F2E00),
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSurface,
tertiary = colors.success,
onTertiary = Color(0xFF00391E),
surface = colors.surface,
onSurface = onSurface,
surfaceVariant = colors.surfaceContainer,
onSurfaceVariant = onSurfaceVariant,
background = colors.surface,
onBackground = onSurface,
outline = colors.outline,
error = colors.error,
onError = Color(0xFF690005),
)
} else {
lightColorScheme(
primary = colors.primary,
onPrimary = onPrimary,
primaryContainer = primaryContainer,
onPrimaryContainer = Color(0xFF001F29),
secondary = colors.pending,
onSecondary = Color.White,
secondaryContainer = secondaryContainer,
onSecondaryContainer = onSurface,
tertiary = colors.success,
onTertiary = Color.White,
surface = colors.surface,
onSurface = onSurface,
surfaceVariant = colors.surfaceContainer,
onSurfaceVariant = onSurfaceVariant,
background = colors.surface,
onBackground = onSurface,
outline = colors.outline,
error = colors.error,
onError = Color.White,
)
}
}
private fun ColorScheme.toABoxColorTokens() = ABoxColorTokens(
primary = primary,
surface = surface,
surfaceContainer = surfaceContainer,
outline = outline,
success = tertiary,
warning = secondary,
error = error,
offline = outline,
pending = secondary,
deviceSocket = primary,
deviceSensor = tertiary,
deviceInfrared = secondary,
)
@@ -2,4 +2,5 @@ package com.flagship.abox.manager.designsystem
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
/** Default typography supplied through [LocalABoxTypography]. */
val ABoxTypography = Typography() val ABoxTypography = Typography()
@@ -0,0 +1,114 @@
package com.flagship.abox.manager.designsystem.icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.outlined.AccountCircle
import androidx.compose.material.icons.outlined.Add
import androidx.compose.material.icons.outlined.Bolt
import androidx.compose.material.icons.outlined.Close
import androidx.compose.material.icons.outlined.Devices
import androidx.compose.material.icons.outlined.Edit
import androidx.compose.material.icons.outlined.ErrorOutline
import androidx.compose.material.icons.automirrored.outlined.HelpOutline
import androidx.compose.material.icons.outlined.History
import androidx.compose.material.icons.outlined.Layers
import androidx.compose.material.icons.outlined.Power
import androidx.compose.material.icons.outlined.PowerSettingsNew
import androidx.compose.material.icons.outlined.Refresh
import androidx.compose.material.icons.outlined.Schedule
import androidx.compose.material.icons.outlined.Sensors
import androidx.compose.material.icons.outlined.Thermostat
import androidx.compose.material.icons.outlined.Warning
import androidx.compose.material.icons.outlined.WaterDrop
import androidx.compose.material.icons.outlined.Wifi
import androidx.compose.material.icons.outlined.WifiOff
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.material3.Icon as MaterialIcon
/** Stable semantic identifiers for every icon exposed by the ABox design system. */
enum class ABoxIconKey {
BACK,
CLOSE,
ADD,
EDIT,
REFRESH,
ACCOUNT,
PROVIDER,
DEVICES,
SCENES,
ACTIVITY,
SOCKET,
SENSOR,
INFRARED,
POWER,
TEMPERATURE,
HUMIDITY,
ONLINE,
OFFLINE,
PENDING,
WARNING,
ERROR,
UNKNOWN_DEVICE,
}
/** Design-system-owned device categories; this module does not depend on domain models. */
enum class ABoxDeviceType {
SOCKET,
SENSOR,
INFRARED,
POWER,
UNKNOWN,
}
object ABoxIconMapper {
fun forDeviceType(type: ABoxDeviceType?): ABoxIconKey = when (type) {
ABoxDeviceType.SOCKET -> ABoxIconKey.SOCKET
ABoxDeviceType.SENSOR -> ABoxIconKey.SENSOR
ABoxDeviceType.INFRARED -> ABoxIconKey.INFRARED
ABoxDeviceType.POWER -> ABoxIconKey.POWER
null, ABoxDeviceType.UNKNOWN -> ABoxIconKey.UNKNOWN_DEVICE
}
internal fun vectorFor(key: ABoxIconKey): ImageVector = when (key) {
ABoxIconKey.BACK -> Icons.AutoMirrored.Outlined.ArrowBack
ABoxIconKey.CLOSE -> Icons.Outlined.Close
ABoxIconKey.ADD -> Icons.Outlined.Add
ABoxIconKey.EDIT -> Icons.Outlined.Edit
ABoxIconKey.REFRESH -> Icons.Outlined.Refresh
ABoxIconKey.ACCOUNT -> Icons.Outlined.AccountCircle
ABoxIconKey.PROVIDER -> Icons.Outlined.Bolt
ABoxIconKey.DEVICES -> Icons.Outlined.Devices
ABoxIconKey.SCENES -> Icons.Outlined.Layers
ABoxIconKey.ACTIVITY -> Icons.Outlined.History
ABoxIconKey.SOCKET -> Icons.Outlined.Power
ABoxIconKey.SENSOR -> Icons.Outlined.Sensors
ABoxIconKey.INFRARED -> Icons.Outlined.Bolt
ABoxIconKey.POWER -> Icons.Outlined.PowerSettingsNew
ABoxIconKey.TEMPERATURE -> Icons.Outlined.Thermostat
ABoxIconKey.HUMIDITY -> Icons.Outlined.WaterDrop
ABoxIconKey.ONLINE -> Icons.Outlined.Wifi
ABoxIconKey.OFFLINE -> Icons.Outlined.WifiOff
ABoxIconKey.PENDING -> Icons.Outlined.Schedule
ABoxIconKey.WARNING -> Icons.Outlined.Warning
ABoxIconKey.ERROR -> Icons.Outlined.ErrorOutline
ABoxIconKey.UNKNOWN_DEVICE -> Icons.AutoMirrored.Outlined.HelpOutline
}
}
@Composable
fun ABoxIcon(
key: ABoxIconKey,
contentDescription: String,
modifier: Modifier = Modifier,
) {
require(contentDescription.isNotBlank()) {
"ABoxIcon contentDescription must not be blank"
}
MaterialIcon(
imageVector = ABoxIconMapper.vectorFor(key),
contentDescription = contentDescription,
modifier = modifier,
)
}
@@ -0,0 +1,20 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import com.flagship.abox.manager.preferences.DesignRenderer
internal interface ABoxRendererAdapter {
@Composable
fun Render(
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
)
}
internal object ABoxRendererAdapters {
fun forRenderer(renderer: DesignRenderer): ABoxRendererAdapter = when (renderer) {
DesignRenderer.MATERIAL3 -> Material3RendererAdapter
DesignRenderer.MIUIX -> MiuixRendererAdapter
}
}
@@ -0,0 +1,23 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.ABoxShapes
import com.flagship.abox.manager.designsystem.ABoxTypography
import com.flagship.abox.manager.designsystem.materialColorScheme
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
internal object Material3RendererAdapter : ABoxRendererAdapter {
@Composable
override fun Render(
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
) {
MaterialTheme(
colorScheme = materialColorScheme(resolution),
typography = ABoxTypography,
shapes = ABoxShapes(resolution.shapeTokens),
content = content,
)
}
}
@@ -0,0 +1,25 @@
package com.flagship.abox.manager.designsystem.renderer
import androidx.compose.runtime.Composable
import com.flagship.abox.manager.designsystem.tokens.ABoxThemeResolution
import top.yukonga.miuix.kmp.theme.MiuixTheme
import top.yukonga.miuix.kmp.theme.darkColorScheme
import top.yukonga.miuix.kmp.theme.lightColorScheme
internal object MiuixRendererAdapter : ABoxRendererAdapter {
@Composable
override fun Render(
resolution: ABoxThemeResolution,
content: @Composable () -> Unit,
) {
val colors = if (resolution.isDarkTheme) darkColorScheme() else lightColorScheme()
MiuixTheme(
colors = colors,
) {
Material3RendererAdapter.Render(
resolution = resolution,
content = content,
)
}
}
}
@@ -0,0 +1,139 @@
package com.flagship.abox.manager.designsystem.tokens
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@Immutable
data class ABoxColorTokens(
val primary: Color,
val surface: Color,
val surfaceContainer: Color,
val outline: Color,
val success: Color,
val warning: Color,
val error: Color,
val offline: Color,
val pending: Color,
val deviceSocket: Color,
val deviceSensor: Color,
val deviceInfrared: Color,
)
@Immutable
data class ABoxSpacing(
val dp4: Dp = 4.dp,
val dp8: Dp = 8.dp,
val dp12: Dp = 12.dp,
val dp16: Dp = 16.dp,
val dp20: Dp = 20.dp,
val dp24: Dp = 24.dp,
val dp32: Dp = 32.dp,
val minimumTouchTarget: Dp = 48.dp,
)
@Immutable
data class ABoxComponentState(
val enabled: Boolean = true,
val pending: Boolean = false,
val offline: Boolean = false,
val dangerous: Boolean = false,
)
@Immutable
data class ABoxShapeTokens(
val smallCornerRadius: Dp,
val mediumCornerRadius: Dp,
val largeCornerRadius: Dp,
)
@Immutable
data class ABoxThemeResolution(
val isDarkTheme: Boolean,
val colorTokens: ABoxColorTokens,
val shapeTokens: ABoxShapeTokens,
val blurEnabled: Boolean,
val renderer: DesignRenderer,
)
object ABoxThemeDefaults {
val lightColors = ABoxColorTokens(
primary = Color(0xFF006780),
surface = Color(0xFFF8FAFC),
surfaceContainer = Color(0xFFE2E8F0),
outline = Color(0xFF70797D),
success = Color(0xFF1B7F4B),
warning = Color(0xFF8A5800),
error = Color(0xFFBA1A1A),
offline = Color(0xFF5F6B72),
pending = Color(0xFF8A5800),
deviceSocket = Color(0xFF006E90),
deviceSensor = Color(0xFF476B23),
deviceInfrared = Color(0xFF77536F),
)
val darkColors = ABoxColorTokens(
primary = Color(0xFF5DD5F8),
surface = Color(0xFF101416),
surfaceContainer = Color(0xFF1F292D),
outline = Color(0xFF8A969B),
success = Color(0xFF7EDFA7),
warning = Color(0xFFFFB95B),
error = Color(0xFFFFB4AB),
offline = Color(0xFFBFC9CD),
pending = Color(0xFFFFB95B),
deviceSocket = Color(0xFF5DD5F8),
deviceSensor = Color(0xFFAFD67C),
deviceInfrared = Color(0xFFE6B5D6),
)
val material3Shapes = ABoxShapeTokens(
smallCornerRadius = 8.dp,
mediumCornerRadius = 16.dp,
largeCornerRadius = 24.dp,
)
val miuixShapes = ABoxShapeTokens(
smallCornerRadius = 12.dp,
mediumCornerRadius = 20.dp,
largeCornerRadius = 28.dp,
)
}
object ABoxThemeResolver {
fun resolve(
preferences: UiPreferences,
systemInDarkTheme: Boolean,
sdkInt: Int,
dynamicColorTokens: ABoxColorTokens?,
): ABoxThemeResolution {
val isDarkTheme = when (preferences.themeMode) {
AppThemeMode.SYSTEM -> systemInDarkTheme
AppThemeMode.LIGHT -> false
AppThemeMode.DARK -> true
}
val brandTokens = if (isDarkTheme) {
ABoxThemeDefaults.darkColors
} else {
ABoxThemeDefaults.lightColors
}
val colorTokens = if (preferences.dynamicColorEnabled && sdkInt >= 31 && dynamicColorTokens != null) {
dynamicColorTokens
} else {
brandTokens
}
val shapeTokens = when (preferences.renderer) {
DesignRenderer.MATERIAL3 -> ABoxThemeDefaults.material3Shapes
DesignRenderer.MIUIX -> ABoxThemeDefaults.miuixShapes
}
return ABoxThemeResolution(
isDarkTheme = isDarkTheme,
colorTokens = colorTokens,
shapeTokens = shapeTokens,
blurEnabled = preferences.blurEnabled && preferences.renderer == DesignRenderer.MIUIX,
renderer = preferences.renderer,
)
}
}
@@ -0,0 +1,123 @@
package com.flagship.abox.manager.designsystem
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.android.tools.screenshot.PreviewTest
import com.flagship.abox.manager.designsystem.icon.ABoxIconKey
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@PreviewTest
@Preview(name = "M3 light", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMaterialLight() {
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
}
@PreviewTest
@Preview(name = "M3 dark", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMaterialDark() {
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.DARK)
}
@PreviewTest
@Preview(name = "Miuix light", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMiuixLight() {
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT)
}
@PreviewTest
@Preview(name = "Miuix dark", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotMiuixDark() {
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.DARK)
}
@PreviewTest
@Preview(name = "Large font", widthDp = 360, heightDp = 760, fontScale = 1.3f)
@Composable
fun ABoxThemeScreenshotLargeFont() {
ABoxScreenshotFixture(DesignRenderer.MATERIAL3, AppThemeMode.LIGHT)
}
@PreviewTest
@Preview(name = "Blur fallback", widthDp = 360, heightDp = 760)
@Composable
fun ABoxThemeScreenshotBlurFallback() {
ABoxScreenshotFixture(DesignRenderer.MIUIX, AppThemeMode.LIGHT, blurEnabled = true)
}
@Composable
private fun ABoxScreenshotFixture(
renderer: DesignRenderer,
themeMode: AppThemeMode,
blurEnabled: Boolean = false,
) {
ABoxTheme(
preferences = UiPreferences(
renderer = renderer,
themeMode = themeMode,
dynamicColorEnabled = false,
blurEnabled = blurEnabled,
),
) {
ABoxAppSurface(modifier = Modifier.padding(16.dp)) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
ABoxTopPillBar(
title = "我的设备",
onAvatarClick = {},
userName = "小明",
onAddClick = {},
addActionContentDescription = "添加设备",
avatarContentDescription = "账户",
)
ABoxCard(
title = "客厅插座",
subtitle = "在线 · 正常",
onClick = {},
quickAction = ABoxCardQuickAction(
key = ABoxIconKey.REFRESH,
contentDescription = "刷新设备",
onClick = {},
),
)
ABoxStatusBanner(
state = ABoxStatus.PENDING,
contentDescription = "正在处理",
)
ABoxStatusBanner(
state = ABoxStatus.OFFLINE,
contentDescription = "设备离线",
)
ABoxLoadingState(contentDescription = "正在加载")
ABoxEmptyState(
title = "暂无设备",
description = "添加设备后会显示在这里。",
)
ABoxErrorState(
title = "加载失败",
description = "请稍后重试。",
onRetry = {},
)
ABoxFilterChip(
selected = true,
label = "全部",
onClick = {},
)
}
}
}
}
@@ -4,10 +4,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import com.android.tools.screenshot.PreviewTest import com.android.tools.screenshot.PreviewTest
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
@PreviewTest @PreviewTest
@Preview @Preview
@Composable @Composable
fun DesignSystemSmokePreview() { fun DesignSystemSmokePreview() {
ABoxTheme(darkTheme = false) { Text("截图基线") } ABoxTheme(
preferences = UiPreferences(
renderer = DesignRenderer.MATERIAL3,
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
) {
Text("截图基线")
}
} }
@@ -0,0 +1,37 @@
package com.flagship.abox.manager.designsystem.icon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ABoxIconMappingTest {
@Test
fun unknownDeviceNeverMapsToPower() {
assertNotEquals(ABoxIconKey.POWER, ABoxIconMapper.forDeviceType(null))
assertEquals(ABoxIconKey.UNKNOWN_DEVICE, ABoxIconMapper.forDeviceType(null))
assertEquals(
ABoxIconKey.UNKNOWN_DEVICE,
ABoxIconMapper.forDeviceType(ABoxDeviceType.UNKNOWN),
)
}
@Test
fun knownDeviceCategoriesMapToTheirTypedIcons() {
assertEquals(
ABoxIconKey.SOCKET,
ABoxIconMapper.forDeviceType(ABoxDeviceType.SOCKET),
)
assertEquals(
ABoxIconKey.SENSOR,
ABoxIconMapper.forDeviceType(ABoxDeviceType.SENSOR),
)
assertEquals(
ABoxIconKey.INFRARED,
ABoxIconMapper.forDeviceType(ABoxDeviceType.INFRARED),
)
assertEquals(
ABoxIconKey.POWER,
ABoxIconMapper.forDeviceType(ABoxDeviceType.POWER),
)
}
}
@@ -0,0 +1,19 @@
package com.flagship.abox.manager.designsystem.renderer
import com.flagship.abox.manager.preferences.DesignRenderer
import org.junit.Assert.assertSame
import org.junit.Test
class ABoxRendererAdapterTest {
@Test
fun selectsMaterial3AndMiuixAdaptersFromRendererPreference() {
assertSame(
Material3RendererAdapter,
ABoxRendererAdapters.forRenderer(DesignRenderer.MATERIAL3),
)
assertSame(
MiuixRendererAdapter,
ABoxRendererAdapters.forRenderer(DesignRenderer.MIUIX),
)
}
}
@@ -0,0 +1,116 @@
package com.flagship.abox.manager.designsystem.tokens
import androidx.compose.ui.graphics.Color
import com.flagship.abox.manager.preferences.AppThemeMode
import com.flagship.abox.manager.preferences.DesignRenderer
import com.flagship.abox.manager.preferences.UiPreferences
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Test
class ABoxThemeResolverTest {
@Test
fun systemLightAndDarkResolveDeterministically() {
val light = ABoxThemeResolver.resolve(
preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM),
systemInDarkTheme = false,
sdkInt = 30,
dynamicColorTokens = null,
)
val dark = ABoxThemeResolver.resolve(
preferences = UiPreferences(themeMode = AppThemeMode.SYSTEM),
systemInDarkTheme = true,
sdkInt = 30,
dynamicColorTokens = null,
)
assertFalse(light.isDarkTheme)
assertEquals(ABoxThemeDefaults.lightColors, light.colorTokens)
assertEquals(ABoxThemeDefaults.darkColors, dark.colorTokens)
}
@Test
fun dynamicColorDisabledUsesBrandTokens() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(
themeMode = AppThemeMode.LIGHT,
dynamicColorEnabled = false,
),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = dynamicTokens,
)
assertEquals(ABoxThemeDefaults.lightColors, resolution.colorTokens)
}
@Test
fun pre31DynamicColorFallsBackToBrandTokens() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(
themeMode = AppThemeMode.DARK,
dynamicColorEnabled = true,
),
systemInDarkTheme = false,
sdkInt = 30,
dynamicColorTokens = dynamicTokens,
)
assertEquals(ABoxThemeDefaults.darkColors, resolution.colorTokens)
}
@Test
fun blurDisabledResolvesOpaqueSurface() {
val resolution = ABoxThemeResolver.resolve(
preferences = UiPreferences(blurEnabled = false),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = null,
)
assertFalse(resolution.blurEnabled)
assertEquals(1f, resolution.colorTokens.surface.alpha)
assertEquals(1f, resolution.colorTokens.surfaceContainer.alpha)
}
@Test
fun rendererChangesShapeButNotSemanticStateRoles() {
val material = ABoxThemeResolver.resolve(
preferences = UiPreferences(renderer = DesignRenderer.MATERIAL3),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = null,
)
val miuix = ABoxThemeResolver.resolve(
preferences = UiPreferences(renderer = DesignRenderer.MIUIX),
systemInDarkTheme = false,
sdkInt = 31,
dynamicColorTokens = null,
)
assertEquals(material.colorTokens.success, miuix.colorTokens.success)
assertEquals(material.colorTokens.warning, miuix.colorTokens.warning)
assertEquals(material.colorTokens.error, miuix.colorTokens.error)
assertEquals(material.colorTokens.offline, miuix.colorTokens.offline)
assertEquals(material.colorTokens.pending, miuix.colorTokens.pending)
assertNotEquals(material.shapeTokens, miuix.shapeTokens)
}
private companion object {
val dynamicTokens = ABoxColorTokens(
primary = Color(0xFF111111),
surface = Color(0xFF222222),
surfaceContainer = Color(0xFF333333),
outline = Color(0xFF444444),
success = Color(0xFF555555),
warning = Color(0xFF666666),
error = Color(0xFF777777),
offline = Color(0xFF888888),
pending = Color(0xFF999999),
deviceSocket = Color(0xFFAAAAAA),
deviceSensor = Color(0xFFBBBBBB),
deviceInfrared = Color(0xFFCCCCCC),
)
}
}
+32
View File
@@ -0,0 +1,32 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
}
android {
namespace = "com.flagship.abox.manager.preferences"
compileSdk = 37
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.datastore.preferences)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
@@ -0,0 +1,93 @@
package com.flagship.abox.manager.preferences
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.MutablePreferences
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
private const val UI_PREFERENCES_DATA_STORE_NAME = "ui_preferences"
private val Context.uiPreferencesDataStore: DataStore<Preferences> by preferencesDataStore(
name = UI_PREFERENCES_DATA_STORE_NAME,
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
)
class DataStoreUiPreferencesRepository internal constructor(
private val dataStore: DataStore<Preferences>,
) : UiPreferencesRepository {
constructor(context: Context) : this(context.uiPreferencesDataStore)
override fun observe(): Flow<UiPreferences> = dataStore.data
.map(::preferencesFrom)
.catch { cause ->
if (cause is CancellationException) throw cause
emit(UiPreferences())
}
override suspend fun update(
transform: (UiPreferences) -> UiPreferences,
): UiPreferencesWriteResult = try {
dataStore.edit { storedPreferences ->
val updatedPreferences = try {
transform(preferencesFrom(storedPreferences))
} catch (cause: Throwable) {
throw TransformFailure(cause)
}
storedPreferences.putAll(updatedPreferences)
}
UiPreferencesWriteResult.Success
} catch (failure: TransformFailure) {
throw failure.cause
} catch (cause: CancellationException) {
throw cause
} catch (_: IOException) {
UiPreferencesWriteResult.Failure
}
private fun preferencesFrom(preferences: Preferences): UiPreferences = UiPreferences(
renderer = preferences[RENDERER]?.enumOrDefault(DesignRenderer.MATERIAL3)
?: DesignRenderer.MATERIAL3,
themeMode = preferences[THEME_MODE]?.enumOrDefault(AppThemeMode.SYSTEM)
?: AppThemeMode.SYSTEM,
dynamicColorEnabled = preferences[DYNAMIC_COLOR] ?: true,
blurEnabled = preferences[BLUR] ?: false,
navigationPresentation = preferences[NAVIGATION]?.enumOrDefault(NavigationPresentation.STANDARD)
?: NavigationPresentation.STANDARD,
hapticsEnabled = preferences[HAPTICS] ?: true,
)
private fun MutablePreferences.putAll(preferences: UiPreferences) {
this[RENDERER] = preferences.renderer.name
this[THEME_MODE] = preferences.themeMode.name
this[DYNAMIC_COLOR] = preferences.dynamicColorEnabled
this[BLUR] = preferences.blurEnabled
this[NAVIGATION] = preferences.navigationPresentation.name
this[HAPTICS] = preferences.hapticsEnabled
}
private inline fun <reified T : Enum<T>> String.enumOrDefault(default: T): T =
enumValues<T>().firstOrNull { it.name == this } ?: default
private class TransformFailure(
override val cause: Throwable,
) : RuntimeException(cause)
private companion object {
val RENDERER = stringPreferencesKey("renderer")
val THEME_MODE = stringPreferencesKey("theme_mode")
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val BLUR = booleanPreferencesKey("blur")
val NAVIGATION = stringPreferencesKey("navigation")
val HAPTICS = booleanPreferencesKey("haptics")
}
}
@@ -0,0 +1,35 @@
package com.flagship.abox.manager.preferences
enum class DesignRenderer {
MATERIAL3,
MIUIX,
}
enum class AppThemeMode {
SYSTEM,
LIGHT,
DARK,
}
enum class NavigationPresentation {
STANDARD,
FLOATING,
}
enum class PreferenceField {
RENDERER,
THEME_MODE,
DYNAMIC_COLOR,
BLUR,
NAVIGATION,
HAPTICS,
}
data class UiPreferences(
val renderer: DesignRenderer = DesignRenderer.MATERIAL3,
val themeMode: AppThemeMode = AppThemeMode.SYSTEM,
val dynamicColorEnabled: Boolean = true,
val blurEnabled: Boolean = false,
val navigationPresentation: NavigationPresentation = NavigationPresentation.STANDARD,
val hapticsEnabled: Boolean = true,
)
@@ -0,0 +1,16 @@
package com.flagship.abox.manager.preferences
import kotlinx.coroutines.flow.Flow
sealed interface UiPreferencesWriteResult {
data object Success : UiPreferencesWriteResult
data object Failure : UiPreferencesWriteResult
}
interface UiPreferencesRepository {
fun observe(): Flow<UiPreferences>
suspend fun update(
transform: (UiPreferences) -> UiPreferences,
): UiPreferencesWriteResult
}
@@ -0,0 +1,157 @@
package com.flagship.abox.manager.preferences
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.core.stringPreferencesKey
import java.io.IOException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Test
class DataStoreUiPreferencesRepositoryTest {
@Test
fun defaultsAreEmitted() = runTest {
val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore())
assertEquals(UiPreferences(), repository.observe().first())
}
@Test
fun eachFieldPersistsIndependently() = runTest {
val dataStore = InMemoryPreferencesDataStore()
val repository = DataStoreUiPreferencesRepository(dataStore)
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(renderer = DesignRenderer.MIUIX)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(themeMode = AppThemeMode.DARK)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(dynamicColorEnabled = false)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(blurEnabled = true)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(navigationPresentation = NavigationPresentation.FLOATING)
})
assertEquals(UiPreferencesWriteResult.Success, repository.update {
it.copy(hapticsEnabled = false)
})
assertEquals(
UiPreferences(
renderer = DesignRenderer.MIUIX,
themeMode = AppThemeMode.DARK,
dynamicColorEnabled = false,
blurEnabled = true,
navigationPresentation = NavigationPresentation.FLOATING,
hapticsEnabled = false,
),
repository.observe().first(),
)
}
@Test
fun unknownEnumFallsBackToDefault() = runTest {
val dataStore = InMemoryPreferencesDataStore()
dataStore.edit { preferences ->
preferences[stringPreferencesKey("renderer")] = "RETIRED_STYLE"
}
val repository = DataStoreUiPreferencesRepository(dataStore)
assertEquals(DesignRenderer.MATERIAL3, repository.observe().first().renderer)
}
@Test
fun corruptDataFallsBackWithoutThrowing() = runTest {
val repository = DataStoreUiPreferencesRepository(CorruptPreferencesDataStore())
assertEquals(UiPreferences(), repository.observe().first())
}
@Test
fun updateReturnsFailureWhenStorageWriteFails() = runTest {
val repository = DataStoreUiPreferencesRepository(FailingWritePreferencesDataStore())
assertEquals(
UiPreferencesWriteResult.Failure,
repository.update { it.copy(blurEnabled = true) },
)
}
@Test
fun updateRethrowsTransformExceptions() = runTest {
val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore())
val failure = IllegalStateException("invalid transform")
val actual = runCatching {
repository.update { throw failure }
}.exceptionOrNull()
assertEquals(failure, actual)
}
@Test
fun updateRethrowsTransformIOException() = runTest {
val repository = DataStoreUiPreferencesRepository(InMemoryPreferencesDataStore())
val failure = IOException("invalid transform")
val actual = runCatching {
repository.update { throw failure }
}.exceptionOrNull()
assertEquals(failure, actual)
}
@Test
fun updateRethrowsCancellationException() = runTest {
val cancellation = CancellationException("cancelled")
val repository = DataStoreUiPreferencesRepository(
FailingWritePreferencesDataStore(cancellation),
)
val actual = runCatching {
repository.update { it.copy(blurEnabled = true) }
}.exceptionOrNull()
assertEquals(cancellation, actual)
}
private class InMemoryPreferencesDataStore : DataStore<Preferences> {
private val state = MutableStateFlow<Preferences>(emptyPreferences())
override val data: Flow<Preferences> = state
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = transform(state.value).also(state::value::set)
}
private class CorruptPreferencesDataStore : DataStore<Preferences> {
override val data: Flow<Preferences> = kotlinx.coroutines.flow.flow {
throw IOException("corrupt preferences")
}
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = error("not used")
}
private class FailingWritePreferencesDataStore(
private val failure: Throwable = IOException("write failed"),
) : DataStore<Preferences> {
override val data: Flow<Preferences> = MutableStateFlow(emptyPreferences())
override suspend fun updateData(
transform: suspend (t: Preferences) -> Preferences,
): Preferences = throw failure
}
}
@@ -147,6 +147,35 @@ class SessionCoordinator(
} }
} }
suspend fun endActiveSession(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val logoutResult = profile.gateway().logout(profile.id)) {
is DomainResult.Failure -> return@storeOperation logoutResult
is DomainResult.Success -> Unit
}
if (profile.type == ProviderType.LOCAL_EXPERIENCE) {
profileStore.setActiveProfile(null)
} else {
DomainResult.Success(Unit)
}
}
suspend fun forgetSavedCredentials(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
is DomainResult.Failure -> return@storeOperation credentialResult
is DomainResult.Success -> Unit
}
val preferences = profileStore.loginPreferences(profile.id)
profileStore.saveLoginPreferences(preferences.copy(autoLoginEnabled = false))
}
suspend fun validateActiveSession(): DomainResult<SessionValidity> = storeOperation {
val profile = profileStore.activeProfile()
?: return@storeOperation DomainResult.Success(SessionValidity.INVALID)
profile.gateway().validateSession(profile.id)
}
suspend fun logout(): DomainResult<Unit> = storeOperation { suspend fun logout(): DomainResult<Unit> = storeOperation {
val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit) val profile = profileStore.activeProfile() ?: return@storeOperation DomainResult.Success(Unit)
when (val credentialResult = credentialStore.clearCredentials(profile.id)) { when (val credentialResult = credentialStore.clearCredentials(profile.id)) {
+2
View File
@@ -15,6 +15,7 @@ adaptive = "1.3.0"
adaptiveNavigationSuite = "1.4.0" adaptiveNavigationSuite = "1.4.0"
window = "1.5.1" window = "1.5.1"
composeScreenshot = "0.0.1-alpha16" composeScreenshot = "0.0.1-alpha16"
miuix = "0.9.3"
junit = "4.13.2" junit = "4.13.2"
androidxJunit = "1.2.1" androidxJunit = "1.2.1"
espresso = "3.6.1" espresso = "3.6.1"
@@ -40,6 +41,7 @@ compose-material-icons-extended = { module = "androidx.compose.material:material
compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" }
compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" }
compose-screenshot-test = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" } compose-screenshot-test = { module = "com.android.tools.screenshot:screenshot-validation-api", version.ref = "composeScreenshot" }
miuix-ui-android = { module = "top.yukonga.miuix.kmp:miuix-ui-android", version.ref = "miuix" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
+1
View File
@@ -31,6 +31,7 @@ include(":runtime:simulation")
include(":absdk-bridge") include(":absdk-bridge")
include(":testing:fakes") include(":testing:fakes")
include(":android:core:designsystem") include(":android:core:designsystem")
include(":android:core:preferences")
include(":android:core:session") include(":android:core:session")
include(":android:feature:infrared") include(":android:feature:infrared")
include(":android:app") include(":android:app")
@@ -12,6 +12,7 @@ import com.flagship.abox.manager.domain.ProviderType
import com.flagship.abox.manager.domain.SessionCoordinator import com.flagship.abox.manager.domain.SessionCoordinator
import com.flagship.abox.manager.domain.SessionGateway import com.flagship.abox.manager.domain.SessionGateway
import com.flagship.abox.manager.domain.Session import com.flagship.abox.manager.domain.Session
import com.flagship.abox.manager.domain.SessionValidity
import com.flagship.abox.manager.domain.Username import com.flagship.abox.manager.domain.Username
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -133,6 +134,101 @@ class SessionCoordinatorTest {
) )
} }
@Test
fun endActiveSessionKeepsAboxCredentials() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
val result = fixture.coordinator.endActiveSession()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile, fixture.profiles.activeProfile())
assertNull(fixture.remote.observeSession(remoteProfile.id).first())
assertEquals(
credentials,
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
assertTrue(fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
}
@Test
fun forgetSavedCredentialsDisablesAutoLoginAndKeepsSessionStateExplicit() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(remoteProfile)
fixture.coordinator.login(
remoteProfile.id,
credentials,
rememberUsername = true,
autoLogin = true,
)
val result = fixture.coordinator.forgetSavedCredentials()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile, fixture.profiles.activeProfile())
assertEquals(
remoteProfile.id,
fixture.remote.observeSession(remoteProfile.id).first()?.profileId,
)
assertNull(
(fixture.credentials.loadCredentials(remoteProfile.id) as DomainResult.Success).value,
)
val preferences = fixture.profiles.loginPreferences(remoteProfile.id)
assertEquals(credentials.username, preferences.rememberedUsername)
assertTrue(!preferences.autoLoginEnabled)
}
@Test
fun switchProviderClearsActiveProfileAfterGatewayLogout() = runTest {
val profiles = FakeProviderProfileStore()
val remote = RecordingSessionGateway(profiles)
val coordinator = SessionCoordinator(
profileStore = profiles,
credentialStore = FakeCredentialStore(),
remoteGateway = remote,
localGateway = FakeLocalSessionGateway(),
)
coordinator.selectProvider(remoteProfile)
remote.login(remoteProfile.id, credentials)
val result = coordinator.switchProvider()
assertTrue(result is DomainResult.Success)
assertEquals(remoteProfile.id, remote.activeProfileAtLogout)
assertNull(profiles.activeProfile())
}
@Test
fun validateActiveSessionDoesNotRelogin() = runTest {
val remote = CountingSessionGateway()
val credentialsStore = CountingCredentialStore()
val profiles = FakeProviderProfileStore()
val coordinator = SessionCoordinator(
profileStore = profiles,
credentialStore = credentialsStore,
remoteGateway = remote,
localGateway = FakeLocalSessionGateway(),
)
profiles.saveProfile(remoteProfile)
profiles.setActiveProfile(remoteProfile.id)
remote.setValidationResult(
remoteProfile.id,
DomainResult.Success(SessionValidity.INVALID),
)
val result = coordinator.validateActiveSession()
assertEquals(DomainResult.Success(SessionValidity.INVALID), result)
assertEquals(0, remote.loginCalls)
assertEquals(0, credentialsStore.loadCalls)
}
@Test @Test
fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest { fun authenticationFailureDuringRecoveryClearsSavedCredential() = runTest {
val fixture = Fixture() val fixture = Fixture()
@@ -189,6 +285,18 @@ class SessionCoordinatorTest {
assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled) assertTrue(!fixture.profiles.loginPreferences(remoteProfile.id).autoLoginEnabled)
} }
@Test
fun endActiveSessionClearsLocalActiveProfile() = runTest {
val fixture = Fixture()
fixture.coordinator.selectProvider(localProfile)
val result = fixture.coordinator.endActiveSession()
assertTrue(result is DomainResult.Success)
assertNull(fixture.profiles.activeProfile())
assertNull(fixture.local.observeSession(localProfile.id).first())
}
private class Fixture { private class Fixture {
val profiles = FakeProviderProfileStore() val profiles = FakeProviderProfileStore()
val credentials = FakeCredentialStore() val credentials = FakeCredentialStore()
@@ -197,6 +305,19 @@ class SessionCoordinatorTest {
val coordinator = SessionCoordinator(profiles, credentials, remote, local) val coordinator = SessionCoordinator(profiles, credentials, remote, local)
} }
private class RecordingSessionGateway(
private val profileStore: FakeProviderProfileStore,
private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : SessionGateway by delegate {
var activeProfileAtLogout: ProviderProfileId? = null
private set
override suspend fun logout(profileId: ProviderProfileId): DomainResult<Unit> {
activeProfileAtLogout = profileStore.activeProfile()?.id
return delegate.logout(profileId)
}
}
private class CountingSessionGateway( private class CountingSessionGateway(
private val delegate: FakeSessionGateway = FakeSessionGateway(), private val delegate: FakeSessionGateway = FakeSessionGateway(),
) : SessionGateway by delegate { ) : SessionGateway by delegate {
@@ -214,6 +335,13 @@ class SessionCoordinatorTest {
fun failLogin(profileId: ProviderProfileId, error: DomainError) { fun failLogin(profileId: ProviderProfileId, error: DomainError) {
delegate.failLogin(profileId, error) delegate.failLogin(profileId, error)
} }
fun setValidationResult(
profileId: ProviderProfileId,
result: DomainResult<SessionValidity>,
) {
delegate.setValidationResult(profileId, result)
}
} }
private class CountingCredentialStore( private class CountingCredentialStore(