From 5a55d2591ed9a781b10e0d812eca14ec78c5cd4e Mon Sep 17 00:00:00 2001 From: haoyue Date: Sun, 10 May 2026 16:43:16 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=A4=96?= =?UTF-8?q?=E8=A7=82=E8=AE=BE=E7=BD=AE=EF=BC=8C=E6=94=AF=E6=8C=81=E6=B5=85?= =?UTF-8?q?=E8=89=B2/=E6=B7=B1=E8=89=B2=E4=B8=BB=E9=A2=98=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/commonMain/kotlin/cn/edu/ubaa/App.kt | 31 +- .../edu/ubaa/ui/common/components/Sidebar.kt | 6 + .../edu/ubaa/ui/navigation/MainAppScreen.kt | 38 ++- .../ui/screens/menu/ThemeSettingsScreen.kt | 285 ++++++++++++++++++ .../kotlin/cn/edu/ubaa/ui/theme/Theme.kt | 94 +++++- 5 files changed, 447 insertions(+), 7 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt index 75efa449..fdb375f9 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt @@ -1,6 +1,7 @@ package cn.edu.ubaa import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.heightIn @@ -11,7 +12,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -28,6 +31,7 @@ import cn.edu.ubaa.ui.navigation.MainAppScreen import cn.edu.ubaa.ui.screens.auth.AuthViewModel import cn.edu.ubaa.ui.screens.auth.ConnectionModeSelectionScreen import cn.edu.ubaa.ui.screens.auth.LoginScreen +import cn.edu.ubaa.ui.screens.menu.ThemeMode import cn.edu.ubaa.ui.screens.splash.SplashScreen import cn.edu.ubaa.ui.theme.PreloadFonts import cn.edu.ubaa.ui.theme.UBAATheme @@ -48,7 +52,24 @@ fun App() { // 预加载应用所需的中文字体 PreloadFonts() - UBAATheme { + val systemDarkTheme = isSystemInDarkTheme() + var themeMode by rememberSaveable { mutableStateOf(ThemeMode.SYSTEM) } + var themeColorValue by rememberSaveable { mutableStateOf(0xFF6750A4) } + var useDynamicColor by rememberSaveable { mutableStateOf(false) } + var oledEnhance by rememberSaveable { mutableStateOf(false) } + val themeColor = Color(themeColorValue) + val darkTheme = + when (themeMode) { + ThemeMode.SYSTEM -> systemDarkTheme + ThemeMode.LIGHT -> false + ThemeMode.DARK -> true + } + + UBAATheme( + darkTheme = darkTheme, + seedColor = themeColor, + oledEnhance = oledEnhance, + ) { val authViewModel: AuthViewModel = viewModel { AuthViewModel() } val uiState by authViewModel.uiState.collectAsState() val loginForm by authViewModel.loginForm.collectAsState() @@ -199,6 +220,14 @@ fun App() { authViewModel.switchConnectionMode(mode) appScope.launch { checkStartupPrompts() } }, + themeMode = themeMode, + onThemeModeSelected = { themeMode = it }, + themeColorValue = themeColorValue, + onColorSelected = { themeColorValue = it }, + useDynamicColor = useDynamicColor, + onToggleDynamicColor = { useDynamicColor = it }, + oledEnhance = oledEnhance, + onToggleOledEnhance = { oledEnhance = it }, onLogoutClick = { authViewModel.logout() }, modifier = Modifier.fillMaxSize(), ) diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/Sidebar.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/Sidebar.kt index 99ae9ae8..d604ed16 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/Sidebar.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/common/components/Sidebar.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ExitToApp import androidx.compose.material.icons.filled.AccountCircle +import androidx.compose.material.icons.filled.Brush import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings @@ -44,6 +45,7 @@ fun Sidebar( onLogoutClick: () -> Unit, onMyClick: () -> Unit, onSettingsClick: () -> Unit, + onAppearanceClick: () -> Unit, onAboutClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -117,6 +119,10 @@ fun Sidebar( Spacer(modifier = Modifier.height(8.dp)) + SidebarMenuItem(icon = Icons.Default.Brush, title = "外观设置", onClick = onAppearanceClick) + + Spacer(modifier = Modifier.height(8.dp)) + SidebarMenuItem(icon = Icons.Default.Info, title = "关于", onClick = onAboutClick) Spacer(modifier = Modifier.weight(1f)) diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt index 5be4a70d..4ced42e9 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt @@ -54,7 +54,18 @@ import cn.edu.ubaa.ui.screens.libbook.LibBookBookingsScreen import cn.edu.ubaa.ui.screens.libbook.LibBookHomeScreen import cn.edu.ubaa.ui.screens.libbook.LibBookReserveScreen import cn.edu.ubaa.ui.screens.libbook.LibBookViewModel -import cn.edu.ubaa.ui.screens.menu.* +import cn.edu.ubaa.ui.screens.menu.AboutScreen +import cn.edu.ubaa.ui.screens.menu.AdvancedFeaturesScreen +import cn.edu.ubaa.ui.screens.menu.HomeScreen +import cn.edu.ubaa.ui.screens.menu.HomeTodoAction +import cn.edu.ubaa.ui.screens.menu.HomeTodoItem +import cn.edu.ubaa.ui.screens.menu.HomeTodoSource +import cn.edu.ubaa.ui.screens.menu.MyScreen +import cn.edu.ubaa.ui.screens.menu.RegularFeaturesScreen +import cn.edu.ubaa.ui.screens.menu.SettingsScreen +import cn.edu.ubaa.ui.screens.menu.ThemeMode +import cn.edu.ubaa.ui.screens.menu.ThemeSettingsScreen +import cn.edu.ubaa.ui.screens.menu.buildHomeTodoItems import cn.edu.ubaa.ui.screens.schedule.CourseDetailScreen import cn.edu.ubaa.ui.screens.schedule.ScheduleScreen import cn.edu.ubaa.ui.screens.schedule.ScheduleViewModel @@ -81,6 +92,7 @@ enum class AppScreen { ADVANCED, MY, SETTINGS, + THEME_SETTINGS, ABOUT, SCHEDULE, EXAM, @@ -126,6 +138,14 @@ fun MainAppScreen( availableConnectionModes: List, onEnsureUserInfo: () -> Unit, onConnectionModeSelected: (ConnectionMode) -> Unit, + themeMode: ThemeMode, + onThemeModeSelected: (ThemeMode) -> Unit, + themeColorValue: Long, + onColorSelected: (Long) -> Unit, + useDynamicColor: Boolean, + onToggleDynamicColor: (Boolean) -> Unit, + oledEnhance: Boolean, + onToggleOledEnhance: (Boolean) -> Unit, onLogoutClick: () -> Unit, modifier: Modifier = Modifier, ) { @@ -553,6 +573,7 @@ fun MainAppScreen( AppScreen.ADVANCED -> "高级功能" AppScreen.MY -> "我的" AppScreen.SETTINGS -> "设置" + AppScreen.THEME_SETTINGS -> "外观设置" AppScreen.ABOUT -> "关于" AppScreen.SCHEDULE -> "课程表" AppScreen.EXAM -> "考试查询" @@ -703,6 +724,17 @@ fun MainAppScreen( availableModes = availableConnectionModes, onModeSelected = onConnectionModeSelected, ) + AppScreen.THEME_SETTINGS -> + ThemeSettingsScreen( + themeMode = themeMode, + selectedColorValue = themeColorValue, + onColorSelected = onColorSelected, + useDynamicColor = useDynamicColor, + onToggleDynamicColor = onToggleDynamicColor, + oledEnhance = oledEnhance, + onThemeModeSelected = onThemeModeSelected, + onToggleOledEnhance = onToggleOledEnhance, + ) AppScreen.ABOUT -> AboutScreen() AppScreen.SCHEDULE -> ScheduleScreen( @@ -993,6 +1025,10 @@ fun MainAppScreen( showSidebar = false navigateTo(AppScreen.SETTINGS) }, + onAppearanceClick = { + showSidebar = false + navigateTo(AppScreen.THEME_SETTINGS) + }, onAboutClick = { showSidebar = false navigateTo(AppScreen.ABOUT) diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt new file mode 100644 index 00000000..e4258370 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt @@ -0,0 +1,285 @@ +package cn.edu.ubaa.ui.screens.menu + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +enum class ThemeMode { + SYSTEM, + LIGHT, + DARK, +} + +private val colorThemeTypes = listOf( + 0xFF6750A4L to "默认", + 0xFF009688L to "青色", + 0xFF2196F3L to "蓝色", + 0xFF3F51B5L to "靛蓝色", + 0xFF7E57C2L to "紫罗兰色", + 0xFFE91E63L to "粉红色", + 0xFFFFEB3BL to "黄色", + 0xFFFF9800L to "橙色", + 0xFFFF5722L to "深橙色", +) + +@Composable +fun ThemeSettingsScreen( + themeMode: ThemeMode, + selectedColorValue: Long, + onColorSelected: (Long) -> Unit, + useDynamicColor: Boolean, + onToggleDynamicColor: (Boolean) -> Unit, + oledEnhance: Boolean, + onThemeModeSelected: (ThemeMode) -> Unit, + onToggleOledEnhance: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Text( + text = "外观设置", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "深色模式", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(12.dp)) + ThemeMode.values().forEach { mode -> + val title = + when (mode) { + ThemeMode.SYSTEM -> "跟随系统" + ThemeMode.LIGHT -> "浅色" + ThemeMode.DARK -> "深色" + } + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onThemeModeSelected(mode) } + .padding(vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(text = title, style = MaterialTheme.typography.bodyLarge) + RadioButton( + selected = themeMode == mode, + onClick = { onThemeModeSelected(mode) }, + ) + } + if (mode != ThemeMode.DARK) { + HorizontalDivider(color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f)) + } + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Text(text = "配色方案", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + colorThemeTypes.forEach { (colorValue, label) -> + val color = Color(colorValue) + val selected = colorValue == selectedColorValue + Box( + modifier = Modifier + .alpha(if (useDynamicColor) 0.5f else 1f) + .clickable(enabled = !useDynamicColor) { + onColorSelected(colorValue) + } + .padding(vertical = 4.dp), + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + ThemeSchemePreview(seedColor = color, selected = selected) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = if (useDynamicColor) "动态配色启用时,颜色方案由系统/主题自动选择。" else "选择你希望的应用整体色调。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(text = "动态配色", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "开启后尝试使用系统动态配色。", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = useDynamicColor, + onCheckedChange = onToggleDynamicColor, + ) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text(text = "OLED 优化", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "深色模式下使用纯黑背景,适合 OLED 屏幕。", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = oledEnhance, + onCheckedChange = onToggleOledEnhance, + ) + } + } + } +} + +@Composable +private fun ThemeSchemePreview(seedColor: Color, selected: Boolean) { + val cardShape = RoundedCornerShape(12.dp) + Box( + modifier = + Modifier + .size(88.dp) + .clip(cardShape) + .background(Color(0xFFF1F1F1)) + .border( + width = 1.dp, + color = if (selected) blend(seedColor, Color.Black, 0.10f) else Color.Transparent, + shape = cardShape, + ) + .padding(10.dp), + ) { + Box( + modifier = + Modifier + .align(Alignment.Center) + .size(62.dp) + .clip(CircleShape), + ) { + Column(modifier = Modifier.fillMaxSize()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .background(blend(seedColor, Color.White, 0.70f)), + ) + Row(modifier = Modifier.fillMaxWidth().weight(1f)) { + Box( + modifier = + Modifier + .weight(1f) + .fillMaxSize() + .background(blend(seedColor, Color.Black, 0.36f)), + ) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxSize() + .background(blend(seedColor, Color.White, 0.36f)), + ) + } + } + } + if (selected) { + Box( + modifier = + Modifier + .align(Alignment.TopEnd) + .size(28.dp) + .background(MaterialTheme.colorScheme.primary, CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = "已选择", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) + } + } + } +} + +private fun blend(start: Color, end: Color, amount: Float): Color { + val clampedAmount = amount.coerceIn(0f, 1f) + val inverseAmount = 1f - clampedAmount + return Color( + red = start.red * inverseAmount + end.red * clampedAmount, + green = start.green * inverseAmount + end.green * clampedAmount, + blue = start.blue * inverseAmount + end.blue * clampedAmount, + alpha = start.alpha * inverseAmount + end.alpha * clampedAmount, + ) +} diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt index 9150c49d..7ce1aefe 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt @@ -4,23 +4,107 @@ import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface +import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color -private val DarkColorScheme = darkColorScheme() -private val LightColorScheme = lightColorScheme() +private fun darkOledColorScheme(seedColor: Color) = + darkTonalColorScheme(seedColor) + .copy( + background = Color.Black, + surface = Color.Black, + surfaceVariant = blend(seedColor, Color.Black, 0.78f), + onBackground = Color.White, + onSurface = Color.White, + ) @Composable -fun UBAATheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { +fun UBAATheme( + darkTheme: Boolean = isSystemInDarkTheme(), + seedColor: Color = Color(0xFF6750A4), + oledEnhance: Boolean = false, + content: @Composable () -> Unit, +) { val colorScheme = when { - darkTheme -> DarkColorScheme - else -> LightColorScheme + darkTheme && oledEnhance -> darkOledColorScheme(seedColor) + darkTheme -> darkTonalColorScheme(seedColor) + else -> lightTonalColorScheme(seedColor) } MaterialTheme(colorScheme = colorScheme, typography = getAppTypography()) { Surface(modifier = Modifier.fillMaxSize(), color = colorScheme.background) { content() } } } + +private fun lightTonalColorScheme(seedColor: Color): ColorScheme { + val primary = accessiblePrimary(seedColor, darkTheme = false) + return lightColorScheme( + primary = primary, + onPrimary = Color.White, + primaryContainer = blend(primary, Color.White, 0.80f), + onPrimaryContainer = blend(primary, Color.Black, 0.25f), + secondary = blend(primary, Color(0xFF006A6A), 0.35f), + onSecondary = Color.White, + secondaryContainer = blend(primary, Color.White, 0.86f), + onSecondaryContainer = blend(primary, Color.Black, 0.30f), + tertiary = blend(primary, Color(0xFF9A405D), 0.35f), + onTertiary = Color.White, + tertiaryContainer = blend(primary, Color.White, 0.88f), + onTertiaryContainer = blend(primary, Color.Black, 0.28f), + background = blend(primary, Color.White, 0.96f), + onBackground = Color(0xFF1D1B20), + surface = blend(primary, Color.White, 0.98f), + onSurface = Color(0xFF1D1B20), + surfaceVariant = blend(primary, Color.White, 0.90f), + onSurfaceVariant = Color(0xFF49454F), + outline = blend(primary, Color(0xFF79747E), 0.18f), + outlineVariant = blend(primary, Color(0xFFCAC4D0), 0.35f), + ) +} + +private fun darkTonalColorScheme(seedColor: Color): ColorScheme { + val primary = accessiblePrimary(seedColor, darkTheme = true) + return darkColorScheme( + primary = primary, + onPrimary = Color(0xFF1D1B20), + primaryContainer = blend(primary, Color.Black, 0.58f), + onPrimaryContainer = blend(primary, Color.White, 0.78f), + secondary = blend(primary, Color(0xFF80CBC4), 0.35f), + onSecondary = Color(0xFF1D1B20), + secondaryContainer = blend(primary, Color.Black, 0.66f), + onSecondaryContainer = blend(primary, Color.White, 0.82f), + tertiary = blend(primary, Color(0xFFF48FB1), 0.35f), + onTertiary = Color(0xFF1D1B20), + tertiaryContainer = blend(primary, Color.Black, 0.68f), + onTertiaryContainer = blend(primary, Color.White, 0.84f), + background = blend(primary, Color.Black, 0.93f), + onBackground = Color(0xFFE6E1E5), + surface = blend(primary, Color.Black, 0.90f), + onSurface = Color(0xFFE6E1E5), + surfaceVariant = blend(primary, Color.Black, 0.78f), + onSurfaceVariant = Color(0xFFCAC4D0), + outline = blend(primary, Color(0xFF938F99), 0.22f), + outlineVariant = blend(primary, Color(0xFF49454F), 0.35f), + ) +} + +private fun accessiblePrimary(color: Color, darkTheme: Boolean): Color { + val target = if (darkTheme) Color.White else Color.Black + val amount = if (darkTheme) 0.32f else 0.12f + return blend(color, target, amount) +} + +private fun blend(start: Color, end: Color, amount: Float): Color { + val clampedAmount = amount.coerceIn(0f, 1f) + val inverseAmount = 1f - clampedAmount + return Color( + red = start.red * inverseAmount + end.red * clampedAmount, + green = start.green * inverseAmount + end.green * clampedAmount, + blue = start.blue * inverseAmount + end.blue * clampedAmount, + alpha = start.alpha * inverseAmount + end.alpha * clampedAmount, + ) +} From 0315699b69b5b7e97fa781a1066e782684dc0fe0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 08:46:53 +0000 Subject: [PATCH 2/3] chore: auto-format Kotlin code --- .../ui/screens/menu/ThemeSettingsScreen.kt | 84 +++++++++---------- .../kotlin/cn/edu/ubaa/ui/theme/Theme.kt | 2 +- 2 files changed, 40 insertions(+), 46 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt index e4258370..d4d788c5 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt @@ -43,17 +43,18 @@ enum class ThemeMode { DARK, } -private val colorThemeTypes = listOf( - 0xFF6750A4L to "默认", - 0xFF009688L to "青色", - 0xFF2196F3L to "蓝色", - 0xFF3F51B5L to "靛蓝色", - 0xFF7E57C2L to "紫罗兰色", - 0xFFE91E63L to "粉红色", - 0xFFFFEB3BL to "黄色", - 0xFFFF9800L to "橙色", - 0xFFFF5722L to "深橙色", -) +private val colorThemeTypes = + listOf( + 0xFF6750A4L to "默认", + 0xFF009688L to "青色", + 0xFF2196F3L to "蓝色", + 0xFF3F51B5L to "靛蓝色", + 0xFF7E57C2L to "紫罗兰色", + 0xFFE91E63L to "粉红色", + 0xFFFFEB3BL to "黄色", + 0xFFFF9800L to "橙色", + 0xFFFF5722L to "深橙色", + ) @Composable fun ThemeSettingsScreen( @@ -76,7 +77,9 @@ fun ThemeSettingsScreen( Spacer(modifier = Modifier.height(24.dp)) - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text(text = "深色模式", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(12.dp)) @@ -88,10 +91,10 @@ fun ThemeSettingsScreen( ThemeMode.DARK -> "深色" } Row( - modifier = Modifier - .fillMaxWidth() - .clickable { onThemeModeSelected(mode) } - .padding(vertical = 10.dp), + modifier = + Modifier.fillMaxWidth() + .clickable { onThemeModeSelected(mode) } + .padding(vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, ) { @@ -110,26 +113,24 @@ fun ThemeSettingsScreen( Spacer(modifier = Modifier.height(16.dp)) - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text(text = "配色方案", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(12.dp)) Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { colorThemeTypes.forEach { (colorValue, label) -> val color = Color(colorValue) val selected = colorValue == selectedColorValue Box( - modifier = Modifier - .alpha(if (useDynamicColor) 0.5f else 1f) - .clickable(enabled = !useDynamicColor) { - onColorSelected(colorValue) - } - .padding(vertical = 4.dp), + modifier = + Modifier.alpha(if (useDynamicColor) 0.5f else 1f) + .clickable(enabled = !useDynamicColor) { onColorSelected(colorValue) } + .padding(vertical = 4.dp), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { ThemeSchemePreview(seedColor = color, selected = selected) @@ -154,7 +155,9 @@ fun ThemeSettingsScreen( Spacer(modifier = Modifier.height(16.dp)) - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -180,7 +183,9 @@ fun ThemeSettingsScreen( Spacer(modifier = Modifier.height(16.dp)) - Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant) + ) { Row( modifier = Modifier.fillMaxWidth().padding(16.dp), verticalAlignment = Alignment.CenterVertically, @@ -209,8 +214,7 @@ private fun ThemeSchemePreview(seedColor: Color, selected: Boolean) { val cardShape = RoundedCornerShape(12.dp) Box( modifier = - Modifier - .size(88.dp) + Modifier.size(88.dp) .clip(cardShape) .background(Color(0xFFF1F1F1)) .border( @@ -221,32 +225,23 @@ private fun ThemeSchemePreview(seedColor: Color, selected: Boolean) { .padding(10.dp), ) { Box( - modifier = - Modifier - .align(Alignment.Center) - .size(62.dp) - .clip(CircleShape), + modifier = Modifier.align(Alignment.Center).size(62.dp).clip(CircleShape), ) { Column(modifier = Modifier.fillMaxSize()) { Box( modifier = - Modifier - .fillMaxWidth() - .weight(1f) - .background(blend(seedColor, Color.White, 0.70f)), + Modifier.fillMaxWidth().weight(1f).background(blend(seedColor, Color.White, 0.70f)), ) Row(modifier = Modifier.fillMaxWidth().weight(1f)) { Box( modifier = - Modifier - .weight(1f) + Modifier.weight(1f) .fillMaxSize() .background(blend(seedColor, Color.Black, 0.36f)), ) Box( modifier = - Modifier - .weight(1f) + Modifier.weight(1f) .fillMaxSize() .background(blend(seedColor, Color.White, 0.36f)), ) @@ -256,8 +251,7 @@ private fun ThemeSchemePreview(seedColor: Color, selected: Boolean) { if (selected) { Box( modifier = - Modifier - .align(Alignment.TopEnd) + Modifier.align(Alignment.TopEnd) .size(28.dp) .background(MaterialTheme.colorScheme.primary, CircleShape), contentAlignment = Alignment.Center, diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt index 7ce1aefe..3cec11ef 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt @@ -2,9 +2,9 @@ package cn.edu.ubaa.ui.theme import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.ColorScheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface -import androidx.compose.material3.ColorScheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable From 2abe63d0feb7526d4e4ea4a2493f48acf596dd55 Mon Sep 17 00:00:00 2001 From: MooreFoss Date: Sun, 10 May 2026 18:55:30 +0800 Subject: [PATCH 3/3] fix appearance settings review findings --- .../src/commonMain/kotlin/cn/edu/ubaa/App.kt | 25 ++++- .../edu/ubaa/ui/navigation/MainAppScreen.kt | 1 + .../ui/screens/menu/ThemeSettingsScreen.kt | 21 ++-- .../kotlin/cn/edu/ubaa/ui/theme/Theme.kt | 97 +++++++++++++------ .../ubaa/ui/theme/ThemeColorContrastTest.kt | 21 ++++ .../ubaa/api/storage/ThemePreferenceStore.kt | 52 ++++++++++ .../edu/ubaa/api/ThemePreferenceStoreTest.kt | 43 ++++++++ 7 files changed, 222 insertions(+), 38 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/theme/ThemeColorContrastTest.kt create mode 100644 shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/ThemePreferenceStore.kt create mode 100644 shared/src/commonTest/kotlin/cn/edu/ubaa/api/ThemePreferenceStoreTest.kt diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt index fdb375f9..ba4ac388 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt @@ -26,6 +26,8 @@ import cn.edu.ubaa.api.auth.AppAnnouncement import cn.edu.ubaa.api.auth.AppVersionCheckResponse import cn.edu.ubaa.api.auth.UpdateService import cn.edu.ubaa.api.storage.AnnouncementReadStore +import cn.edu.ubaa.api.storage.StoredThemePreferences +import cn.edu.ubaa.api.storage.ThemePreferenceStore import cn.edu.ubaa.ui.common.components.ReleaseNotesText import cn.edu.ubaa.ui.navigation.MainAppScreen import cn.edu.ubaa.ui.screens.auth.AuthViewModel @@ -53,10 +55,13 @@ fun App() { PreloadFonts() val systemDarkTheme = isSystemInDarkTheme() - var themeMode by rememberSaveable { mutableStateOf(ThemeMode.SYSTEM) } - var themeColorValue by rememberSaveable { mutableStateOf(0xFF6750A4) } - var useDynamicColor by rememberSaveable { mutableStateOf(false) } - var oledEnhance by rememberSaveable { mutableStateOf(false) } + val storedThemePreferences = remember { ThemePreferenceStore.get() } + var themeMode by rememberSaveable { + mutableStateOf(ThemeMode.fromStorageKey(storedThemePreferences.themeMode)) + } + var themeColorValue by rememberSaveable { mutableStateOf(storedThemePreferences.seedColorValue) } + var useDynamicColor by rememberSaveable { mutableStateOf(storedThemePreferences.useDynamicColor) } + var oledEnhance by rememberSaveable { mutableStateOf(storedThemePreferences.oledEnhance) } val themeColor = Color(themeColorValue) val darkTheme = when (themeMode) { @@ -65,9 +70,21 @@ fun App() { ThemeMode.DARK -> true } + LaunchedEffect(themeMode, themeColorValue, useDynamicColor, oledEnhance) { + ThemePreferenceStore.save( + StoredThemePreferences( + themeMode = themeMode.storageKey, + seedColorValue = themeColorValue, + useDynamicColor = useDynamicColor, + oledEnhance = oledEnhance, + ) + ) + } + UBAATheme( darkTheme = darkTheme, seedColor = themeColor, + dynamicColor = useDynamicColor, oledEnhance = oledEnhance, ) { val authViewModel: AuthViewModel = viewModel { AuthViewModel() } diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt index 4ced42e9..8e0b10a6 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/navigation/MainAppScreen.kt @@ -960,6 +960,7 @@ fun MainAppScreen( AppScreen.COURSE_DETAIL, AppScreen.MY, AppScreen.SETTINGS, + AppScreen.THEME_SETTINGS, AppScreen.ABOUT, AppScreen.BYKC_HOME, AppScreen.BYKC_COURSES, diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt index d4d788c5..90878bfd 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.Card @@ -40,7 +41,15 @@ import androidx.compose.ui.unit.dp enum class ThemeMode { SYSTEM, LIGHT, - DARK, + DARK; + + val storageKey: String + get() = name.lowercase() + + companion object { + fun fromStorageKey(value: String?): ThemeMode = + entries.firstOrNull { it.storageKey == value?.trim()?.lowercase() } ?: SYSTEM + } } private val colorThemeTypes = @@ -68,7 +77,7 @@ fun ThemeSettingsScreen( onToggleOledEnhance: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Column(modifier = modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp)) { Text( text = "外观设置", style = MaterialTheme.typography.titleLarge, @@ -83,7 +92,7 @@ fun ThemeSettingsScreen( Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text(text = "深色模式", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(12.dp)) - ThemeMode.values().forEach { mode -> + ThemeMode.entries.forEach { mode -> val title = when (mode) { ThemeMode.SYSTEM -> "跟随系统" @@ -146,7 +155,7 @@ fun ThemeSettingsScreen( } Spacer(modifier = Modifier.height(8.dp)) Text( - text = if (useDynamicColor) "动态配色启用时,颜色方案由系统/主题自动选择。" else "选择你希望的应用整体色调。", + text = if (useDynamicColor) "自动配色启用时,应用将使用默认色调并随深浅色模式调整。" else "选择你希望的应用整体色调。", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -165,10 +174,10 @@ fun ThemeSettingsScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { Column { - Text(text = "动态配色", style = MaterialTheme.typography.titleMedium) + Text(text = "自动配色", style = MaterialTheme.typography.titleMedium) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "开启后尝试使用系统动态配色。", + text = "开启后使用应用默认色调。", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt index 3cec11ef..ed93ef80 100644 --- a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/theme/Theme.kt @@ -10,9 +10,12 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow private fun darkOledColorScheme(seedColor: Color) = - darkTonalColorScheme(seedColor) + buildDarkTonalColorScheme(seedColor) .copy( background = Color.Black, surface = Color.Black, @@ -25,14 +28,16 @@ private fun darkOledColorScheme(seedColor: Color) = fun UBAATheme( darkTheme: Boolean = isSystemInDarkTheme(), seedColor: Color = Color(0xFF6750A4), + dynamicColor: Boolean = false, oledEnhance: Boolean = false, content: @Composable () -> Unit, ) { + val effectiveSeedColor = if (dynamicColor) Color(0xFF6750A4) else seedColor val colorScheme = when { - darkTheme && oledEnhance -> darkOledColorScheme(seedColor) - darkTheme -> darkTonalColorScheme(seedColor) - else -> lightTonalColorScheme(seedColor) + darkTheme && oledEnhance -> darkOledColorScheme(effectiveSeedColor) + darkTheme -> buildDarkTonalColorScheme(effectiveSeedColor) + else -> buildLightTonalColorScheme(effectiveSeedColor) } MaterialTheme(colorScheme = colorScheme, typography = getAppTypography()) { @@ -40,21 +45,26 @@ fun UBAATheme( } } -private fun lightTonalColorScheme(seedColor: Color): ColorScheme { +internal fun buildLightTonalColorScheme(seedColor: Color): ColorScheme { val primary = accessiblePrimary(seedColor, darkTheme = false) + val primaryContainer = blend(primary, Color.White, 0.80f) + val secondary = blend(primary, Color(0xFF006A6A), 0.35f) + val secondaryContainer = blend(primary, Color.White, 0.86f) + val tertiary = blend(primary, Color(0xFF9A405D), 0.35f) + val tertiaryContainer = blend(primary, Color.White, 0.88f) return lightColorScheme( primary = primary, - onPrimary = Color.White, - primaryContainer = blend(primary, Color.White, 0.80f), - onPrimaryContainer = blend(primary, Color.Black, 0.25f), - secondary = blend(primary, Color(0xFF006A6A), 0.35f), - onSecondary = Color.White, - secondaryContainer = blend(primary, Color.White, 0.86f), - onSecondaryContainer = blend(primary, Color.Black, 0.30f), - tertiary = blend(primary, Color(0xFF9A405D), 0.35f), - onTertiary = Color.White, - tertiaryContainer = blend(primary, Color.White, 0.88f), - onTertiaryContainer = blend(primary, Color.Black, 0.28f), + onPrimary = readableContentColorFor(primary), + primaryContainer = primaryContainer, + onPrimaryContainer = readableContentColorFor(primaryContainer), + secondary = secondary, + onSecondary = readableContentColorFor(secondary), + secondaryContainer = secondaryContainer, + onSecondaryContainer = readableContentColorFor(secondaryContainer), + tertiary = tertiary, + onTertiary = readableContentColorFor(tertiary), + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = readableContentColorFor(tertiaryContainer), background = blend(primary, Color.White, 0.96f), onBackground = Color(0xFF1D1B20), surface = blend(primary, Color.White, 0.98f), @@ -66,21 +76,26 @@ private fun lightTonalColorScheme(seedColor: Color): ColorScheme { ) } -private fun darkTonalColorScheme(seedColor: Color): ColorScheme { +private fun buildDarkTonalColorScheme(seedColor: Color): ColorScheme { val primary = accessiblePrimary(seedColor, darkTheme = true) + val primaryContainer = blend(primary, Color.Black, 0.58f) + val secondary = blend(primary, Color(0xFF80CBC4), 0.35f) + val secondaryContainer = blend(primary, Color.Black, 0.66f) + val tertiary = blend(primary, Color(0xFFF48FB1), 0.35f) + val tertiaryContainer = blend(primary, Color.Black, 0.68f) return darkColorScheme( primary = primary, - onPrimary = Color(0xFF1D1B20), - primaryContainer = blend(primary, Color.Black, 0.58f), - onPrimaryContainer = blend(primary, Color.White, 0.78f), - secondary = blend(primary, Color(0xFF80CBC4), 0.35f), - onSecondary = Color(0xFF1D1B20), - secondaryContainer = blend(primary, Color.Black, 0.66f), - onSecondaryContainer = blend(primary, Color.White, 0.82f), - tertiary = blend(primary, Color(0xFFF48FB1), 0.35f), - onTertiary = Color(0xFF1D1B20), - tertiaryContainer = blend(primary, Color.Black, 0.68f), - onTertiaryContainer = blend(primary, Color.White, 0.84f), + onPrimary = readableContentColorFor(primary), + primaryContainer = primaryContainer, + onPrimaryContainer = readableContentColorFor(primaryContainer), + secondary = secondary, + onSecondary = readableContentColorFor(secondary), + secondaryContainer = secondaryContainer, + onSecondaryContainer = readableContentColorFor(secondaryContainer), + tertiary = tertiary, + onTertiary = readableContentColorFor(tertiary), + tertiaryContainer = tertiaryContainer, + onTertiaryContainer = readableContentColorFor(tertiaryContainer), background = blend(primary, Color.Black, 0.93f), onBackground = Color(0xFFE6E1E5), surface = blend(primary, Color.Black, 0.90f), @@ -92,12 +107,38 @@ private fun darkTonalColorScheme(seedColor: Color): ColorScheme { ) } +internal fun contrastRatio(background: Color, foreground: Color): Float { + val backgroundLuminance = relativeLuminance(background) + val foregroundLuminance = relativeLuminance(foreground) + val lighter = max(backgroundLuminance, foregroundLuminance) + val darker = min(backgroundLuminance, foregroundLuminance) + return (lighter + 0.05f) / (darker + 0.05f) +} + private fun accessiblePrimary(color: Color, darkTheme: Boolean): Color { val target = if (darkTheme) Color.White else Color.Black val amount = if (darkTheme) 0.32f else 0.12f return blend(color, target, amount) } +private fun readableContentColorFor(background: Color): Color { + val whiteContrast = contrastRatio(background, Color.White) + val blackContrast = contrastRatio(background, Color.Black) + return if (whiteContrast >= blackContrast) Color.White else Color.Black +} + +private fun relativeLuminance(color: Color): Float = + 0.2126f * linearize(color.red) + + 0.7152f * linearize(color.green) + + 0.0722f * linearize(color.blue) + +private fun linearize(component: Float): Float = + if (component <= 0.03928f) { + component / 12.92f + } else { + ((component + 0.055f) / 1.055f).toDouble().pow(2.4).toFloat() + } + private fun blend(start: Color, end: Color, amount: Float): Color { val clampedAmount = amount.coerceIn(0f, 1f) val inverseAmount = 1f - clampedAmount diff --git a/composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/theme/ThemeColorContrastTest.kt b/composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/theme/ThemeColorContrastTest.kt new file mode 100644 index 00000000..be24b2bd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/cn/edu/ubaa/ui/theme/ThemeColorContrastTest.kt @@ -0,0 +1,21 @@ +package cn.edu.ubaa.ui.theme + +import androidx.compose.ui.graphics.Color +import kotlin.test.Test +import kotlin.test.assertTrue + +class ThemeColorContrastTest { + @Test + fun `light primary content color stays readable for bright preset colors`() { + val brightPresetSeeds = listOf(Color(0xFFFFEB3B), Color(0xFFFF9800), Color(0xFF2196F3)) + + brightPresetSeeds.forEach { seedColor -> + val scheme = buildLightTonalColorScheme(seedColor) + + assertTrue( + contrastRatio(scheme.primary, scheme.onPrimary) >= 4.5f, + "primary/onPrimary contrast was too low for $seedColor", + ) + } + } +} diff --git a/shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/ThemePreferenceStore.kt b/shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/ThemePreferenceStore.kt new file mode 100644 index 00000000..36cdb7d9 --- /dev/null +++ b/shared/src/commonMain/kotlin/cn/edu/ubaa/api/storage/ThemePreferenceStore.kt @@ -0,0 +1,52 @@ +package cn.edu.ubaa.api.storage + +import com.russhwolf.settings.Settings + +data class StoredThemePreferences( + val themeMode: String = ThemePreferenceStore.DEFAULT_THEME_MODE, + val seedColorValue: Long = ThemePreferenceStore.DEFAULT_SEED_COLOR_VALUE, + val useDynamicColor: Boolean = false, + val oledEnhance: Boolean = false, +) + +object ThemePreferenceStore { + const val DEFAULT_THEME_MODE = "system" + const val DEFAULT_SEED_COLOR_VALUE = 0xFF6750A4L + + private const val KEY_THEME_MODE = "appearance_theme_mode" + private const val KEY_SEED_COLOR = "appearance_seed_color" + private const val KEY_DYNAMIC_COLOR = "appearance_dynamic_color" + private const val KEY_OLED_ENHANCE = "appearance_oled_enhance" + + private var _settings: Settings? = null + var settings: Settings + get() = _settings ?: Settings().also { _settings = it } + set(value) { + _settings = value + } + + fun save(preferences: StoredThemePreferences) { + settings.putString(KEY_THEME_MODE, preferences.themeMode) + settings.putString(KEY_SEED_COLOR, preferences.seedColorValue.toString()) + settings.putString(KEY_DYNAMIC_COLOR, preferences.useDynamicColor.toString()) + settings.putString(KEY_OLED_ENHANCE, preferences.oledEnhance.toString()) + } + + fun get(): StoredThemePreferences = + StoredThemePreferences( + themeMode = settings.getStringOrNull(KEY_THEME_MODE) ?: DEFAULT_THEME_MODE, + seedColorValue = + settings.getStringOrNull(KEY_SEED_COLOR)?.toLongOrNull() ?: DEFAULT_SEED_COLOR_VALUE, + useDynamicColor = + settings.getStringOrNull(KEY_DYNAMIC_COLOR)?.toBooleanStrictOrNull() ?: false, + oledEnhance = + settings.getStringOrNull(KEY_OLED_ENHANCE)?.toBooleanStrictOrNull() ?: false, + ) + + fun clear() { + settings.remove(KEY_THEME_MODE) + settings.remove(KEY_SEED_COLOR) + settings.remove(KEY_DYNAMIC_COLOR) + settings.remove(KEY_OLED_ENHANCE) + } +} diff --git a/shared/src/commonTest/kotlin/cn/edu/ubaa/api/ThemePreferenceStoreTest.kt b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/ThemePreferenceStoreTest.kt new file mode 100644 index 00000000..f6d031d8 --- /dev/null +++ b/shared/src/commonTest/kotlin/cn/edu/ubaa/api/ThemePreferenceStoreTest.kt @@ -0,0 +1,43 @@ +package cn.edu.ubaa.api + +import cn.edu.ubaa.api.storage.StoredThemePreferences +import cn.edu.ubaa.api.storage.ThemePreferenceStore +import com.russhwolf.settings.MapSettings +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ThemePreferenceStoreTest { + @BeforeTest + fun resetStore() { + ThemePreferenceStore.settings = MapSettings() + } + + @Test + fun `empty store returns default appearance preferences`() { + assertEquals( + StoredThemePreferences( + themeMode = "system", + seedColorValue = ThemePreferenceStore.DEFAULT_SEED_COLOR_VALUE, + useDynamicColor = false, + oledEnhance = false, + ), + ThemePreferenceStore.get(), + ) + } + + @Test + fun `appearance preferences round trip through settings`() { + val preferences = + StoredThemePreferences( + themeMode = "dark", + seedColorValue = 0xFFFF9800, + useDynamicColor = true, + oledEnhance = true, + ) + + ThemePreferenceStore.save(preferences) + + assertEquals(preferences, ThemePreferenceStore.get()) + } +}