diff --git a/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/App.kt index 75efa449..ba4ac388 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 @@ -23,11 +26,14 @@ 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 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 +54,39 @@ fun App() { // 预加载应用所需的中文字体 PreloadFonts() - UBAATheme { + val systemDarkTheme = isSystemInDarkTheme() + 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) { + ThemeMode.SYSTEM -> systemDarkTheme + ThemeMode.LIGHT -> false + 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() } val uiState by authViewModel.uiState.collectAsState() val loginForm by authViewModel.loginForm.collectAsState() @@ -199,6 +237,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..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 @@ -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( @@ -928,6 +960,7 @@ fun MainAppScreen( AppScreen.COURSE_DETAIL, AppScreen.MY, AppScreen.SETTINGS, + AppScreen.THEME_SETTINGS, AppScreen.ABOUT, AppScreen.BYKC_HOME, AppScreen.BYKC_COURSES, @@ -993,6 +1026,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..90878bfd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/cn/edu/ubaa/ui/screens/menu/ThemeSettingsScreen.kt @@ -0,0 +1,288 @@ +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.foundation.verticalScroll +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; + + val storageKey: String + get() = name.lowercase() + + companion object { + fun fromStorageKey(value: String?): ThemeMode = + entries.firstOrNull { it.storageKey == value?.trim()?.lowercase() } ?: SYSTEM + } +} + +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().verticalScroll(rememberScrollState()).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.entries.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..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 @@ -2,25 +2,150 @@ 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.darkColorScheme 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 val DarkColorScheme = darkColorScheme() -private val LightColorScheme = lightColorScheme() +private fun darkOledColorScheme(seedColor: Color) = + buildDarkTonalColorScheme(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), + dynamicColor: Boolean = false, + oledEnhance: Boolean = false, + content: @Composable () -> Unit, +) { + val effectiveSeedColor = if (dynamicColor) Color(0xFF6750A4) else seedColor val colorScheme = when { - darkTheme -> DarkColorScheme - else -> LightColorScheme + darkTheme && oledEnhance -> darkOledColorScheme(effectiveSeedColor) + darkTheme -> buildDarkTonalColorScheme(effectiveSeedColor) + else -> buildLightTonalColorScheme(effectiveSeedColor) } MaterialTheme(colorScheme = colorScheme, typography = getAppTypography()) { Surface(modifier = Modifier.fillMaxSize(), color = colorScheme.background) { content() } } } + +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 = 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), + 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 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 = 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), + 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), + ) +} + +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 + 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/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()) + } +}