Compare commits
10
Commits
b39b3f95c7
...
05d44578b9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05d44578b9 | ||
|
|
54eef78a46 | ||
|
|
c1c21551fb | ||
|
|
283f56a3b6 | ||
|
|
b24ca98e14 | ||
|
|
063a3f8dab | ||
|
|
cae2ea9ce0 | ||
|
|
4055c53362 | ||
|
|
686176b148 | ||
|
|
79b47fc354 |
@@ -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"))
|
||||||
|
|||||||
+215
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+90
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-8
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+499
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
+227
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
@@ -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
|
||||||
|
}
|
||||||
+39
-3
@@ -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>
|
||||||
|
|||||||
+12
-1
@@ -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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+91
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+111
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+197
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
+183
@@ -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("返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
+469
-52
@@ -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,
|
||||||
|
|||||||
+6
-5
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
+175
-26
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
+1
@@ -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()
|
||||||
|
|||||||
+114
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+20
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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 = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -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("截图基线")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
+37
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
+93
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+35
@@ -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,
|
||||||
|
)
|
||||||
+16
@@ -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
|
||||||
|
}
|
||||||
+157
@@ -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)) {
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
+128
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user