diff --git a/app/src/main/java/com/ahu/ahutong/data/dao/PreferencesManager.kt b/app/src/main/java/com/ahu/ahutong/data/dao/PreferencesManager.kt index 33bba24d..86447578 100644 --- a/app/src/main/java/com/ahu/ahutong/data/dao/PreferencesManager.kt +++ b/app/src/main/java/com/ahu/ahutong/data/dao/PreferencesManager.kt @@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.preferencesDataStore +import com.ahu.ahutong.data.model.AppThemeMode import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -20,6 +21,7 @@ object PreferencesKeys { val COURSE_REMINDER_LIVE_COUNTDOWN_ENABLED = booleanPreferencesKey("course_reminder_live_countdown_enabled") val THEME_COLOR = stringPreferencesKey("theme_color_hex") + val THEME_MODE = stringPreferencesKey("theme_mode") val REPOSITORY_ACCELERATION_SOURCE = stringPreferencesKey("repository_acceleration_source") val PERSONALIZATION_ENABLED = booleanPreferencesKey("personalization_enabled") val PREDICTIVE_PREFETCH_ENABLED = booleanPreferencesKey("predictive_prefetch_enabled") @@ -88,6 +90,20 @@ class PreferencesManager @Inject constructor(@param:ApplicationContext private v context.dataStore.edit { it[PreferencesKeys.BEHAVIOR_RETENTION_DAYS] = value.coerceIn(7, 30) } } + val themeMode: Flow = context.dataStore.data.map { prefs -> + AppThemeMode.fromStorage(prefs[PreferencesKeys.THEME_MODE]) + } + + suspend fun setThemeMode(value: AppThemeMode) { + context.dataStore.edit { prefs -> + if (value == AppThemeMode.FOLLOW_SYSTEM) { + prefs.remove(PreferencesKeys.THEME_MODE) + } else { + prefs[PreferencesKeys.THEME_MODE] = value.storageValue + } + } + } + val themeColor: Flow = context.dataStore.data.map { prefs -> prefs[PreferencesKeys.THEME_COLOR] } diff --git a/app/src/main/java/com/ahu/ahutong/data/model/AppThemeMode.kt b/app/src/main/java/com/ahu/ahutong/data/model/AppThemeMode.kt new file mode 100644 index 00000000..b72c9189 --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/data/model/AppThemeMode.kt @@ -0,0 +1,18 @@ +package com.ahu.ahutong.data.model + +enum class AppThemeMode(val storageValue: String) { + FOLLOW_SYSTEM("follow_system"), + DARK("dark"), + LIGHT("light"); + + fun resolve(systemIsDark: Boolean): Boolean = when (this) { + FOLLOW_SYSTEM -> systemIsDark + DARK -> true + LIGHT -> false + } + + companion object { + fun fromStorage(value: String?): AppThemeMode = + entries.firstOrNull { it.storageValue == value } ?: FOLLOW_SYSTEM + } +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTabs.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTabs.kt index 848985e9..81e33211 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTabs.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidBottomTabs.kt @@ -3,7 +3,6 @@ package com.ahu.ahutong.ui.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.EaseOut import androidx.compose.animation.core.spring -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -21,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow +import androidx.compose.material3.MaterialTheme import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -28,6 +28,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.clearAndSetSemantics @@ -72,7 +73,7 @@ fun LiquidBottomTabs( val isLiquid = LocalIsLiquidGlassEnabled.current val backdrop = if (isLiquid) backdrop else emptyBackdrop() - val isLightTheme = !isSystemInDarkTheme() + val isLightTheme = MaterialTheme.colorScheme.surface.luminance() > 0.5f val accentColor = if (isLiquid) { if (isLightTheme) Color(0xFF0088FF) @@ -111,7 +112,7 @@ fun LiquidBottomTabs( val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr val animationScope = rememberCoroutineScope() - var currentIndex by remember(selectedTabIndex) { + var currentIndex by remember { mutableIntStateOf(selectedTabIndex()) } val dampedDragAnimation = remember(animationScope, isLiquid) { @@ -146,11 +147,11 @@ fun LiquidBottomTabs( } ) } - LaunchedEffect(selectedTabIndex) { - snapshotFlow { selectedTabIndex() } - .collectLatest { index -> - currentIndex = index - } + val requestedIndex = selectedTabIndex() + LaunchedEffect(requestedIndex) { + if (currentIndex != requestedIndex) { + currentIndex = requestedIndex + } } LaunchedEffect(dampedDragAnimation) { snapshotFlow { currentIndex } diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidToggle.kt b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidToggle.kt index fcb8a8dc..3c41bba0 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/components/LiquidToggle.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/components/LiquidToggle.kt @@ -1,31 +1,38 @@ package com.ahu.ahutong.ui.components -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalViewConfiguration import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.LayoutDirection @@ -45,39 +52,43 @@ import com.kyant.backdrop.highlight.Highlight import com.kyant.backdrop.shadow.InnerShadow import com.kyant.backdrop.shadow.Shadow import com.kyant.capsule.ContinuousCapsule -import com.kyant.monet.a1 -import com.kyant.monet.a2 -import com.kyant.monet.n1 -import com.kyant.monet.n2 -import com.kyant.monet.withNight import kotlinx.coroutines.flow.collectLatest +import kotlin.math.abs @Composable fun LiquidToggle( selected: () -> Boolean, onSelect: (Boolean) -> Unit, backdrop: Backdrop, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + userInputEnabled: Boolean = true, + toggleOnTap: Boolean = true, + onHorizontalDragActiveChange: (Boolean) -> Unit = {} ) { val isLiquid = LocalIsLiquidGlassEnabled.current if (!isLiquid) { + val colorScheme = MaterialTheme.colorScheme val switchColor = SwitchDefaults.colors( - checkedThumbColor = 80.a1 withNight 80.n1, - uncheckedThumbColor = 80.a2 withNight 40.n2, - checkedTrackColor = (80.a1 withNight 80.n1).copy(alpha = 0.5f), - uncheckedTrackColor = (80.a2 withNight 40.n2).copy(alpha = 0.5f), - uncheckedBorderColor = 80.n1 withNight 40.n1 + checkedThumbColor = colorScheme.onPrimary, + checkedTrackColor = colorScheme.primary, + checkedBorderColor = colorScheme.primary, + disabledCheckedThumbColor = colorScheme.onPrimary, + disabledCheckedTrackColor = colorScheme.primary, + disabledCheckedBorderColor = colorScheme.primary, + uncheckedThumbColor = colorScheme.outline, + uncheckedTrackColor = colorScheme.surfaceContainerHighest, + uncheckedBorderColor = colorScheme.outline ) Switch( checked = selected(), - onCheckedChange = onSelect, + onCheckedChange = onSelect.takeIf { userInputEnabled && toggleOnTap }, modifier = modifier.height(28f.dp), colors = switchColor ) return } - val isLightTheme = !isSystemInDarkTheme() + val isLightTheme = MaterialTheme.colorScheme.surface.luminance() > 0.5f val accentColor = if (isLightTheme) Color(0xFF34C759) else Color(0xFF30D158) @@ -87,11 +98,25 @@ fun LiquidToggle( val density = LocalDensity.current val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr + val touchSlop = LocalViewConfiguration.current.touchSlop val dragWidth = with(density) { 20f.dp.toPx() } val animationScope = rememberCoroutineScope() - var didDrag by remember { mutableStateOf(false) } + val currentSelected = rememberUpdatedState(selected) + val currentOnSelect = rememberUpdatedState(onSelect) + val currentOnHorizontalDragActiveChange = + rememberUpdatedState(onHorizontalDragActiveChange) + var accumulatedDrag by remember { mutableStateOf(Offset.Zero) } + var gestureMoved by remember { mutableStateOf(false) } + var horizontalDragActive by remember { mutableStateOf(false) } var fraction by remember { mutableFloatStateOf(if (selected()) 1f else 0f) } - val dampedDragAnimation = remember(animationScope) { + val dampedDragAnimation = remember( + animationScope, + userInputEnabled, + toggleOnTap, + touchSlop, + dragWidth, + isLtr + ) { DampedDragAnimation( animationScope = animationScope, initialValue = fraction, @@ -99,28 +124,62 @@ fun LiquidToggle( visibilityThreshold = 0.001f, initialScale = 1f, pressedScale = 1.5f, - onDragStarted = {}, + userDragEnabled = userInputEnabled, + onDragStarted = { + accumulatedDrag = Offset.Zero + gestureMoved = false + horizontalDragActive = false + }, onDragStopped = { - if (didDrag) { + if (horizontalDragActive) { fraction = if (targetValue >= 0.5f) 1f else 0f - onSelect(fraction == 1f) - didDrag = false - } else { - fraction = if (selected()) 0f else 1f - onSelect(fraction == 1f) + currentOnSelect.value(fraction == 1f) + currentOnHorizontalDragActiveChange.value(false) + } else if (!gestureMoved && toggleOnTap) { + fraction = if (currentSelected.value()) 0f else 1f + currentOnSelect.value(fraction == 1f) } + accumulatedDrag = Offset.Zero + gestureMoved = false + horizontalDragActive = false }, onDrag = { _, dragAmount -> - if (!didDrag) { - didDrag = dragAmount.x != 0f + accumulatedDrag += dragAmount + if (!gestureMoved && accumulatedDrag.getDistance() >= touchSlop) { + gestureMoved = true + if (abs(accumulatedDrag.x) > abs(accumulatedDrag.y)) { + horizontalDragActive = true + currentOnHorizontalDragActiveChange.value(true) + } } - val delta = dragAmount.x / dragWidth - fraction = - if (isLtr) (fraction + delta).fastCoerceIn(0f, 1f) - else (fraction - delta).fastCoerceIn(0f, 1f) - } + if (horizontalDragActive) { + val delta = dragAmount.x / dragWidth + fraction = + if (isLtr) (fraction + delta).fastCoerceIn(0f, 1f) + else (fraction - delta).fastCoerceIn(0f, 1f) + } + }, + onDragCancelled = { + if (horizontalDragActive) { + currentOnHorizontalDragActiveChange.value(false) + } + fraction = if (currentSelected.value()) 1f else 0f + animateToValue(fraction) + accumulatedDrag = Offset.Zero + gestureMoved = false + horizontalDragActive = false + }, + pointerEventPass = PointerEventPass.Initial, + shouldConsumeDrag = { horizontalDragActive } ) } + DisposableEffect(Unit) { + onDispose { + if (horizontalDragActive) { + currentOnHorizontalDragActiveChange.value(false) + } + } + } LaunchedEffect(dampedDragAnimation) { snapshotFlow { fraction } .collectLatest { fraction -> @@ -164,10 +223,15 @@ fun LiquidToggle( if (isLtr) lerp(padding, padding + dragWidth, fraction) else lerp(-padding, -(padding + dragWidth), fraction) } - .semantics { - role = Role.Switch - } - .then(dampedDragAnimation.modifier) + .then( + if (userInputEnabled) { + Modifier + .semantics { role = Role.Switch } + .then(dampedDragAnimation.modifier) + } else { + Modifier.clearAndSetSemantics { } + } + ) .drawBackdrop( backdrop = rememberCombinedBackdrop( backdrop, diff --git a/app/src/main/java/com/ahu/ahutong/ui/components/SettingsComponents.kt b/app/src/main/java/com/ahu/ahutong/ui/components/SettingsComponents.kt new file mode 100644 index 00000000..4ffb12cc --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/components/SettingsComponents.kt @@ -0,0 +1,653 @@ +package com.ahu.ahutong.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape +import com.kyant.backdrop.Backdrop +import com.kyant.backdrop.backdrops.layerBackdrop +import com.kyant.backdrop.backdrops.rememberLayerBackdrop +import com.kyant.backdrop.drawBackdrop +import com.kyant.backdrop.effects.blur +import com.kyant.backdrop.effects.vibrancy +import com.kyant.backdrop.shadow.Shadow + +data class SettingsChoice( + val value: T, + val label: String +) + +@Composable +fun settingsScreenBackground(): Color = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surfaceContainerLowest +} else { + MaterialTheme.colorScheme.surface +} + +@Composable +fun settingsGroupColor(): Color = if (LocalIsLiquidGlassEnabled.current) { + MaterialTheme.colorScheme.surface.copy(alpha = 0.86f) +} else { + MaterialTheme.colorScheme.surfaceContainer +} + +@Composable +fun SettingsBackdropContainer( + modifier: Modifier = Modifier, + content: @Composable BoxScope.(Backdrop) -> Unit +) { + val backdrop = rememberLayerBackdrop() + val liquid = LocalIsLiquidGlassEnabled.current + val background = settingsScreenBackground() + val primary = MaterialTheme.colorScheme.primary + val secondary = MaterialTheme.colorScheme.secondary + + Box(modifier = modifier.background(background)) { + Box( + modifier = Modifier + .matchParentSize() + .clipToBounds() + .layerBackdrop(backdrop) + .background( + if (liquid) { + Brush.verticalGradient( + listOf( + background, + primary.copy(alpha = 0.08f), + secondary.copy(alpha = 0.05f), + background + ) + ) + } else { + Brush.linearGradient(listOf(background, background)) + } + ) + ) + content(backdrop) + } +} + +@Composable +fun SettingsPageHeader( + title: String, + modifier: Modifier = Modifier, + onBack: (() -> Unit)? = null, + backdrop: Backdrop? = null +) { + val isLiquid = LocalIsLiquidGlassEnabled.current + val backShape = SmoothRoundedCornerShape(24.dp) + val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val glassTint = if (isDark) { + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.64f) + } else { + Color.White.copy(alpha = 0.46f) + } + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + onBack?.let { + Box( + modifier = Modifier + .size(48.dp) + .then( + if (isLiquid && backdrop != null) { + Modifier.liquidGlassSurface( + backdrop = backdrop, + shape = backShape, + surfaceColor = glassTint + ) + } else { + Modifier + .clip(backShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + } + ) + .clickable(onClick = it), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = "返回", + tint = MaterialTheme.colorScheme.onSurface + ) + } + } + Text( + text = title, + color = MaterialTheme.colorScheme.onSurface, + style = if (onBack == null) { + MaterialTheme.typography.headlineLarge + } else { + MaterialTheme.typography.headlineMedium + }, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +fun SettingsHeroCard( + backdrop: Backdrop, + onClick: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + val isLiquid = LocalIsLiquidGlassEnabled.current + val shape = SmoothRoundedCornerShape(28.dp) + val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val glassTint = if (isDark) { + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.64f) + } else { + Color.White.copy(alpha = 0.46f) + } + Row( + modifier = modifier + .fillMaxWidth() + .then( + if (isLiquid) { + Modifier.liquidGlassSurface(backdrop, shape, glassTint) + } else { + Modifier + .clip(shape) + .background(MaterialTheme.colorScheme.primaryContainer) + } + ) + .clickable(onClick = onClick) + .padding(horizontal = 22.dp, vertical = 18.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + content = content + ) +} + +@Composable +fun SettingsSection( + title: String, + modifier: Modifier = Modifier, + backdrop: Backdrop? = null, + content: @Composable ColumnScope.() -> Unit +) { + val isLiquid = LocalIsLiquidGlassEnabled.current + val shape = SmoothRoundedCornerShape(if (isLiquid) 26.dp else 24.dp) + val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f + val glassTint = if (isDark) { + MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.64f) + } else { + Color.White.copy(alpha = 0.46f) + } + Column( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = title, + modifier = Modifier.padding(horizontal = 20.dp), + color = if (isLiquid) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Column( + modifier = Modifier + .fillMaxWidth() + .then( + if (isLiquid && backdrop != null) { + Modifier.liquidGlassSurface(backdrop, shape, glassTint) + } else { + Modifier + .clip(shape) + .background(settingsGroupColor()) + } + ), + content = content + ) + } +} + +private fun Modifier.liquidGlassSurface( + backdrop: Backdrop, + shape: Shape, + surfaceColor: Color +): Modifier = drawBackdrop( + backdrop = backdrop, + shape = { shape }, + effects = { + vibrancy() + blur(18.dp.toPx()) + }, + shadow = { + Shadow( + radius = 14.dp, + color = Color.Black.copy(alpha = 0.12f) + ) + }, + onDrawSurface = { + drawRect(surfaceColor) + } +) + +@Composable +fun SettingsActionRow( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + leadingIcon: ImageVector? = null, + value: String? = null, + destructive: Boolean = false, + showChevron: Boolean = true, + showDivider: Boolean = true +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .heightIn(min = 68.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + leadingIcon?.let { + Box( + modifier = Modifier + .size(40.dp) + .clip(SmoothRoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = it, + contentDescription = null, + modifier = Modifier.size(22.dp), + tint = MaterialTheme.colorScheme.onSecondaryContainer + ) + } + } + SettingsRowText( + title = title, + subtitle = subtitle, + destructive = destructive, + modifier = Modifier.weight(1f) + ) + value?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + } + if (showChevron) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.72f) + ) + } + } + SettingsDivider(visible = showDivider, leadingInset = if (leadingIcon == null) 20.dp else 74.dp) + } +} + +@Composable +fun SettingsInfoRow( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + value: String? = null, + showDivider: Boolean = true +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 68.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsRowText( + title = title, + subtitle = subtitle, + modifier = Modifier.weight(1f) + ) + value?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + } + } + SettingsDivider(visible = showDivider) + } +} + +@Composable +fun SettingsToggleRow( + title: String, + selected: Boolean, + onSelectedChange: (Boolean) -> Unit, + backdrop: Backdrop, + modifier: Modifier = Modifier, + subtitle: String? = null, + enabled: Boolean = true, + showDivider: Boolean = true, + onHorizontalDragActiveChange: (Boolean) -> Unit = {} +) { + Column(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = selected, + enabled = enabled, + role = Role.Switch, + onValueChange = onSelectedChange + ) + .heightIn(min = 72.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsRowText( + title = title, + subtitle = subtitle, + enabled = enabled, + modifier = Modifier.weight(1f) + ) + LiquidToggle( + selected = { selected }, + onSelect = onSelectedChange, + backdrop = backdrop, + userInputEnabled = enabled, + toggleOnTap = false, + onHorizontalDragActiveChange = onHorizontalDragActiveChange + ) + } + SettingsDivider(visible = showDivider) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsSelectRow( + title: String, + selected: T, + choices: List>, + onSelected: (T) -> Unit, + modifier: Modifier = Modifier, + subtitle: String? = null, + showDivider: Boolean = true +) { + var expanded by remember { mutableStateOf(false) } + val selectedLabel = choices.firstOrNull { it.value == selected }?.label.orEmpty() + Column(modifier = modifier.fillMaxWidth()) { + ExposedDropdownMenuBox( + expanded = expanded, + onExpandedChange = { expanded = !expanded }, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .menuAnchor( + type = ExposedDropdownMenuAnchorType.PrimaryNotEditable, + enabled = true + ) + .heightIn(min = 68.dp) + .padding(horizontal = 20.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically + ) { + SettingsRowText( + title = title, + subtitle = subtitle, + modifier = Modifier.weight(1f) + ) + Text( + text = selectedLabel, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge + ) + ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) + } + ExposedDropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + choices.forEach { choice -> + DropdownMenuItem( + text = { + Text( + text = choice.label, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge + ) + }, + leadingIcon = { + RadioButton( + selected = choice.value == selected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary + ) + ) + }, + trailingIcon = { + if (choice.value == selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } + }, + onClick = { + onSelected(choice.value) + expanded = false + } + ) + } + } + } + SettingsDivider(visible = showDivider) + } +} + +@Composable +fun SettingsDialogSelectRow( + title: String, + selected: T, + choices: List>, + onSelected: (T) -> Unit, + modifier: Modifier = Modifier, + dialogTitle: String = title, + subtitle: String? = null, + showDivider: Boolean = true +) { + var dialogVisible by remember { mutableStateOf(false) } + val selectedLabel = choices.firstOrNull { it.value == selected }?.label.orEmpty() + + SettingsActionRow( + title = title, + subtitle = subtitle, + value = selectedLabel, + showChevron = true, + showDivider = showDivider, + modifier = modifier, + onClick = { dialogVisible = true } + ) + + if (dialogVisible) { + Dialog(onDismissRequest = { dialogVisible = false }) { + Surface( + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 560.dp), + shape = SmoothRoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + tonalElevation = 6.dp, + shadowElevation = 10.dp + ) { + Column( + modifier = Modifier + .padding(vertical = 18.dp) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = dialogTitle, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium + ) + choices.forEach { choice -> + val isSelected = choice.value == selected + Row( + modifier = Modifier + .fillMaxWidth() + .selectable( + selected = isSelected, + role = Role.RadioButton, + onClick = { + onSelected(choice.value) + dialogVisible = false + } + ) + .heightIn(min = 64.dp) + .padding(horizontal = 24.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(20.dp), + verticalAlignment = Alignment.CenterVertically + ) { + RadioButton( + selected = isSelected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary + ) + ) + Text( + text = choice.label, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge + ) + } + } + } + } + } + } +} + +@Composable +private fun SettingsRowText( + title: String, + modifier: Modifier = Modifier, + subtitle: String? = null, + destructive: Boolean = false, + enabled: Boolean = true +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(3.dp) + ) { + Text( + text = title, + color = when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + destructive -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurface + }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) + subtitle?.let { + Text( + text = it, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = if (enabled) 1f else 0.38f + ), + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +private fun SettingsDivider( + visible: Boolean, + leadingInset: androidx.compose.ui.unit.Dp = 20.dp +) { + if (visible) { + HorizontalDivider( + modifier = Modifier.padding(start = leadingInset), + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = if (LocalIsLiquidGlassEnabled.current) 0.55f else 0.7f + ), + thickness = 0.5.dp + ) + } else { + Spacer(modifier = Modifier.height(0.dp)) + } +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt index a6b52810..718fc8cc 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/BottomNavBar.kt @@ -1,6 +1,5 @@ package com.ahu.ahutong.ui.screen -import android.util.Log import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -13,106 +12,130 @@ import androidx.compose.material.icons.outlined.Settings import androidx.compose.material.icons.outlined.TableChart import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import com.ahu.ahutong.ui.components.LiquidBottomTab import com.ahu.ahutong.ui.components.LiquidBottomTabs +import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled import com.kyant.backdrop.Backdrop +private data class BottomDestination( + val route: String, + val label: String, + val selectedIcon: ImageVector, + val unselectedIcon: ImageVector +) + +private val bottomDestinations = listOf( + BottomDestination("home", "主页", Icons.Outlined.Home, Icons.Outlined.Home), + BottomDestination("schedule", "课表", Icons.Outlined.TableChart, Icons.Outlined.TableChart), + BottomDestination("tools", "小工具", Icons.Outlined.Build, Icons.Outlined.Build), + BottomDestination("settings", "设置", Icons.Outlined.Settings, Icons.Outlined.Settings) +) + @Composable fun BoxScope.BottomNavBar( navController: NavHostController, backdrop: Backdrop ) { - val currentRoute = navController.currentBackStackEntryAsState().value?.destination?.route - val allowedRoutes = setOf("home", "schedule", "tools", "settings") - if (currentRoute == "login" || currentRoute !in allowedRoutes) return - val selectedTabIndex by rememberUpdatedState( - when (currentRoute) { - "home" -> 0 - "schedule" -> 1 - "tools" -> 2 - "settings" -> 3 - else -> 0 - } - ) + val currentRoute by navController.currentBackStackEntryAsState() + val selectedRoute = currentRoute?.destination?.route + if (selectedRoute !in bottomDestinations.map { it.route }) return - Row( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .padding(vertical = 16f.dp) - .navigationBarsPadding() - ) { - LiquidBottomTabs( - selectedTabIndex = { selectedTabIndex }, - onTabSelected = { - navController.navigatePreservingHome( - when (it) { - 0 -> "home" - 1 -> "schedule" - 2 -> "tools" - 3 -> "settings" - else -> "home" - } - ) - }, - backdrop = backdrop, - tabsCount = 4, - modifier = Modifier.padding(horizontal = 36f.dp) + if (LocalIsLiquidGlassEnabled.current) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .padding(vertical = 16.dp) + .navigationBarsPadding() ) { - LiquidBottomTab({ navController.navigatePreservingHome("home") }) { - Icon( - imageVector = Icons.Outlined.Home, - contentDescription = null - ) - Text(text = "主页", style = MaterialTheme.typography.labelMedium) - } - LiquidBottomTab({ navController.navigatePreservingHome("schedule") }) { - Icon( - imageVector = Icons.Outlined.TableChart, - contentDescription = null - ) - Text(text = "课表", style = MaterialTheme.typography.labelMedium) - } - LiquidBottomTab({ navController.navigatePreservingHome("tools") }) { - Icon( - imageVector = Icons.Outlined.Build, - contentDescription = null - ) - Text(text = "小工具", style = MaterialTheme.typography.labelMedium) + LiquidBottomTabs( + selectedTabIndex = { + bottomDestinations.indexOfFirst { it.route == selectedRoute }.coerceAtLeast(0) + }, + onTabSelected = { index -> + navController.navigatePreservingHome(bottomDestinations[index].route) + }, + backdrop = backdrop, + tabsCount = bottomDestinations.size, + modifier = Modifier.padding(horizontal = 36.dp) + ) { + bottomDestinations.forEach { destination -> + val selected = selectedRoute == destination.route + LiquidBottomTab( + onClick = { + navController.navigatePreservingHome(destination.route) + } + ) { + Icon( + imageVector = if (selected) { + destination.selectedIcon + } else { + destination.unselectedIcon + }, + contentDescription = destination.label + ) + Text( + text = destination.label, + style = MaterialTheme.typography.labelMedium + ) + } + } } - LiquidBottomTab({ navController.navigatePreservingHome("settings") }) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = null + } + } else { + NavigationBar( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + containerColor = MaterialTheme.colorScheme.surfaceContainer, + tonalElevation = 0.dp + ) { + bottomDestinations.forEach { destination -> + val selected = selectedRoute == destination.route + NavigationBarItem( + selected = selected, + onClick = { navController.navigatePreservingHome(destination.route) }, + icon = { + Icon( + imageVector = if (selected) { + destination.selectedIcon + } else { + destination.unselectedIcon + }, + contentDescription = destination.label + ) + }, + label = { Text(destination.label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.onSecondaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onSurface, + indicatorColor = MaterialTheme.colorScheme.secondaryContainer, + unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant + ) ) - Text(text = "设置", style = MaterialTheme.typography.labelMedium) } } } } private fun NavController.navigatePreservingHome(route: String) { - val currentRoute = this.currentBackStackEntry?.destination?.route - if (currentRoute == route) return - - val homeRoute = "home" - - Log.e("TAG", "navigatePreservingHome: $homeRoute") - - this.navigate(route) { - popUpTo(homeRoute) { - inclusive = false - } + if (currentBackStackEntry?.destination?.route == route) return + navigate(route) { + popUpTo("home") { inclusive = false } launchSingleTop = true } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/Main.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/Main.kt index 9ef812a2..dc366e67 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/Main.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/Main.kt @@ -292,7 +292,7 @@ fun Main( } animatedComposable("preferences") { - Preferences() + Preferences(onBack = { navController.popBackStack() }) } animatedComposable("electricity_pay") { @@ -308,7 +308,18 @@ fun Main( } animatedComposable("cmb_card_recharge") { - CmbCardRecharge() + CmbCardRecharge( + onExit = { navController.popBackStack() }, + onRechargeSuccessExit = { + val returnedHome = navController.popBackStack("home", inclusive = false) + if (!returnedHome) { + navController.navigate("home") { + popUpTo("cmb_card_recharge") { inclusive = true } + launchSingleTop = true + } + } + } + ) } animatedComposable("network_recharge") { diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/Settings.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/Settings.kt index 9795af55..00fbb322 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/Settings.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/Settings.kt @@ -2,17 +2,12 @@ package com.ahu.ahutong.ui.screen import android.annotation.SuppressLint import android.content.Intent -import android.net.Uri -import androidx.core.net.toUri import android.widget.Toast import androidx.activity.ComponentActivity import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures 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 @@ -21,58 +16,58 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Article +import androidx.compose.material.icons.automirrored.outlined.Article +import androidx.compose.material.icons.automirrored.outlined.Login import androidx.compose.material.icons.outlined.ClearAll import androidx.compose.material.icons.outlined.Feedback -import androidx.compose.material.icons.outlined.Login import androidx.compose.material.icons.outlined.PeopleOutline import androidx.compose.material.icons.outlined.Tune import androidx.compose.material.icons.outlined.Update -import androidx.compose.material3.Icon +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog +import androidx.core.net.toUri import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.ahu.ahutong.AHUApplication -import com.ahu.ahutong.Constants import com.ahu.ahutong.R -import com.ahu.ahutong.data.api.AHUCookieJar import com.ahu.ahutong.data.dao.AHUCache -import com.ahu.ahutong.sdk.RustSDK import com.ahu.ahutong.data.crawler.manager.CookieManager -import com.ahu.ahutong.data.mock_server.MockServer import com.ahu.ahutong.data.server.AhuTong +import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime +import com.ahu.ahutong.sdk.RustSDK +import com.ahu.ahutong.ui.components.SettingsActionRow +import com.ahu.ahutong.ui.components.SettingsBackdropContainer +import com.ahu.ahutong.ui.components.SettingsInfoRow +import com.ahu.ahutong.ui.components.SettingsHeroCard +import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled +import com.ahu.ahutong.ui.components.SettingsPageHeader +import com.ahu.ahutong.ui.components.SettingsSection import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.AboutViewModel import com.ahu.ahutong.ui.state.MainViewModel -import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime -import kotlinx.coroutines.launch -import com.franmontiel.persistentcookiejar.cache.SetCookieCache -import com.franmontiel.persistentcookiejar.persistence.SharedPrefsCookiePersistor import com.kyant.capsule.ContinuousCapsule -import com.kyant.monet.a1 -import com.kyant.monet.n1 -import com.kyant.monet.withNight +import kotlinx.coroutines.launch @SuppressLint("ContextCastToActivity") @Composable @@ -83,376 +78,222 @@ fun Settings( behaviorRuntime: BehaviorPredictionRuntime ) { val context = LocalContext.current as ComponentActivity - var isClearCacheDialogShown by rememberSaveable { mutableStateOf(false) } + val scope = rememberCoroutineScope() + var isClearDataDialogShown by rememberSaveable { mutableStateOf(false) } var isUpdateLogDialogShown by rememberSaveable { mutableStateOf(false) } - val tip by remember { aboutViewModel.tipState } var updateLog by remember { mutableStateOf("") } - val scope = rememberCoroutineScope() + val tip by remember { aboutViewModel.tipState } + var appCardTapCount by remember { mutableIntStateOf(0) } + var lastAppCardTap by remember { mutableLongStateOf(0L) } LaunchedEffect(tip) { tip?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() - aboutViewModel.tipState.value = null; + aboutViewModel.tipState.value = null } + runCatching { AhuTong.API.getApkUpdateInfo().changelog.orEmpty() } + .onSuccess { updateLog = it.ifBlank { "暂无更新说明" } } + .onFailure { updateLog = "获取失败" } + } - runCatching { - AhuTong.API.getApkUpdateInfo().changelog - ?.ifBlank { "暂无更新说明" } - ?: "暂无更新说明" + val onAppCardClick = { + val now = System.currentTimeMillis() + appCardTapCount = if (now - lastAppCardTap > 1_000L) 1 else appCardTapCount + 1 + lastAppCardTap = now + if (appCardTapCount >= 8) { + appCardTapCount = 0 + navController.navigate("debug") } - .onSuccess { updateLog = it } - .onFailure { - updateLog = "获取失败" - } } + SettingsBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .systemBarsPadding() + .padding(bottom = 112.dp), + verticalArrangement = Arrangement.spacedBy(26.dp) + ) { + SettingsPageHeader(title = stringResource(id = R.string.setting)) - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .systemBarsPadding() - .padding(bottom = 96.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Text( - text = stringResource(id = R.string.setting), - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineLarge - ) - var count by remember { mutableStateOf(0) } - var lastClickTime by remember { mutableStateOf(0L) } - val clickTimes: Int = 8 - val interval: Long = 1000 - val checkUpdate = { - mainViewModel.checkApkUpdateManually(context) { message -> - Toast.makeText(context, message, Toast.LENGTH_SHORT).show() - } + val isLiquid = LocalIsLiquidGlassEnabled.current + val heroContentColor = if (isLiquid) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onPrimaryContainer } - val onAppCardClick = { - val now = System.currentTimeMillis() - if (now - lastClickTime > interval) { - count = 1 - } else { - count++ - } - lastClickTime = now - - if (count >= clickTimes) { - count = 0 - navController.navigate("debug") - } - } - - Column( + SettingsHeroCard( + backdrop = backdrop, + onClick = onAppCardClick, modifier = Modifier - .fillMaxWidth() .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(90.a1 withNight 20.n1) - .padding(24.dp, 16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) ) { - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.pointerInput(onAppCardClick) { - detectTapGestures { onAppCardClick() } - } - ) { - Image( - painter = painterResource(id = R.mipmap.ic_launcher_foreground), - contentDescription = null, - modifier = Modifier - .clip(ContinuousCapsule) - .background(100.n1) - .padding(4.dp) - .size(64.dp) - .clip(ContinuousCapsule) - .scale(1.75f) + Image( + painter = painterResource(id = R.mipmap.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .clip(ContinuousCapsule) + .background(MaterialTheme.colorScheme.surface) + .scale(1.65f) + ) + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = stringResource(id = R.string.app_name), + color = heroContentColor, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = aboutViewModel.versionName.orEmpty(), + color = heroContentColor.copy(alpha = 0.74f), + style = MaterialTheme.typography.bodyLarge ) - Column { - Text( - text = stringResource(id = R.string.app_name), - style = MaterialTheme.typography.headlineMedium - ) - Text( - text = aboutViewModel.versionName!!, - style = MaterialTheme.typography.titleMedium - ) - } } } - Text( - text = "账户信息", - modifier = Modifier.padding(horizontal = 24.dp), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) + AHUCache.getCurrentUser()?.let { user -> - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)) - .background(100.n1 withNight 20.n1) - .padding(bottom = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + val schoolTerm = AHUCache.getSchoolTerm()?.split('-') + ?.takeIf { it.size == 3 } + ?.let { "${it[0]}-${it[1]} 学年 · 第 ${it[2]} 学期" } + SettingsSection( + title = "账户", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp, 16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Text( - text = user.name, - style = MaterialTheme.typography.headlineSmall - ) - AHUCache.getSchoolTerm()?.let { - val data = it.split('-') //2025-2026-1 - if (data.size == 3) { - Text( - text = "第${data.get(0)}-${data.get(1)}学年 第${data.get(2)}学期", - style = MaterialTheme.typography.titleMedium - ) - } - } - - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { -// Row( -// modifier = Modifier -// .weight(1f) -// .clip(ContinuousCapsule) -// .background(100.n1 withNight 30.n1) -// .clickable { navController.navigate("info") } -// .padding(12.dp, 8.dp), -// horizontalArrangement = Arrangement.spacedBy( -// 8.dp, -// Alignment.CenterHorizontally -// ), -// verticalAlignment = Alignment.CenterVertically -// ) { -// Icon( -// imageVector = Icons.Outlined.Edit, -// contentDescription = null, -// modifier = Modifier.size(20.dp) -// ) -// Text( -// text = "修改信息", -// style = MaterialTheme.typography.titleMedium -// ) -// } - Row( - modifier = Modifier - .weight(1f) - .clip(ContinuousCapsule) - .background(100.n1 withNight 30.n1) - .clickable { navController.navigate("login") } - .padding(12.dp, 8.dp), - horizontalArrangement = Arrangement.spacedBy( - 8.dp, - Alignment.CenterHorizontally - ), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = Icons.Outlined.Login, - contentDescription = null, - modifier = Modifier.size(20.dp) - ) - Text( - text = "重新登录", - style = MaterialTheme.typography.titleMedium - ) - } - } + SettingsInfoRow( + title = user.name, + subtitle = schoolTerm + ) + SettingsActionRow( + title = "重新登录", + leadingIcon = Icons.AutoMirrored.Outlined.Login, + showDivider = false, + onClick = { navController.navigate("login") } + ) } } - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)), - verticalArrangement = Arrangement.spacedBy(2.dp) + SettingsSection( + title = "应用", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop ) { - SettingItem( - label = stringResource(id = R.string.preferences), - icon = Icons.Outlined.Tune, + SettingsActionRow( + title = stringResource(id = R.string.preferences), + subtitle = "通知、外观、主页与智能体验", + leadingIcon = Icons.Outlined.Tune, onClick = { navController.navigate("preferences") } ) - + SettingsActionRow( + title = stringResource(id = R.string.check_update), + leadingIcon = Icons.Outlined.Update, + showDivider = false, + onClick = { + mainViewModel.checkApkUpdateManually(context) { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } + } + ) } - Text( - text = "关于", - modifier = Modifier.padding(horizontal = 24.dp), - fontWeight = FontWeight.Bold, - style = MaterialTheme.typography.titleMedium - ) - Column( - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(SmoothRoundedCornerShape(32.dp)), - verticalArrangement = Arrangement.spacedBy(2.dp) + SettingsSection( + title = "关于与支持", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop ) { - SettingItem( - label = stringResource(id = R.string.license), - icon = Icons.Outlined.Article, + SettingsActionRow( + title = stringResource(id = R.string.license), + leadingIcon = Icons.AutoMirrored.Outlined.Article, onClick = { navController.navigate("settings__license") } ) - SettingItem( - label = stringResource(id = R.string.contributors), - icon = Icons.Outlined.PeopleOutline, + SettingsActionRow( + title = stringResource(id = R.string.contributors), + leadingIcon = Icons.Outlined.PeopleOutline, onClick = { navController.navigate("settings__contributors") } ) - SettingItem( - label = stringResource(id = R.string.mine_tv_feedback), - icon = Icons.Outlined.Feedback, + SettingsActionRow( + title = stringResource(id = R.string.mine_tv_feedback), + leadingIcon = Icons.Outlined.Feedback, onClick = { - try { + runCatching { context.startActivity( Intent( Intent.ACTION_VIEW, "mqqapi://card/show_pslcard?src_type=internal&version=1&uin=1006203134&card_type=group&source=qrcode".toUri() - ).apply { - flags = Intent.FLAG_ACTIVITY_CLEAR_TOP - } + ).apply { flags = Intent.FLAG_ACTIVITY_CLEAR_TOP } ) - } catch (e: Exception) { - Toast - .makeText(context, "请安装 QQ 或 Tim", Toast.LENGTH_SHORT) - .show() + }.onFailure { + Toast.makeText(context, "请安装 QQ 或 Tim", Toast.LENGTH_SHORT).show() } } ) - SettingItem( - label = stringResource(id = R.string.setting_clear), - icon = Icons.Outlined.ClearAll, - onClick = { isClearCacheDialogShown = true } - ) - SettingItem( - label = stringResource(id = R.string.check_update), - icon = Icons.Outlined.Update, - onClick = { checkUpdate() } - ) - SettingItem( - label = stringResource(id = R.string.update_intro), - icon = Icons.Outlined.Article, + SettingsActionRow( + title = stringResource(id = R.string.update_intro), + leadingIcon = Icons.AutoMirrored.Outlined.Article, onClick = { isUpdateLogDialogShown = true } ) + SettingsActionRow( + title = stringResource(id = R.string.setting_clear), + subtitle = "清除登录状态、课表和本地数据", + leadingIcon = Icons.Outlined.ClearAll, + destructive = true, + showDivider = false, + onClick = { isClearDataDialogShown = true } + ) + } } } - if (isClearCacheDialogShown) { - Dialog(onDismissRequest = { isClearCacheDialogShown = false }) { - Column( - modifier = Modifier - .clip(SmoothRoundedCornerShape(32.dp)) - .background(96.n1 withNight 10.n1) - .verticalScroll(rememberScrollState()) - .padding(vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - - Text( - text = "您的登录状态、课表等信息将会被永久清除", - modifier = Modifier.padding(horizontal = 24.dp), - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.titleLarge - ) - Text( - text = "清除", - modifier = Modifier - .padding(horizontal = 16.dp) - .clip(ContinuousCapsule) - .background(90.a1 withNight 30.n1) - .clickable { - scope.launch { - behaviorRuntime.logoutAndClear() - mainViewModel.logout() - AHUCache.clearAll() - RustSDK.initSafe("") - - CookieManager.cookieJar.clear() - CookieManager.cookieJar.clearSession() - AHUApplication.sessionExpired = true - - Toast - .makeText(context, "已清除所有数据", Toast.LENGTH_SHORT) - .show() - - navController.navigate("login") { - popUpTo(0) - } - } + if (isClearDataDialogShown) { + AlertDialog( + onDismissRequest = { isClearDataDialogShown = false }, + title = { Text("清除所有数据?") }, + text = { Text("登录状态、课表及本机设置将被永久清除。") }, + confirmButton = { + TextButton( + onClick = { + scope.launch { + behaviorRuntime.logoutAndClear() + mainViewModel.logout() + AHUCache.clearAll() + RustSDK.initSafe("") + CookieManager.cookieJar.clear() + CookieManager.cookieJar.clearSession() + AHUApplication.sessionExpired = true + Toast.makeText(context, "已清除所有数据", Toast.LENGTH_SHORT).show() + navController.navigate("login") { popUpTo(0) } } - .padding(16.dp, 8.dp), - color = 100.n1 withNight 100.n1, - style = MaterialTheme.typography.titleMedium - ) + } + ) { + Text("清除", color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(onClick = { isClearDataDialogShown = false }) { + Text("取消") + } } - } + ) } + if (isUpdateLogDialogShown) { - Dialog(onDismissRequest = { isUpdateLogDialogShown = false }) { - Column( - modifier = Modifier - .clip(SmoothRoundedCornerShape(32.dp)) - .background(96.n1 withNight 10.n1) - .padding(vertical = 24.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { + AlertDialog( + onDismissRequest = { isUpdateLogDialogShown = false }, + title = { Text(stringResource(id = R.string.update_intro)) }, + text = { Text( - text = stringResource(id = R.string.update_intro), - modifier = Modifier.padding(horizontal = 24.dp), - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.headlineMedium + text = updateLog, + modifier = Modifier.verticalScroll(rememberScrollState()), + color = MaterialTheme.colorScheme.onSurfaceVariant ) - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - ) { - Text( -// text = RustSDK.getUpdateLog(), - text = updateLog, - color = 0.n1 withNight 100.n1, - style = MaterialTheme.typography.bodyLarge - ) + }, + confirmButton = { + TextButton(onClick = { isUpdateLogDialogShown = false }) { + Text("完成") } } - } - } -} - -@Composable -private fun SettingItem( - label: String, - icon: ImageVector, - onClick: () -> Unit -) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(4.dp)) - .background(100.n1 withNight 20.n1) - .clickable(onClick = onClick) - .padding(24.dp, 16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - Icon( - imageVector = icon, - contentDescription = null - ) - Text( - text = label, - style = MaterialTheme.typography.titleMedium ) } } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt index d731f921..25759370 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbCardRecharge.kt @@ -17,17 +17,28 @@ import android.webkit.WebViewClient import androidx.activity.compose.BackHandler import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.absoluteOffset 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.systemBarsPadding +import androidx.compose.foundation.layout.width import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -35,51 +46,34 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.unit.dp +import androidx.compose.ui.semantics.Role import com.ahu.ahutong.data.crawler.manager.CookieManager as YcardCookieManager import com.ahu.ahutong.data.crawler.manager.TokenManager import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.personalization.action.AppActionId import com.ahu.ahutong.personalization.ui.rememberBehaviorActionReporter -import com.kyant.monet.n1 -import com.kyant.monet.withNight import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.Cookie +import java.net.URI -private const val CMB_RECHARGE_STYLE_SCRIPT = """ -(function(){ - var styleId = 'ahutong-cmb-style'; - if (document.getElementById(styleId)) return; - var style = document.createElement('style'); - style.id = styleId; - style.textContent = [ - 'html,body,#app,#app-box{background:#eef2f5 !important;color:#1f2328 !important;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif !important;}', - 'body{overscroll-behavior:none !important;-webkit-font-smoothing:antialiased !important;}', - '#app,#app-box,.page,.container,.main,.weui-tab__panel{max-width:720px;margin:0 auto !important;}', - '.weui-btn_primary,.weui-btn_warn,.weui-btn_default{border-radius:16px !important;box-shadow:none !important;}', - '.weui-btn_primary{background:#1e88e5 !important;border-color:#1e88e5 !important;}', - '.weui-btn_warn{background:#d94f4f !important;border-color:#d94f4f !important;}', - '.weui-btn_default{background:#ffffff !important;color:#1f2328 !important;border-color:#d0d7de !important;}', - '.weui-cells,.weui-panel,.card,.panel{border-radius:20px !important;overflow:hidden !important;background:#ffffff !important;}', - '.weui-cell{padding-top:14px !important;padding-bottom:14px !important;}', - '.van-cell,.van-field,.cell,.form-item,.pay-item{border-radius:16px !important;background:#ffffff !important;}', - '.van-button,.el-button,button{border-radius:16px !important;box-shadow:none !important;}', - '.van-button--primary,.el-button--primary,button[type=submit]{background:#1e88e5 !important;border-color:#1e88e5 !important;color:#ffffff !important;}', - '.van-field__label,.label,.title{color:#1f2328 !important;}', - '.van-field__control,input,textarea,select{color:#1f2328 !important;}', - '.van-cell-group,.form,.charge-box,.cashier-box{border-radius:20px !important;overflow:hidden !important;background:#ffffff !important;}', - 'input,textarea,select{font-family:inherit !important;}', - 'a{color:#1e88e5 !important;}' - ].join(''); - document.head.appendChild(style); -})(); -""" +internal data class CmbRechargeNormalizedBounds( + val left: Float, + val top: Float, + val width: Float, + val height: Float +) private const val CMB_SUBMIT_OBSERVER_SCRIPT = """ (function(){ @@ -102,27 +96,55 @@ private const val CMB_SUBMIT_OBSERVER_SCRIPT = """ })(); """ +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun CmbCardRecharge() { +fun CmbCardRecharge( + onExit: () -> Unit, + onRechargeSuccessExit: () -> Unit +) { val context = LocalContext.current val behaviorReporter = rememberBehaviorActionReporter() + val colorScheme = MaterialTheme.colorScheme + val isDarkTheme = colorScheme.background.luminance() < 0.5f + val pageBackgroundColor = colorScheme.background + val pageStylePalette = CmbRechargePagePalette( + colorScheme = if (isDarkTheme) "dark" else "light", + background = pageBackgroundColor.toCssColor(), + surface = colorScheme.surface.toCssColor(), + surfaceVariant = colorScheme.surfaceVariant.toCssColor(), + text = colorScheme.onBackground.toCssColor(), + secondaryText = colorScheme.onSurfaceVariant.toCssColor(), + outline = colorScheme.outline.toCssColor(), + accent = colorScheme.primary.toCssColor(), + onAccent = colorScheme.onPrimary.toCssColor(), + success = (if (isDarkTheme) Color(0xFF81C784) else Color(0xFF2E7D32)).toCssColor(), + scrim = if (isDarkTheme) "rgba(0, 0, 0, 0.62)" else "rgba(0, 0, 0, 0.38)" + ) + val pageStyleScript = remember(pageStylePalette) { + buildCmbRechargeStyleScript(pageStylePalette) + } + val latestPageStyleScript = rememberUpdatedState(pageStyleScript) + val latestRechargeSuccessExit = rememberUpdatedState(onRechargeSuccessExit) var entryUrl by remember { mutableStateOf(null) } var webView by remember { mutableStateOf(null) } var progress by remember { mutableIntStateOf(0) } var tokenRequestVersion by remember { mutableIntStateOf(0) } var loadRequestVersion by remember { mutableIntStateOf(0) } var isLoading by remember { mutableStateOf(true) } - var canGoBack by remember { mutableStateOf(false) } + var isRechargeSuccessPage by remember { mutableStateOf(false) } + var successReturnBounds by remember { + mutableStateOf(null) + } var errorMessage by remember { mutableStateOf(null) } - BackHandler(enabled = canGoBack) { - webView?.goBack() - } + BackHandler(onBack = onExit) fun reloadEntry() { progress = 0 isLoading = true errorMessage = null + isRechargeSuccessPage = false + successReturnBounds = null webView?.stopLoading() loadRequestVersion += 1 } @@ -132,7 +154,8 @@ fun CmbCardRecharge() { isLoading = true errorMessage = null entryUrl = null - canGoBack = false + isRechargeSuccessPage = false + successReturnBounds = null val token = withContext(Dispatchers.IO) { TokenManager.awaitToken() } if (token.isNullOrBlank()) { errorMessage = "校园卡登录凭证暂未就绪,请稍后重试" @@ -146,67 +169,53 @@ fun CmbCardRecharge() { DisposableEffect(Unit) { onDispose { webView?.stopLoading() + webView?.cmbRechargeState?.boundsLocator?.dispose() webView?.destroy() webView = null } } - Column( - modifier = Modifier - .fillMaxSize() - .systemBarsPadding() - ) { - Text( - text = "招商银行充值", - modifier = Modifier.padding(24.dp, 32.dp, 24.dp, 16.dp), - style = MaterialTheme.typography.headlineMedium - ) - - if (progress in 1..99) { - LinearProgressIndicator( - progress = { progress / 100f }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - ) + LaunchedEffect(pageStyleScript) { + webView?.let { currentView -> + applyCmbRechargePageStyle(currentView, currentView.url, pageStyleScript) + currentView.cmbRechargeState?.boundsLocator?.locate(currentView.url) } + } - errorMessage?.let { message -> - Column( - modifier = Modifier - .padding(16.dp) - .fillMaxWidth() - .background(100.n1 withNight 20.n1, SmoothRoundedCornerShape(24.dp)) - .padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - Text( - text = message, - color = 10.n1 withNight 90.n1, - style = MaterialTheme.typography.bodyLarge - ) - Text( - text = "重试", - modifier = Modifier.clickable { - if (entryUrl == null) { - errorMessage = null - isLoading = true - tokenRequestVersion += 1 - } else { - reloadEntry() - } - }, - color = 30.n1 withNight 70.n1, - style = MaterialTheme.typography.titleMedium + val pageContentColor = colorScheme.onBackground + Scaffold( + modifier = Modifier.fillMaxSize(), + containerColor = pageBackgroundColor, + contentColor = pageContentColor, + topBar = { + TopAppBar( + title = { + Text( + text = "招商银行充值", + style = MaterialTheme.typography.titleLarge + ) + }, + navigationIcon = { + IconButton(onClick = onExit) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "返回" + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = pageBackgroundColor, + navigationIconContentColor = pageContentColor, + titleContentColor = pageContentColor ) - } + ) } - + ) { contentPadding -> Box( modifier = Modifier .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp) - .background(100.n1 withNight 20.n1, SmoothRoundedCornerShape(24.dp)) + .padding(contentPadding) + .background(pageBackgroundColor) ) { entryUrl?.let { url -> val requestVersion = loadRequestVersion @@ -215,9 +224,15 @@ fun CmbCardRecharge() { factory = { viewContext -> createCmbRechargeWebView( context = viewContext, + pageBackgroundColor = pageBackgroundColor.toArgb(), + pageStyleScript = { latestPageStyleScript.value }, onLoadingChanged = { isLoading = it }, onProgressChanged = { progress = it }, - onNavigationChanged = { canGoBack = it }, + onSuccessPageChanged = { isSuccessPage -> + isRechargeSuccessPage = isSuccessPage + if (!isSuccessPage) successReturnBounds = null + }, + onSuccessReturnBoundsChanged = { successReturnBounds = it }, onMainFrameError = { error -> errorMessage = error }, @@ -229,15 +244,16 @@ fun CmbCardRecharge() { } ).also { created -> syncYcardCookiesToWebView(created) - created.tag = requestVersion + created.cmbRechargeState?.requestVersion = requestVersion created.loadUrl(url) webView = created } }, update = { currentView -> - if (currentView.tag != requestVersion) { + currentView.setBackgroundColor(pageBackgroundColor.toArgb()) + if (currentView.cmbRechargeState?.requestVersion != requestVersion) { syncYcardCookiesToWebView(currentView) - currentView.tag = requestVersion + currentView.cmbRechargeState?.requestVersion = requestVersion currentView.loadUrl(url) } webView = currentView @@ -245,27 +261,110 @@ fun CmbCardRecharge() { ) } + if (isRechargeSuccessPage) { + successReturnBounds?.let { bounds -> + CmbRechargeSuccessReturnOverlay( + bounds = bounds, + onClick = { + successReturnBounds = null + latestRechargeSuccessExit.value() + } + ) + } + } + if (isLoading) { CircularProgressIndicator( modifier = Modifier.align(Alignment.Center), - color = 30.n1 withNight 70.n1 + color = colorScheme.primary + ) + } + + if (progress in 1..99) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() ) } + + errorMessage?.let { message -> + Column( + modifier = Modifier + .align(Alignment.Center) + .padding(24.dp) + .fillMaxWidth() + .background(colorScheme.surface, SmoothRoundedCornerShape(24.dp)) + .padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text( + text = message, + color = pageContentColor, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = "重试", + modifier = Modifier.clickable { + if (entryUrl == null) { + errorMessage = null + isLoading = true + tokenRequestVersion += 1 + } else { + reloadEntry() + } + }, + color = colorScheme.primary, + style = MaterialTheme.typography.titleMedium + ) + } + } } } } +@Composable +private fun CmbRechargeSuccessReturnOverlay( + bounds: CmbRechargeNormalizedBounds, + onClick: () -> Unit +) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + Box( + modifier = Modifier + .absoluteOffset( + x = maxWidth * bounds.left, + y = maxHeight * bounds.top + ) + .width(maxWidth * bounds.width) + .height(maxHeight * bounds.height) + .clip(SmoothRoundedCornerShape(20.dp)) + .clickable( + onClickLabel = "返回应用首页", + role = Role.Button, + onClick = onClick + ) + ) + } +} + @SuppressLint("SetJavaScriptEnabled") private fun createCmbRechargeWebView( context: android.content.Context, + pageBackgroundColor: Int, + pageStyleScript: () -> String, onLoadingChanged: (Boolean) -> Unit, onProgressChanged: (Int) -> Unit, - onNavigationChanged: (Boolean) -> Unit, + onSuccessPageChanged: (Boolean) -> Unit, + onSuccessReturnBoundsChanged: (CmbRechargeNormalizedBounds?) -> Unit, onMainFrameError: (String) -> Unit, onExternalLink: (String) -> Unit, onSubmitIntent: () -> Unit ): WebView { return WebView(context).apply { + setBackgroundColor(pageBackgroundColor) settings.javaScriptEnabled = true settings.domStorageEnabled = true settings.loadsImagesAutomatically = true @@ -282,6 +381,8 @@ private fun createCmbRechargeWebView( } android.webkit.CookieManager.getInstance().setAcceptCookie(true) addJavascriptInterface(CmbBehaviorBridge(this, onSubmitIntent), "AhuTongBehaviorBridge") + val boundsLocator = CmbRechargeBoundsLocator(this, onSuccessReturnBoundsChanged) + tag = CmbRechargeWebViewState(boundsLocator = boundsLocator) webChromeClient = object : WebChromeClient() { override fun onProgressChanged(view: WebView?, newProgress: Int) { @@ -293,6 +394,13 @@ private fun createCmbRechargeWebView( } webViewClient = object : WebViewClient() { + private fun updateSuccessPage(url: String?): Boolean { + val isSuccessPage = isCmbRechargeSuccessUrl(url) + onSuccessPageChanged(isSuccessPage) + if (!isSuccessPage) boundsLocator.clear() + return isSuccessPage + } + override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? @@ -314,20 +422,33 @@ private fun createCmbRechargeWebView( override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { onLoadingChanged(true) - onNavigationChanged(view?.canGoBack() == true) + boundsLocator.clear() + updateSuccessPage(url) super.onPageStarted(view, url, favicon) } override fun onPageFinished(view: WebView?, url: String?) { onLoadingChanged(false) - onNavigationChanged(view?.canGoBack() == true) - if (url?.let(Uri::parse)?.let(::isAuditedCmbSubmitPage) == true) { - view?.evaluateJavascript(CMB_RECHARGE_STYLE_SCRIPT, null) - view?.evaluateJavascript(CMB_SUBMIT_OBSERVER_SCRIPT, null) + updateSuccessPage(url) + if (view != null) { + applyCmbRechargePageStyle(view, url, pageStyleScript()) + if (url?.let(Uri::parse)?.let(::isAuditedCmbSubmitPage) == true) { + view.evaluateJavascript(CMB_SUBMIT_OBSERVER_SCRIPT, null) + } + boundsLocator.locate(url) } super.onPageFinished(view, url) } + override fun doUpdateVisitedHistory( + view: WebView?, + url: String?, + isReload: Boolean + ) { + if (updateSuccessPage(url) && view != null) boundsLocator.locate(url) + super.doUpdateVisitedHistory(view, url, isReload) + } + override fun onReceivedError( view: WebView?, request: WebResourceRequest?, @@ -335,6 +456,8 @@ private fun createCmbRechargeWebView( ) { if (request?.isForMainFrame == true) { onLoadingChanged(false) + boundsLocator.clear() + onSuccessPageChanged(false) onMainFrameError(error?.description?.toString() ?: "页面加载失败,请稍后重试") } super.onReceivedError(view, request, error) @@ -366,6 +489,172 @@ private class CmbBehaviorBridge( private companion object { const val NATIVE_SUBMIT_DEBOUNCE_MS = 1_000L } } +private class CmbRechargeWebViewState( + val boundsLocator: CmbRechargeBoundsLocator, + var requestVersion: Int = -1 +) + +private val WebView.cmbRechargeState: CmbRechargeWebViewState? + get() = tag as? CmbRechargeWebViewState + +private class CmbRechargeBoundsLocator( + private val webView: WebView, + private val onBoundsChanged: (CmbRechargeNormalizedBounds?) -> Unit +) { + private var generation = 0 + private var consecutiveMisses = 0 + private var lastBounds: CmbRechargeNormalizedBounds? = null + private var pendingPoll: Runnable? = null + private var isDisposed = false + + fun clear() { + if (isDisposed) return + generation += 1 + cancelPendingPoll() + consecutiveMisses = 0 + publish(null) + } + + fun locate(url: String?) { + if (isDisposed) return + generation += 1 + cancelPendingPoll() + consecutiveMisses = 0 + val currentGeneration = generation + if (!isCmbRechargeSuccessUrl(url)) { + publish(null) + return + } + publish(null) + locate(currentGeneration) + } + + fun dispose() { + if (isDisposed) return + isDisposed = true + generation += 1 + cancelPendingPoll() + lastBounds = null + } + + private fun locate(currentGeneration: Int) { + if ( + isDisposed || + currentGeneration != generation || + !isCmbRechargeSuccessUrl(webView.url) + ) { + return + } + webView.evaluateJavascript(buildCmbRechargeSuccessReturnBoundsScript()) { rawResult -> + if ( + isDisposed || + currentGeneration != generation || + !isCmbRechargeSuccessUrl(webView.url) + ) { + return@evaluateJavascript + } + val bounds = parseCmbRechargeNormalizedBounds(rawResult) + if (bounds != null) { + consecutiveMisses = 0 + publish(bounds) + } else { + consecutiveMisses += 1 + publish(null) + } + scheduleNextPoll( + currentGeneration = currentGeneration, + delayMillis = when { + bounds != null -> 250L + consecutiveMisses <= 30 -> 100L + else -> 1_000L + } + ) + } + } + + private fun scheduleNextPoll(currentGeneration: Int, delayMillis: Long) { + val poll = Runnable { + pendingPoll = null + locate(currentGeneration) + } + pendingPoll = poll + if (!webView.postDelayed(poll, delayMillis)) pendingPoll = null + } + + private fun cancelPendingPoll() { + pendingPoll?.let(webView::removeCallbacks) + pendingPoll = null + } + + private fun publish(bounds: CmbRechargeNormalizedBounds?) { + if (lastBounds == bounds) return + lastBounds = bounds + onBoundsChanged(bounds) + } +} + +internal fun parseCmbRechargeNormalizedBounds(rawResult: String?): CmbRechargeNormalizedBounds? { + val value = rawResult?.trim().orEmpty() + if (!value.startsWith('[') || !value.endsWith(']')) return null + val parts = value.substring(1, value.length - 1).split(',') + if (parts.size != 4) return null + val numbers = parts.map { it.trim().toDoubleOrNull() ?: return null } + return validateCmbRechargeNormalizedBounds( + left = numbers[0], + top = numbers[1], + width = numbers[2], + height = numbers[3] + ) +} + +internal fun validateCmbRechargeNormalizedBounds( + left: Double, + top: Double, + width: Double, + height: Double +): CmbRechargeNormalizedBounds? { + val values = listOf(left, top, width, height) + if (values.any { !it.isFinite() }) return null + if (left !in 0.0..1.0 || top !in 0.0..1.0) return null + if (width !in 0.05..1.0 || height !in 0.01..0.35) return null + if (left + width > 1.001 || top + height > 1.001) return null + return CmbRechargeNormalizedBounds( + left = left.toFloat(), + top = top.toFloat(), + width = width.toFloat(), + height = height.toFloat() + ) +} + +private fun applyCmbRechargePageStyle(webView: WebView, url: String?, script: String) { + if (!isCmbRechargeStyleTarget(url)) return + webView.evaluateJavascript(script, null) +} + +internal fun isCmbRechargeSuccessUrl(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val uri = runCatching { URI(url) }.getOrNull() ?: return false + val scheme = uri.scheme.orEmpty().lowercase() + val host = uri.host.orEmpty().lowercase() + val path = uri.path.orEmpty().trimEnd('/').lowercase() + return scheme == "https" && + host == "epay92.ahu.edu.cn" && + uri.port in setOf(-1, 443) && + path == "/cashier-mobile/chargeresult" +} + +internal fun isCmbRechargeStyleTarget(url: String?): Boolean { + if (url.isNullOrBlank()) return false + val uri = runCatching { URI(url) }.getOrNull() ?: return false + val host = uri.host.orEmpty().lowercase() + val path = uri.path.orEmpty().lowercase() + return when (host) { + "epay92.ahu.edu.cn" -> path == "/cashier-mobile" || path.startsWith("/cashier-mobile/") + "ycard.ahu.edu.cn" -> path.startsWith("/charge-app") + else -> false + } +} + private fun buildCmbRechargeEntryUrl(token: String): String { return Uri.Builder() .scheme("https") @@ -431,3 +720,5 @@ private fun buildCookieTargetUrl(cookie: Cookie): String { val domain = cookie.domain.trimStart('.') return "$scheme://$domain" } + +private fun Color.toCssColor(): String = "#%06X".format(toArgb() and 0xFFFFFF) diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyle.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyle.kt new file mode 100644 index 00000000..f4e4bb2f --- /dev/null +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyle.kt @@ -0,0 +1,310 @@ +package com.ahu.ahutong.ui.screen.main + +internal data class CmbRechargePagePalette( + val colorScheme: String, + val background: String, + val surface: String, + val surfaceVariant: String, + val text: String, + val secondaryText: String, + val outline: String, + val accent: String, + val onAccent: String, + val success: String, + val scrim: String +) + +/** + * Builds the styling JavaScript injected into the CMB recharge flow. + * + * The script creates or updates one style element. It deliberately does not observe the DOM, + * register event handlers, read form values, or touch the page's network and payment logic. + */ +internal fun buildCmbRechargeStyleScript(palette: CmbRechargePagePalette): String { + val css = """ + :root { + color-scheme: ${palette.colorScheme}; + --ahutong-bg: ${palette.background}; + --ahutong-surface: ${palette.surface}; + --ahutong-surface-variant: ${palette.surfaceVariant}; + --ahutong-text: ${palette.text}; + --ahutong-text-secondary: ${palette.secondaryText}; + --ahutong-outline: ${palette.outline}; + --ahutong-accent: ${palette.accent}; + --ahutong-on-accent: ${palette.onAccent}; + --ahutong-success: ${palette.success}; + --ahutong-scrim: ${palette.scrim}; + } + html, + body, + #app, + #app > .home { + min-height: 100%; + background: var(--ahutong-bg) !important; + color: var(--ahutong-text) !important; + font-family: -apple-system, BlinkMacSystemFont, Segoe UI, PingFang SC, + Hiragino Sans GB, Microsoft YaHei, sans-serif !important; + } + body { + margin: 0; + overscroll-behavior: none; + -webkit-font-smoothing: antialiased; + } + #app { + width: 100%; + margin: 0 auto !important; + } + #app .van-nav-bar { + display: none !important; + } + #app .van-hairline--bottom::after, + #app .van-cell::after { + border-color: var(--ahutong-outline) !important; + } + #app .charge { + padding: 20px 0 28px !important; + } + #app .charge .swiper-container { + margin-bottom: 16px !important; + border-radius: 0 !important; + } + #app .charge .cardBox { + margin-top: 0 !important; + padding: 18px 20px 24px !important; + border-radius: 24px !important; + box-shadow: none !important; + } + #app .charge .cardBox.electronic { + overflow: hidden; + background-position: center !important; + background-size: 100% 100% !important; + } + #app .charge .van-cell { + margin-bottom: 8px; + background: var(--ahutong-surface) !important; + border: 1px solid var(--ahutong-outline); + border-radius: 16px !important; + box-shadow: none !important; + } + #app .van-cell { + padding: 14px 8px !important; + background: transparent !important; + color: var(--ahutong-text) !important; + } + #app .van-cell__title, + #app .van-field__label, + #app .van-action-sheet__header { + color: var(--ahutong-text) !important; + } + #app .van-cell__value, + #app .van-cell__right-icon, + #app .text-gray, + #app .van-action-sheet__close { + color: var(--ahutong-text-secondary) !important; + } + #app .van-field__control { + color: var(--ahutong-text) !important; + -webkit-text-fill-color: var(--ahutong-text) !important; + caret-color: var(--ahutong-accent) !important; + font-family: inherit !important; + } + #app .van-field__control::placeholder { + color: var(--ahutong-text-secondary) !important; + -webkit-text-fill-color: var(--ahutong-text-secondary) !important; + opacity: 1; + } + #app .closeAmount { + gap: 8px; + justify-content: stretch !important; + margin: 16px 0 24px !important; + } + #app .closeAmount .van-button { + min-width: 0; + height: 40px !important; + padding: 0 8px !important; + flex: 1 1 0; + overflow: hidden; + border-width: 1px !important; + border-radius: 14px !important; + box-shadow: none !important; + } + #app .closeAmount .van-hairline--surround::after { + content: none !important; + } + #app .van-button--warning.van-button--plain { + background: var(--ahutong-surface-variant) !important; + border-color: var(--ahutong-accent) !important; + color: var(--ahutong-accent) !important; + } + #app .charge .van-button--info.van-button--block, + #app .van-button--default.van-button--block { + height: 48px !important; + background: var(--ahutong-accent) !important; + border-color: var(--ahutong-accent) !important; + border-radius: 16px !important; + box-shadow: none !important; + color: var(--ahutong-on-accent) !important; + } + #app .van-button__text { + color: inherit !important; + } + #app .charge .text-center.text-gray { + padding: 0 12px; + color: var(--ahutong-text-secondary) !important; + line-height: 1.65; + } + #app .van-overlay { + background: var(--ahutong-scrim) !important; + } + #app .van-popup, + #app .van-action-sheet { + background: var(--ahutong-surface) !important; + color: var(--ahutong-text) !important; + } + #app .van-action-sheet { + overflow: hidden; + border-radius: 28px 28px 0 0 !important; + box-shadow: none !important; + } + #app .van-password-input__security { + overflow: hidden; + background: var(--ahutong-surface-variant) !important; + border-radius: 16px !important; + } + #app .van-password-input__security li { + background: var(--ahutong-surface-variant) !important; + color: var(--ahutong-text) !important; + } + #app .van-password-input__security::after, + #app .van-password-input__item::after { + border-color: var(--ahutong-outline) !important; + } + #app .van-password-input__security i { + background: var(--ahutong-text) !important; + } + #app .keyboard { + background: var(--ahutong-surface) !important; + color: var(--ahutong-text) !important; + } + #app .keyboard tr td { + border-color: var(--ahutong-outline) !important; + color: var(--ahutong-text); + } + #app .keyboard tr td:active { + background: var(--ahutong-surface-variant); + } + #app .resultBox { + margin: 24px 16px 16px !important; + padding: 24px 8px 12px !important; + background: var(--ahutong-surface) !important; + border: 1px solid var(--ahutong-outline); + border-radius: 24px !important; + box-shadow: none !important; + } + #app .resultBox .topIcon { + margin-bottom: 24px !important; + color: var(--ahutong-success) !important; + } + #app .resultBox .cell { + padding: 14px 12px !important; + color: var(--ahutong-text) !important; + } + #app .text-success { + color: var(--ahutong-success) !important; + } + #app #copyText, + #app a { + color: var(--ahutong-accent) !important; + } + #app .van-toast { + background: var(--ahutong-surface-variant) !important; + color: var(--ahutong-text) !important; + border-radius: 18px !important; + box-shadow: none !important; + } + #app .van-loading__spinner { + color: var(--ahutong-accent) !important; + } + """.trimIndent() + + return """ + (function() { + var styleId = 'ahutong-cmb-style'; + var style = document.getElementById(styleId); + if (!style) { + style = document.createElement('style'); + style.id = styleId; + document.head.appendChild(style); + } + style.textContent = ${css.toJavaScriptStringLiteral()}; + })(); + """.trimIndent() +} + +/** + * Locates only the result page's return button and reports its normalized viewport bounds. + * Native Compose content uses those bounds for a click overlay; no page click is intercepted. + */ +internal fun buildCmbRechargeSuccessReturnBoundsScript(): String = + """ + (function() { + var path = window.location.pathname.replace(/\/+$/, '').toLowerCase(); + var isKnownResultPage = + window.location.protocol === 'https:' && + window.location.hostname.toLowerCase() === 'epay92.ahu.edu.cn' && + (window.location.port === '' || window.location.port === '443') && + path === '/cashier-mobile/chargeresult'; + var resultBox = document.querySelector('#app .resultBox'); + if (!isKnownResultPage || !resultBox || resultBox.getClientRects().length === 0) { + return null; + } + var buttons = document.querySelectorAll( + '#app button.van-button.van-button--default.van-button--normal.van-button--block.van-button--round' + ); + if (buttons.length !== 1 || buttons[0].disabled) return null; + var button = buttons[0]; + var style = window.getComputedStyle(button); + if ( + style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0' || + button.getClientRects().length === 0 + ) return null; + button.style.pointerEvents = 'none'; + var viewport = window.visualViewport; + var viewportLeft = viewport ? viewport.offsetLeft : 0; + var viewportTop = viewport ? viewport.offsetTop : 0; + var viewportWidth = viewport ? viewport.width : window.innerWidth; + var viewportHeight = viewport ? viewport.height : window.innerHeight; + if (viewportWidth <= 0 || viewportHeight <= 0) return null; + var rect = button.getBoundingClientRect(); + var left = Math.max(rect.left, viewportLeft); + var top = Math.max(rect.top, viewportTop); + var right = Math.min(rect.right, viewportLeft + viewportWidth); + var bottom = Math.min(rect.bottom, viewportTop + viewportHeight); + if (right <= left || bottom <= top) return null; + return [ + (left - viewportLeft) / viewportWidth, + (top - viewportTop) / viewportHeight, + (right - left) / viewportWidth, + (bottom - top) / viewportHeight + ]; + })(); + """.trimIndent() + +private fun String.toJavaScriptStringLiteral(): String = buildString(length + 2) { + append('"') + this@toJavaScriptStringLiteral.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + '\u2028' -> append("\\u2028") + '\u2029' -> append("\\u2029") + else -> append(character) + } + } + append('"') +} diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Repository.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Repository.kt index 87c56ac4..b7d5cd5f 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/main/Repository.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/main/Repository.kt @@ -57,6 +57,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.compose.foundation.isSystemInDarkTheme import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -287,20 +288,24 @@ internal fun RepositoryMarkdownReader( val context = LocalContext.current val markwon = remember(context) { Markwon.create(context) } val markdownTextColor = MaterialTheme.colorScheme.onSurface.toArgb() + val markdownLinkColor = MaterialTheme.colorScheme.primary.toArgb() - Dialog(onDismissRequest = onDismiss) { + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { Column( modifier = Modifier - .fillMaxWidth() + .fillMaxSize(0.8f) .clip(RoundedCornerShape(20.dp)) - .background(96.n1 withNight 16.n1) + .background(MaterialTheme.colorScheme.surface) .padding(18.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { Row(verticalAlignment = Alignment.CenterVertically) { Text( text = markdownState.document?.title ?: "Markdown", - color = 0.n1 withNight 100.n1, + color = MaterialTheme.colorScheme.onSurface, style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -308,7 +313,7 @@ internal fun RepositoryMarkdownReader( ) Text( "关闭", - color = 40.a1 withNight 80.a1, + color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge, modifier = Modifier .clickable { onDismiss() } @@ -338,7 +343,7 @@ internal fun RepositoryMarkdownReader( AndroidView( modifier = Modifier .fillMaxWidth() - .height(420.dp), + .weight(1f), factory = { viewContext -> ScrollView(viewContext).apply { addView( @@ -352,8 +357,9 @@ internal fun RepositoryMarkdownReader( }, update = { scrollView -> val textView = scrollView.getChildAt(0) as TextView - textView.setTextColor(markdownTextColor) markwon.setMarkdown(textView, markdownState.document.content) + textView.setTextColor(markdownTextColor) + textView.setLinkTextColor(markdownLinkColor) } ) } diff --git a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Preferences.kt b/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Preferences.kt index 24e78848..2c5655f8 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Preferences.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/screen/settings/Preferences.kt @@ -6,11 +6,9 @@ import android.os.Build import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -26,10 +24,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable @@ -42,300 +39,221 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat import androidx.hilt.navigation.compose.hiltViewModel -import com.ahu.ahutong.R import com.ahu.ahutong.data.dao.AHUCache +import com.ahu.ahutong.data.model.AppThemeMode import com.ahu.ahutong.notification.CourseReminderCapability import com.ahu.ahutong.notification.CourseReminderNotifier import com.ahu.ahutong.notification.CourseReminderScheduler -import com.ahu.ahutong.ui.components.LiquidToggle +import com.ahu.ahutong.ui.components.SettingsActionRow +import com.ahu.ahutong.ui.components.SettingsBackdropContainer +import com.ahu.ahutong.ui.components.SettingsChoice +import com.ahu.ahutong.ui.components.SettingsDialogSelectRow +import com.ahu.ahutong.ui.components.SettingsPageHeader +import com.ahu.ahutong.ui.components.SettingsSection +import com.ahu.ahutong.ui.components.SettingsToggleRow import com.ahu.ahutong.ui.shape.SmoothRoundedCornerShape import com.ahu.ahutong.ui.state.PreferencesViewModel -import com.kyant.backdrop.Backdrop -import com.kyant.backdrop.backdrops.rememberCanvasBackdrop -import com.kyant.monet.a1 -import com.kyant.monet.n1 -import com.kyant.monet.withNight @Composable -fun Preferences() { - - val preferencesViewModel: PreferencesViewModel = hiltViewModel() +fun Preferences(onBack: () -> Unit = {}) { + val viewModel: PreferencesViewModel = hiltViewModel() val context = LocalContext.current var isRequestingPermission by remember { mutableStateOf(false) } var useCmbCardRecharge by remember { mutableStateOf(AHUCache.isCmbCardRechargePreferred()) } var showClearLearningConfirm by remember { mutableStateOf(false) } + var showCustomColorDialog by remember { mutableStateOf(false) } + var isToggleHorizontalDragActive by remember { mutableStateOf(false) } + val pageScrollState = rememberScrollState() + val onToggleHorizontalDragActiveChange: (Boolean) -> Unit = { active -> + isToggleHorizontalDragActive = active + } - val showQRCode by preferencesViewModel.showQRCode.collectAsState() - val personalizationEnabled by preferencesViewModel.personalizationEnabled.collectAsState() - val predictivePrefetchEnabled by preferencesViewModel.predictivePrefetchEnabled.collectAsState() - val wifiOnlyPrefetch by preferencesViewModel.wifiOnlyPrefetch.collectAsState() - val behaviorRetentionDays by preferencesViewModel.behaviorRetentionDays.collectAsState() - val useLiquidGlass by preferencesViewModel.useLiquidGlass.collectAsState() - val courseReminderEnabled by preferencesViewModel.courseReminderEnabled.collectAsState() - val courseReminderLiveCountdownEnabled by preferencesViewModel.courseReminderLiveCountdownEnabled.collectAsState() + val appThemeMode by viewModel.appThemeMode.collectAsState() + val showQRCode by viewModel.showQRCode.collectAsState() + val personalizationEnabled by viewModel.personalizationEnabled.collectAsState() + val predictivePrefetchEnabled by viewModel.predictivePrefetchEnabled.collectAsState() + val wifiOnlyPrefetch by viewModel.wifiOnlyPrefetch.collectAsState() + val behaviorRetentionDays by viewModel.behaviorRetentionDays.collectAsState() + val useLiquidGlass by viewModel.useLiquidGlass.collectAsState() + val themeColor by viewModel.themeColor.collectAsState() + val courseReminderEnabled by viewModel.courseReminderEnabled.collectAsState() + val courseReminderLiveCountdownEnabled by + viewModel.courseReminderLiveCountdownEnabled.collectAsState() - val cardColor = 100.n1 withNight 20.n1 - val backdrop = rememberCanvasBackdrop { drawRect(cardColor) } val notificationPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> isRequestingPermission = false if (granted) { - preferencesViewModel.setCourseReminderEnabled(true) + viewModel.setCourseReminderEnabled(true) CourseReminderScheduler.reschedule(context) } else { - preferencesViewModel.setCourseReminderEnabled(false) + viewModel.setCourseReminderEnabled(false) Toast.makeText(context, "未授予通知权限,无法开启课前提醒", Toast.LENGTH_SHORT).show() } } - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(bottom = 80.dp) - .systemBarsPadding() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(24.dp) - ) { - Text( - text = stringResource(id = R.string.preferences), - modifier = Modifier.padding(24.dp, 32.dp), - style = MaterialTheme.typography.headlineLarge - ) - - PreferenceToggleCard( - sectionTitle = "猜你想用", - settingTitle = "显示快捷建议", - description = "根据仅在本机学习的使用习惯,在合适时机显示快捷建议。", - selected = { personalizationEnabled }, - onSelect = preferencesViewModel::setPersonalizationEnabled, - cardColor = cardColor, - backdrop = backdrop - ) - - PreferenceToggleCard( - sectionTitle = "智能预加载", - settingTitle = "提前加载预测内容", - description = "预测可能使用的只读内容并提前加载,命中后可以更快打开页面。", - selected = { predictivePrefetchEnabled }, - onSelect = preferencesViewModel::setPredictivePrefetchEnabled, - cardColor = cardColor, - backdrop = backdrop - ) - - PreferenceToggleCard( - sectionTitle = "预加载网络", - settingTitle = "仅在 Wi-Fi 下智能预加载", - selected = { wifiOnlyPrefetch }, - onSelect = preferencesViewModel::setWifiOnlyPrefetch, - cardColor = cardColor, - backdrop = backdrop - ) - - PreferenceToggleCard( - sectionTitle = "付款码", - settingTitle = "主页默认显示付款码", - selected = { showQRCode }, - onSelect = preferencesViewModel::setShowQRCode, - cardColor = cardColor, - backdrop = backdrop - ) - - PreferenceRetentionCard( - selectedDays = behaviorRetentionDays, - cardColor = cardColor, - onSelect = preferencesViewModel::setBehaviorRetentionDays - ) - - PreferenceActionCard( - sectionTitle = "本地学习记录", - actionTitle = "清除本地学习记录", - description = "删除当前账号的行为统计、训练样本、模型和晋级状态。", - cardColor = cardColor, - onClick = { showClearLearningConfirm = true } - ) - - Column( - modifier = - Modifier - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) - .clickable { - val enabled = !useCmbCardRecharge - useCmbCardRecharge = enabled - AHUCache.setCmbCardRechargePreferred(enabled) - } - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + val requestCourseReminder: (Boolean) -> Unit = { enabled -> + if (!enabled) { + viewModel.setCourseReminderEnabled(false) + CourseReminderScheduler.cancel(context) + } else if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED ) { - Text(text = "充值", style = MaterialTheme.typography.headlineSmall) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(8.dp)) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text(text = "总是使用招商银行充值") - Text( - text = "开启后首页校园卡充值会直接进入招商银行充值", - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) - } - LiquidToggle( - selected = { useCmbCardRecharge }, - onSelect = { enabled -> - useCmbCardRecharge = enabled - AHUCache.setCmbCardRechargePreferred(enabled) - }, - backdrop = backdrop - ) + if (!isRequestingPermission) { + isRequestingPermission = true + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } + } else { + viewModel.setCourseReminderEnabled(true) + CourseReminderScheduler.reschedule(context) } + } + SettingsBackdropContainer(modifier = Modifier.fillMaxSize()) { backdrop -> Column( - modifier = - Modifier - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) - .clickable { - if (!courseReminderEnabled) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - if (!isRequestingPermission) { - isRequestingPermission = true - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } else { - preferencesViewModel.setCourseReminderEnabled(true) - CourseReminderScheduler.reschedule(context) - } - } else { - preferencesViewModel.setCourseReminderEnabled(false) - CourseReminderScheduler.cancel(context) - } - } - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier + .fillMaxSize() + .verticalScroll( + state = pageScrollState, + enabled = !isToggleHorizontalDragActive + ) + .systemBarsPadding() + .padding(bottom = 112.dp), + verticalArrangement = Arrangement.spacedBy(26.dp) ) { - Text(text = "通知", style = MaterialTheme.typography.headlineSmall) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(8.dp)) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + SettingsPageHeader(title = "偏好设置", onBack = onBack, backdrop = backdrop) + + SettingsSection( + title = "智能体验", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text(text = "课前提醒") - Text( - text = "上课前 10 分钟提醒下一节课", - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium + personalizationEnabled?.let { enabled -> + SettingsToggleRow( + title = "显示快捷建议", + subtitle = "根据本机使用习惯显示常用入口", + selected = enabled, + onSelectedChange = viewModel::setPersonalizationEnabled, + backdrop = backdrop, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange ) } - LiquidToggle( - selected = { courseReminderEnabled }, - onSelect = { enabled -> - if (enabled) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && - ContextCompat.checkSelfPermission( - context, - Manifest.permission.POST_NOTIFICATIONS - ) != PackageManager.PERMISSION_GRANTED - ) { - if (!isRequestingPermission) { - isRequestingPermission = true - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - } else { - preferencesViewModel.setCourseReminderEnabled(true) - CourseReminderScheduler.reschedule(context) - } - } else { - preferencesViewModel.setCourseReminderEnabled(false) - CourseReminderScheduler.cancel(context) - } - }, - backdrop = backdrop + val predictiveEnabled = predictivePrefetchEnabled + val wifiOnly = wifiOnlyPrefetch + if (predictiveEnabled != null && wifiOnly != null) { + SettingsToggleRow( + title = "提前加载预测内容", + subtitle = "预测下一步并预加载只读内容", + selected = predictiveEnabled, + onSelectedChange = viewModel::setPredictivePrefetchEnabled, + backdrop = backdrop, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + ) + SettingsToggleRow( + title = "仅在 Wi-Fi 下预加载", + selected = predictiveEnabled && wifiOnly, + onSelectedChange = viewModel::setWifiOnlyPrefetch, + backdrop = backdrop, + enabled = predictiveEnabled, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + ) + } + SettingsDialogSelectRow( + title = "本地记录保留期", + dialogTitle = "选择本地记录保留期", + selected = behaviorRetentionDays, + choices = listOf( + SettingsChoice(7, "7 天"), + SettingsChoice(14, "14 天"), + SettingsChoice(30, "30 天") + ), + onSelected = viewModel::setBehaviorRetentionDays + ) + SettingsActionRow( + title = "清除本地学习记录", + subtitle = "删除行为统计、训练样本和本地模型", + destructive = true, + showChevron = false, + showDivider = false, + onClick = { showClearLearningConfirm = true } ) } + + SettingsSection( + title = "主页与充值", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop + ) { + SettingsToggleRow( + title = "主页默认显示付款码", + selected = showQRCode, + onSelectedChange = viewModel::setShowQRCode, + backdrop = backdrop, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + ) + SettingsToggleRow( + title = "总是使用招商银行充值", + subtitle = "校园卡充值将直接进入招商银行页面", + selected = useCmbCardRecharge, + onSelectedChange = { enabled -> + useCmbCardRecharge = enabled + AHUCache.setCmbCardRechargePreferred(enabled) + }, + backdrop = backdrop, + showDivider = false, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + ) } - Column( - modifier = - Modifier - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text(text = "通知增强", style = MaterialTheme.typography.headlineSmall) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(8.dp)) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + SettingsSection( + title = "通知", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text(text = "课前倒计时岛卡提醒(实验性)") - Text( - text = "仅部分系统支持 需同时开启课前提醒", - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) - } - LiquidToggle( - selected = { - courseReminderLiveCountdownEnabled && Build.VERSION.SDK_INT >= 36 - }, - onSelect = { enabled -> - if (enabled && Build.VERSION.SDK_INT < 36) { - Toast.makeText( - context, - "当前 Android 版本暂不支持岛卡提醒", - Toast.LENGTH_SHORT - ).show() - preferencesViewModel.setCourseReminderLiveCountdownEnabled(false) - } else { - preferencesViewModel.setCourseReminderLiveCountdownEnabled(enabled) - if (!enabled) { - CourseReminderNotifier.cancelActiveReminder(context) - } - } - }, - backdrop = backdrop - ) - } - TextButton( + SettingsToggleRow( + title = "课前提醒", + subtitle = "上课前 10 分钟提醒下一节课", + selected = courseReminderEnabled, + onSelectedChange = requestCourseReminder, + backdrop = backdrop, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + ) + SettingsToggleRow( + title = "课前倒计时岛卡", + subtitle = if (Build.VERSION.SDK_INT >= 36) { + "在支持的系统上显示实时倒计时" + } else { + "当前 Android 版本暂不支持" + }, + selected = courseReminderLiveCountdownEnabled && Build.VERSION.SDK_INT >= 36, + onSelectedChange = { enabled -> + if (enabled && Build.VERSION.SDK_INT < 36) { + Toast.makeText( + context, + "当前 Android 版本暂不支持岛卡提醒", + Toast.LENGTH_SHORT + ).show() + } else { + viewModel.setCourseReminderLiveCountdownEnabled(enabled) + if (!enabled) CourseReminderNotifier.cancelActiveReminder(context) + } + }, + backdrop = backdrop, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + ) + SettingsActionRow( + title = "管理系统岛卡权限", + showDivider = false, onClick = { if (Build.VERSION.SDK_INT < 36) { Toast.makeText( @@ -348,401 +266,230 @@ fun Preferences() { CourseReminderCapability.createPromotionSettingsIntent(context) val fallbackIntent = CourseReminderCapability.createNotificationSettingsIntent(context) - runCatching { - context.startActivity(promotionIntent) - }.getOrElse { - context.startActivity(fallbackIntent) - } + runCatching { context.startActivity(promotionIntent) } + .getOrElse { context.startActivity(fallbackIntent) } } - }, - modifier = Modifier.align(Alignment.End) - ) { - Text(text = "管理系统岛卡权限") - } + } + ) } - Column( - modifier = - Modifier - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) - .clickable { preferencesViewModel.setUseLiquidGlass(!preferencesViewModel.useLiquidGlass.value) } - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text(text = "液态玻璃", style = MaterialTheme.typography.headlineSmall) - Row( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(8.dp)) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + SettingsSection( + title = "外观", + modifier = Modifier.padding(horizontal = 16.dp), + backdrop = backdrop ) { - Text(text = "启用液态玻璃效果") - LiquidToggle( - selected = { useLiquidGlass }, - onSelect = { preferencesViewModel.setUseLiquidGlass(!preferencesViewModel.useLiquidGlass.value) }, - backdrop = backdrop + SettingsDialogSelectRow( + title = "深色模式", + dialogTitle = "选择深色模式", + selected = appThemeMode, + choices = listOf( + SettingsChoice(AppThemeMode.FOLLOW_SYSTEM, "跟随系统"), + SettingsChoice(AppThemeMode.DARK, "深色"), + SettingsChoice(AppThemeMode.LIGHT, "浅色") + ), + onSelected = viewModel::setAppThemeMode + ) + SettingsToggleRow( + title = "液态玻璃", + subtitle = "使用 Apple 风格的玻璃控件和浮动导航", + selected = useLiquidGlass, + onSelectedChange = viewModel::setUseLiquidGlass, + backdrop = backdrop, + onHorizontalDragActiveChange = onToggleHorizontalDragActiveChange + ) + ThemeColorPicker( + selectedColor = themeColor, + onColorSelected = viewModel::setThemeColor, + onCustomColorClick = { showCustomColorDialog = true } ) } } - - ThemeColorSelector(preferencesViewModel, cardColor) } if (showClearLearningConfirm) { AlertDialog( onDismissRequest = { showClearLearningConfirm = false }, title = { Text("清除本地学习记录?") }, - text = { Text("将删除当前账号的行为统计、训练样本、模型与晋级状态,且无法恢复。") }, + text = { Text("行为统计、训练样本、本地模型与晋级状态将被删除,且无法恢复。") }, confirmButton = { - TextButton(onClick = { - preferencesViewModel.clearPersonalizationLearning() - showClearLearningConfirm = false - }) { Text("清除") } + TextButton( + onClick = { + viewModel.clearPersonalizationLearning() + showClearLearningConfirm = false + } + ) { + Text("清除", color = MaterialTheme.colorScheme.error) + } }, - dismissButton = { TextButton(onClick = { showClearLearningConfirm = false }) { Text("取消") } } + dismissButton = { + TextButton(onClick = { showClearLearningConfirm = false }) { + Text("取消") + } + } ) } -} -@Composable -private fun PreferenceRetentionCard( - selectedDays: Int, - cardColor: Color, - onSelect: (Int) -> Unit -) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text(text = "本地学习记录保留期", style = MaterialTheme.typography.headlineSmall) - Text( - text = "只影响本机行为事件的保留时间;模型和聚合统计仍受清除记录操作控制。", - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically - ) { - listOf(7, 14, 30).forEach { days -> - TextButton(onClick = { onSelect(days) }) { - Text( - text = "$days 天", - color = if (selectedDays == days) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface - }, - fontWeight = if (selectedDays == days) FontWeight.Bold else FontWeight.Normal - ) - } + if (showCustomColorDialog) { + CustomThemeColorDialog( + initialValue = themeColor.orEmpty(), + onDismiss = { showCustomColorDialog = false }, + onConfirm = { color -> + viewModel.setThemeColor(color) + showCustomColorDialog = false } - } + ) } } +private data class ThemeColorChoice( + val value: String?, + val name: String, + val color: Color +) + @Composable -private fun PreferenceToggleCard( - sectionTitle: String, - settingTitle: String, - selected: () -> Boolean, - onSelect: (Boolean) -> Unit, - cardColor: Color, - backdrop: Backdrop, - description: String? = null +private fun ThemeColorPicker( + selectedColor: String?, + onColorSelected: (String?) -> Unit, + onCustomColorClick: () -> Unit ) { + val choices = listOf( + ThemeColorChoice(null, "系统", MaterialTheme.colorScheme.primary), + ThemeColorChoice("#FF4A90E2", "极光蓝", Color(0xFF4A90E2)), + ThemeColorChoice("#FFE07A9F", "樱花粉", Color(0xFFE07A9F)), + ThemeColorChoice("#FFF4A261", "落日橙", Color(0xFFF4A261)), + ThemeColorChoice("#FF6A994E", "苔藓绿", Color(0xFF6A994E)), + ThemeColorChoice("#FF9B7EDE", "薰衣草", Color(0xFF9B7EDE)), + ThemeColorChoice("#FF2E8B57", "翡翠", Color(0xFF2E8B57)) + ) + val presetValues = choices.map { it.value }.toSet() + val customSelected = selectedColor != null && selectedColor !in presetValues + Column( - modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) - .clickable { onSelect(!selected()) } - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + modifier = Modifier.fillMaxWidth() ) { - Text(text = sectionTitle, style = MaterialTheme.typography.headlineSmall) + Text( + text = "主题色", + modifier = Modifier.padding(start = 20.dp, top = 14.dp, end = 20.dp), + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Medium + ) Row( modifier = Modifier .fillMaxWidth() - .clip(SmoothRoundedCornerShape(8.dp)) - .padding(vertical = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top ) { - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - Text(text = settingTitle) - description?.let { - Text( - text = it, - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) - } - } - LiquidToggle( - selected = selected, - onSelect = onSelect, - backdrop = backdrop + ThemeColorSwatch( + name = "自定义", + color = runCatching { + Color(android.graphics.Color.parseColor(selectedColor)) + }.getOrDefault(MaterialTheme.colorScheme.surfaceContainerHighest), + selected = customSelected, + custom = true, + onClick = onCustomColorClick ) + choices.forEach { choice -> + ThemeColorSwatch( + name = choice.name, + color = choice.color, + selected = selectedColor == choice.value, + onClick = { onColorSelected(choice.value) } + ) + } } } } @Composable -private fun PreferenceActionCard( - sectionTitle: String, - actionTitle: String, - description: String, - cardColor: Color, - onClick: () -> Unit +private fun ThemeColorSwatch( + name: String, + color: Color, + selected: Boolean, + onClick: () -> Unit, + custom: Boolean = false ) { Column( modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) + .clip(SmoothRoundedCornerShape(14.dp)) .clickable(onClick = onClick) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + .padding(4.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(7.dp) ) { - Text(text = sectionTitle, style = MaterialTheme.typography.headlineSmall) - Column( + Box( modifier = Modifier - .fillMaxWidth() - .clip(SmoothRoundedCornerShape(8.dp)) - .padding(vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + .size(48.dp) + .clip(SmoothRoundedCornerShape(16.dp)) + .background(color), + contentAlignment = Alignment.Center ) { - Text(text = actionTitle, color = MaterialTheme.colorScheme.error) - Text( - text = description, - color = 50.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) + when { + selected -> Icon( + imageVector = Icons.Rounded.Check, + contentDescription = "已选择", + tint = Color.White + ) + custom -> Icon( + imageVector = Icons.Rounded.Add, + contentDescription = "自定义主题色", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } } + Text( + text = name, + color = if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal + ) } } @Composable -fun ThemeColorSelector( - viewModel: PreferencesViewModel, - cardColor: androidx.compose.ui.graphics.Color +private fun CustomThemeColorDialog( + initialValue: String, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit ) { - val themeColor by viewModel.themeColor.collectAsState() - var showCustomColorDialog by remember { mutableStateOf(false) } - var customColorInput by remember { mutableStateOf("") } - - val colors = listOf( - null to "默认", - "#FF4A90E2" to "极光蓝", - "#FFE07A9F" to "樱花粉", - "#FFF4A261" to "落日橙", - "#FF5C6BC0" to "靛夜蓝", - "#FF6A994E" to "苔藓绿", - "#FF9B7EDE" to "薰衣草紫", - "#FFD64550" to "绯红花", - "#FF4CC9F0" to "天空青", - "#FF2E8B57" to "森林翡翠", - "#FF6A4C93" to "午夜紫", - "#FFFF6F61" to "珊瑚粉", - "#FF7ED9C3" to "北极薄荷" - ) - - val isCustomColor = themeColor != null && colors.none { it.first == themeColor } - - if (showCustomColorDialog) { - AlertDialog( - containerColor = cardColor, - onDismissRequest = { showCustomColorDialog = false }, - title = { - Text( - text = "自定义主题颜色", - color = 10.n1 withNight 100.n1, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = "请输入ARGB Hex颜色代码 (例如 #FF007FAC)", - color = 30.n1 withNight 80.n1, - style = MaterialTheme.typography.bodyMedium - ) - androidx.compose.material3.OutlinedTextField( - value = customColorInput, - onValueChange = { customColorInput = it }, - label = { Text("Hex Color") }, - singleLine = true, - modifier = Modifier.fillMaxWidth(), - shape = SmoothRoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = 40.a1 withNight 80.a1, - unfocusedBorderColor = 50.n1 withNight 60.n1, - focusedLabelColor = 40.a1 withNight 80.a1, - unfocusedLabelColor = 50.n1 withNight 60.n1, - cursorColor = 40.a1 withNight 80.a1, - focusedTextColor = 10.n1 withNight 100.n1, - unfocusedTextColor = 10.n1 withNight 100.n1 - ) - ) - } - }, - confirmButton = { - TextButton( - onClick = { - try { - // Validate color parsing - android.graphics.Color.parseColor(customColorInput) - viewModel.setThemeColor(customColorInput) - showCustomColorDialog = false - } catch (e: Exception) { - // Invalid color, maybe show error or just ignore - } - } - ) { - Text( - text = "确定", - color = 40.a1 withNight 80.a1, - fontWeight = FontWeight.Bold - ) - } - }, - dismissButton = { - TextButton(onClick = { showCustomColorDialog = false }) { - Text( - text = "取消", - color = 40.a1 withNight 80.a1, - fontWeight = FontWeight.Bold - ) - } - } - ) + var value by remember(initialValue) { mutableStateOf(initialValue) } + val valid = remember(value) { + runCatching { android.graphics.Color.parseColor(value) }.isSuccess } - - Column( - modifier = Modifier - .clip(SmoothRoundedCornerShape(16.dp)) - .background(cardColor) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - Text(text = "主题颜色", style = MaterialTheme.typography.headlineSmall) - - // Use FlowRow or LazyRow if there are many colors. For now, a simple wrapped layout or Column is fine. - // Let's use a FlowRow equivalent or just a simple vertical list of rows if we want to be safe without experimental APIs, - // or just a Row with horizontal scroll if we expect few items. - // Given the design, a horizontal scrollable Row seems appropriate for color circles. - - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - // Custom Color Button - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { - customColorInput = if (isCustomColor) themeColor ?: "" else "" - showCustomColorDialog = true - } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("自定义主题色") }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + label = { Text("ARGB Hex") }, + placeholder = { Text("#FF007FAC") }, + isError = value.isNotBlank() && !valid, + supportingText = { + if (value.isNotBlank() && !valid) Text("请输入有效的颜色代码") + }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + }, + confirmButton = { + TextButton( + enabled = valid, + onClick = { onConfirm(value) } ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(SmoothRoundedCornerShape(12.dp)) - .background( - if (isCustomColor && themeColor != null) Color( - android.graphics.Color.parseColor( - themeColor - ) - ) else MaterialTheme.colorScheme.surfaceVariant - ), - contentAlignment = Alignment.Center - ) { - if (isCustomColor) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = "Selected", - tint = Color.White - ) - } else { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = "Custom", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - Text( - text = "自定义", - style = MaterialTheme.typography.labelMedium - ) - } - - colors.forEach { (colorHex, name) -> - val isSelected = themeColor == colorHex - val color = if (colorHex != null) { - Color(android.graphics.Color.parseColor(colorHex)) - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - colorResource(id = android.R.color.system_accent1_500) - } else { - Color(0xFF007FAC) - } - } - - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() } - ) { viewModel.setThemeColor(colorHex) } - ) { - Box( - modifier = Modifier - .size(48.dp) - .clip(SmoothRoundedCornerShape(12.dp)) - .background(color), - contentAlignment = Alignment.Center - ) { - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = "Selected", - tint = Color.White - ) - } - } - Text( - text = name, - style = MaterialTheme.typography.labelMedium, - color = if (isSelected) { - 50.a1 - } else { - Color.Black withNight Color.White - } - ) - } + Text("应用") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("取消") } } - } + ) } diff --git a/app/src/main/java/com/ahu/ahutong/ui/state/PreferencesViewModel.kt b/app/src/main/java/com/ahu/ahutong/ui/state/PreferencesViewModel.kt index 0c0fcb3e..327e68ec 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/state/PreferencesViewModel.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/state/PreferencesViewModel.kt @@ -3,6 +3,7 @@ package com.ahu.ahutong.ui.state import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ahu.ahutong.data.dao.PreferencesManager +import com.ahu.ahutong.data.model.AppThemeMode import com.ahu.ahutong.personalization.runtime.BehaviorPredictionRuntime import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -17,14 +18,14 @@ class PreferencesViewModel @Inject constructor( private val behaviorRuntime: BehaviorPredictionRuntime ) : ViewModel() { - private val _personalizationEnabled = MutableStateFlow(true) - val personalizationEnabled: StateFlow = _personalizationEnabled.asStateFlow() + private val _personalizationEnabled = MutableStateFlow(null) + val personalizationEnabled: StateFlow = _personalizationEnabled.asStateFlow() - private val _predictivePrefetchEnabled = MutableStateFlow(true) - val predictivePrefetchEnabled: StateFlow = _predictivePrefetchEnabled.asStateFlow() + private val _predictivePrefetchEnabled = MutableStateFlow(null) + val predictivePrefetchEnabled: StateFlow = _predictivePrefetchEnabled.asStateFlow() - private val _wifiOnlyPrefetch = MutableStateFlow(false) - val wifiOnlyPrefetch: StateFlow = _wifiOnlyPrefetch.asStateFlow() + private val _wifiOnlyPrefetch = MutableStateFlow(null) + val wifiOnlyPrefetch: StateFlow = _wifiOnlyPrefetch.asStateFlow() private val _behaviorRetentionDays = MutableStateFlow(30) val behaviorRetentionDays: StateFlow = _behaviorRetentionDays.asStateFlow() @@ -38,8 +39,11 @@ class PreferencesViewModel @Inject constructor( private val _useLiquidGlass = MutableStateFlow(true) val useLiquidGlass: StateFlow = _useLiquidGlass.asStateFlow() - private val _themeColor = MutableStateFlow(null) - val themeColor: StateFlow = _themeColor.asStateFlow() + private val _themeColor = MutableStateFlow(null) + val themeColor: StateFlow = _themeColor.asStateFlow() + + private val _appThemeMode = MutableStateFlow(AppThemeMode.FOLLOW_SYSTEM) + val appThemeMode: StateFlow = _appThemeMode.asStateFlow() private val _courseReminderEnabled = MutableStateFlow(false) val courseReminderEnabled: StateFlow = _courseReminderEnabled.asStateFlow() @@ -57,6 +61,7 @@ class PreferencesViewModel @Inject constructor( viewModelScope.launch { preferencesManager.predictivePrefetchEnabled.collect { _predictivePrefetchEnabled.value = it } } viewModelScope.launch { preferencesManager.wifiOnlyPrefetch.collect { _wifiOnlyPrefetch.value = it } } viewModelScope.launch { preferencesManager.behaviorRetentionDays.collect { _behaviorRetentionDays.value = it } } + viewModelScope.launch { preferencesManager.themeMode.collect { _appThemeMode.value = it } } viewModelScope.launch { preferencesManager.themeColor.collect { _themeColor.value = it @@ -104,12 +109,19 @@ class PreferencesViewModel @Inject constructor( fun setPredictivePrefetchEnabled(value: Boolean) { viewModelScope.launch { preferencesManager.setPredictivePrefetchEnabled(value) - if (!value) behaviorRuntime.cancelPredictivePrefetch() + if (!value) { + preferencesManager.setWifiOnlyPrefetch(false) + behaviorRuntime.cancelPredictivePrefetch() + } } } fun setWifiOnlyPrefetch(value: Boolean) { - viewModelScope.launch { preferencesManager.setWifiOnlyPrefetch(value) } + viewModelScope.launch { + preferencesManager.setWifiOnlyPrefetch( + value && _predictivePrefetchEnabled.value == true + ) + } } fun clearPersonalizationLearning() { @@ -156,6 +168,12 @@ class PreferencesViewModel @Inject constructor( } } + fun setAppThemeMode(value: AppThemeMode) { + viewModelScope.launch { + preferencesManager.setThemeMode(value) + } + } + fun setRepositoryAccelerationSource(value: String) { viewModelScope.launch { preferencesManager.setRepositoryAccelerationSource(value) diff --git a/app/src/main/java/com/ahu/ahutong/ui/theme/AHUTheme.kt b/app/src/main/java/com/ahu/ahutong/ui/theme/AHUTheme.kt index bdd31038..c845bf12 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/theme/AHUTheme.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/theme/AHUTheme.kt @@ -1,19 +1,30 @@ package com.ahu.ahutong.ui.theme +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import android.content.res.Configuration import android.os.Build import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.colorResource +import androidx.core.view.WindowCompat import androidx.hilt.navigation.compose.hiltViewModel import com.ahu.ahutong.ui.components.LocalIsLiquidGlassEnabled import com.ahu.ahutong.ui.state.PreferencesViewModel import com.kyant.monet.LocalTonalPalettes import com.kyant.monet.TonalPalettes.Companion.toTonalPalettes +import com.kyant.monet.dynamicColorScheme import com.kyant.monet.n1 import com.kyant.monet.toColor import com.kyant.monet.toSrgb @@ -21,23 +32,63 @@ import com.kyant.monet.toSrgb @Composable fun AHUTheme(content: @Composable () -> Unit) { val preferencesViewModel: PreferencesViewModel = hiltViewModel() - val themeColorHex = preferencesViewModel.themeColor.collectAsState().value - - val keyColor = if (themeColorHex != null) { - Color(android.graphics.Color.parseColor(themeColorHex)) - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - colorResource(id = android.R.color.system_accent1_500) - } else { - Color(0xFF007FAC) + val themeColorHex by preferencesViewModel.themeColor.collectAsState() + val themeMode by preferencesViewModel.appThemeMode.collectAsState() + val useLiquidGlass by preferencesViewModel.useLiquidGlass.collectAsState() + val isDarkTheme = themeMode.resolve(isSystemInDarkTheme()) + val configuration = LocalConfiguration.current + val themeConfiguration = remember(configuration, isDarkTheme) { + Configuration(configuration).apply { + val nightMode = if (isDarkTheme) { + Configuration.UI_MODE_NIGHT_YES + } else { + Configuration.UI_MODE_NIGHT_NO + } + uiMode = (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or nightMode + } + } + val view = LocalView.current + + SideEffect { + view.context.findActivity()?.window?.let { window -> + WindowCompat.getInsetsController(window, view).apply { + isAppearanceLightStatusBars = !isDarkTheme + isAppearanceLightNavigationBars = !isDarkTheme + } + } } - MaterialTheme { - CompositionLocalProvider( - LocalTonalPalettes provides keyColor.toSrgb().toColor().toTonalPalettes(), - LocalContentColor provides if (isSystemInDarkTheme()) 100.n1 else 0.n1, - LocalIsLiquidGlassEnabled provides preferencesViewModel.useLiquidGlass.collectAsState().value - ) { - content() + val customKeyColor = remember(themeColorHex) { + themeColorHex?.let { value -> + runCatching { Color(android.graphics.Color.parseColor(value)) }.getOrNull() } } + val keyColor = when { + customKeyColor != null -> customKeyColor + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> + colorResource(id = android.R.color.system_accent1_500) + else -> Color(0xFF007FAC) + } + val tonalPalettes = remember(keyColor) { + keyColor.toSrgb().toColor().toTonalPalettes() + } + + CompositionLocalProvider( + LocalConfiguration provides themeConfiguration, + LocalTonalPalettes provides tonalPalettes + ) { + MaterialTheme(colorScheme = dynamicColorScheme(isLight = !isDarkTheme)) { + CompositionLocalProvider( + LocalContentColor provides if (isDarkTheme) 100.n1 else 0.n1, + LocalIsLiquidGlassEnabled provides useLiquidGlass, + content = content + ) + } + } +} + +private tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null } diff --git a/app/src/main/java/com/ahu/ahutong/ui/utils/DampedDragAnimation.kt b/app/src/main/java/com/ahu/ahutong/ui/utils/DampedDragAnimation.kt index fc0a510c..d7328076 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/utils/DampedDragAnimation.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/utils/DampedDragAnimation.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.MutatorMutex import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.unit.IntSize @@ -27,6 +28,9 @@ class DampedDragAnimation( val onDragStarted: DampedDragAnimation.(position: Offset) -> Unit, val onDragStopped: DampedDragAnimation.() -> Unit, val onDrag: DampedDragAnimation.(size: IntSize, dragAmount: Offset) -> Unit, + val onDragCancelled: DampedDragAnimation.() -> Unit = onDragStopped, + val pointerEventPass: PointerEventPass = PointerEventPass.Main, + val shouldConsumeDrag: DampedDragAnimation.(dragAmount: Offset) -> Boolean = { false }, ) { private val valueAnimationSpec = @@ -75,9 +79,11 @@ class DampedDragAnimation( release() }, onDragCancel = { - onDragStopped() + onDragCancelled() release() - } + }, + eventPass = pointerEventPass, + shouldConsumeDrag = { dragAmount -> shouldConsumeDrag(dragAmount) } ) { change, dragAmount -> onDrag(size, dragAmount) } diff --git a/app/src/main/java/com/ahu/ahutong/ui/utils/DragGestureInspector.kt b/app/src/main/java/com/ahu/ahutong/ui/utils/DragGestureInspector.kt index e8eba62f..46ced356 100644 --- a/app/src/main/java/com/ahu/ahutong/ui/utils/DragGestureInspector.kt +++ b/app/src/main/java/com/ahu/ahutong/ui/utils/DragGestureInspector.kt @@ -16,6 +16,8 @@ suspend fun PointerInputScope.inspectDragGestures( onDragStart: (down: PointerInputChange) -> Unit = {}, onDragEnd: (change: PointerInputChange) -> Unit = {}, onDragCancel: () -> Unit = {}, + eventPass: PointerEventPass = PointerEventPass.Main, + shouldConsumeDrag: (dragAmount: Offset) -> Boolean = { false }, onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit ) { awaitEachGesture { @@ -29,7 +31,12 @@ suspend fun PointerInputScope.inspectDragGestures( val upEvent = drag( pointerId = drag.id, - onDrag = { onDrag(it, it.positionChange()) } + eventPass = eventPass, + onDrag = { + val dragAmount = it.positionChange() + onDrag(it, dragAmount) + if (shouldConsumeDrag(dragAmount)) it.consume() + } ) if (upEvent == null) { onDragCancel() @@ -41,6 +48,7 @@ suspend fun PointerInputScope.inspectDragGestures( private suspend inline fun AwaitPointerEventScope.drag( pointerId: PointerId, + eventPass: PointerEventPass, onDrag: (PointerInputChange) -> Unit ): PointerInputChange? { val isPointerUp = currentEvent.changes.fastFirstOrNull { it.id == pointerId }?.pressed != true @@ -49,7 +57,7 @@ private suspend inline fun AwaitPointerEventScope.drag( } var pointer = pointerId while (true) { - val change = awaitDragOrUp(pointer) ?: return null + val change = awaitDragOrUp(pointer, eventPass) ?: return null if (change.isConsumed) { return null } @@ -62,11 +70,12 @@ private suspend inline fun AwaitPointerEventScope.drag( } private suspend inline fun AwaitPointerEventScope.awaitDragOrUp( - pointerId: PointerId + pointerId: PointerId, + eventPass: PointerEventPass ): PointerInputChange? { var pointer = pointerId while (true) { - val event = awaitPointerEvent() + val event = awaitPointerEvent(eventPass) val dragEvent = event.changes.fastFirstOrNull { it.id == pointer } ?: return null if (dragEvent.changedToUpIgnoreConsumed()) { val otherDown = event.changes.fastFirstOrNull { it.pressed } diff --git a/app/src/test/java/com/ahu/ahutong/data/model/AppThemeModeTest.kt b/app/src/test/java/com/ahu/ahutong/data/model/AppThemeModeTest.kt new file mode 100644 index 00000000..87c52b53 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/data/model/AppThemeModeTest.kt @@ -0,0 +1,29 @@ +package com.ahu.ahutong.data.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AppThemeModeTest { + @Test + fun `unknown storage value falls back to follow system`() { + assertEquals(AppThemeMode.FOLLOW_SYSTEM, AppThemeMode.fromStorage("unknown")) + assertEquals(AppThemeMode.FOLLOW_SYSTEM, AppThemeMode.fromStorage(null)) + } + + @Test + fun `stored values round trip`() { + AppThemeMode.entries.forEach { mode -> + assertEquals(mode, AppThemeMode.fromStorage(mode.storageValue)) + } + } + + @Test + fun `mode resolves expected darkness`() { + assertTrue(AppThemeMode.FOLLOW_SYSTEM.resolve(systemIsDark = true)) + assertFalse(AppThemeMode.FOLLOW_SYSTEM.resolve(systemIsDark = false)) + assertTrue(AppThemeMode.DARK.resolve(systemIsDark = false)) + assertFalse(AppThemeMode.LIGHT.resolve(systemIsDark = true)) + } +} diff --git a/app/src/test/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyleTest.kt b/app/src/test/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyleTest.kt new file mode 100644 index 00000000..443544f9 --- /dev/null +++ b/app/src/test/java/com/ahu/ahutong/ui/screen/main/CmbRechargePageStyleTest.kt @@ -0,0 +1,206 @@ +package com.ahu.ahutong.ui.screen.main + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CmbRechargePageStyleTest { + private val darkPalette = CmbRechargePagePalette( + colorScheme = "dark", + background = "#111111", + surface = "#222222", + surfaceVariant = "#333333", + text = "#EEEEEE", + secondaryText = "#BBBBBB", + outline = "#444444", + accent = "#80BFFF", + onAccent = "#102030", + success = "#81C784", + scrim = "rgba(0, 0, 0, 0.62)" + ) + + @Test + fun styleScriptCoversTheSavedRechargePageStates() { + val script = buildCmbRechargeStyleScript(darkPalette) + + assertContains(script, "color-scheme: dark") + assertContains(script, "#app .van-nav-bar") + assertContains(script, "display: none !important") + assertContains(script, "#app .charge") + assertContains(script, "#app .van-action-sheet") + assertContains(script, "#app .keyboard") + assertContains(script, "#app .resultBox") + assertContains(script, darkPalette.background) + assertContains(script, darkPalette.text) + assertContains(script, darkPalette.accent) + } + + @Test + fun styleScriptDoesNotHookOrReadThePaymentPage() { + val script = buildCmbRechargeStyleScript(darkPalette) + val disallowedOperations = listOf( + "addEventListener", + "MutationObserver", + "XMLHttpRequest", + "fetch(", + "document.cookie", + "localStorage", + "sessionStorage", + ".click()", + ".submit()" + ) + + disallowedOperations.forEach { operation -> + assertFalse(script.contains(operation), "Unexpected page operation: $operation") + } + } + + @Test + fun styleScriptKeepsCarouselAndPaymentControlsVisuallyIntact() { + val script = buildCmbRechargeStyleScript(darkPalette) + + assertContains(script, "padding: 20px 0 28px !important") + assertContains(script, "background-size: 100% 100% !important") + assertContains(script, "#app .closeAmount .van-hairline--surround::after") + assertContains(script, "content: none !important") + assertContains(script, "border-radius: 14px !important") + assertContains(script, "#app .van-password-input__security li") + assertContains(script, "background: var(--ahutong-surface-variant) !important") + assertContains(script, "background: var(--ahutong-text) !important") + } + + @Test + fun successBoundsScriptLocatesOnlyTheResultPageReturnButton() { + val script = buildCmbRechargeSuccessReturnBoundsScript() + + assertContains( + script, + "#app button.van-button.van-button--default.van-button--normal.van-button--block.van-button--round" + ) + assertContains(script, "window.location.hostname.toLowerCase() === 'epay92.ahu.edu.cn'") + assertContains(script, "window.location.port === '443'") + assertContains(script, "path === '/cashier-mobile/chargeresult'") + assertContains(script, "document.querySelector('#app .resultBox')") + assertContains(script, "document.querySelectorAll(") + assertContains(script, "button.getBoundingClientRect()") + assertContains(script, "window.visualViewport") + assertContains(script, "button.style.pointerEvents = 'none'") + + listOf( + "addEventListener", + "document.cookie", + "localStorage", + "sessionStorage", + "XMLHttpRequest", + "fetch(", + "MutationObserver", + "input.value", + "innerText", + "textContent" + ).forEach { operation -> + assertFalse(script.contains(operation), "Unexpected result hook operation: $operation") + } + } + + @Test + fun styleTargetAllowsOnlyKnownHostsAndPaths() { + assertTrue( + isCmbRechargeStyleTarget( + "https://epay92.ahu.edu.cn/cashier-mobile/charge?disable=1" + ) + ) + assertTrue(isCmbRechargeStyleTarget("http://epay92.ahu.edu.cn/cashier-mobile/")) + assertTrue( + isCmbRechargeStyleTarget( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + ) + ) + assertTrue(isCmbRechargeStyleTarget("https://ycard.ahu.edu.cn/charge-app/")) + + assertFalse( + isCmbRechargeStyleTarget( + "https://epay92.ahu.edu.cn.evil.example/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbRechargeStyleTarget( + "https://epay92.ahu.edu.cn/other?next=/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbRechargeStyleTarget( + "https://epay92.ahu.edu.cn/cashier-mobile-redirect/charge" + ) + ) + assertFalse(isCmbRechargeStyleTarget("https://other.ahu.edu.cn/charge-app/")) + } + + @Test + fun successUrlIsStrictlyScoped() { + assertTrue( + isCmbRechargeSuccessUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + ) + ) + assertTrue( + isCmbRechargeSuccessUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult/?order=1" + ) + ) + assertFalse( + isCmbRechargeSuccessUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge" + ) + ) + assertFalse( + isCmbRechargeSuccessUrl( + "https://epay92.ahu.edu.cn.evil.example/cashier-mobile/chargeResult" + ) + ) + assertFalse( + isCmbRechargeSuccessUrl( + "http://epay92.ahu.edu.cn/cashier-mobile/chargeResult" + ) + ) + assertFalse( + isCmbRechargeSuccessUrl( + "https://epay92.ahu.edu.cn:444/cashier-mobile/chargeResult" + ) + ) + assertFalse( + isCmbRechargeSuccessUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/chargeResult-fake" + ) + ) + assertFalse( + isCmbRechargeSuccessUrl( + "https://epay92.ahu.edu.cn/cashier-mobile/charge?next=/cashier-mobile/chargeResult" + ) + ) + + } + + @Test + fun normalizedOverlayBoundsAreParsedAndValidated() { + val bounds = assertNotNull( + parseCmbRechargeNormalizedBounds("[0.05,0.72,0.90,0.08]") + ) + assertTrue(bounds.left in 0.049f..0.051f) + assertTrue(bounds.top in 0.719f..0.721f) + assertTrue(bounds.width in 0.899f..0.901f) + assertTrue(bounds.height in 0.079f..0.081f) + + assertNull(parseCmbRechargeNormalizedBounds(null)) + assertNull(parseCmbRechargeNormalizedBounds("null")) + assertNull(parseCmbRechargeNormalizedBounds("[0,0,1]")) + assertNull(parseCmbRechargeNormalizedBounds("[NaN,0.7,0.9,0.08]")) + assertNull(parseCmbRechargeNormalizedBounds("[-0.1,0.7,0.9,0.08]")) + assertNull(parseCmbRechargeNormalizedBounds("[0.2,0.7,0.9,0.08]")) + assertNull(parseCmbRechargeNormalizedBounds("[0.05,0.99,0.9,0.08]")) + assertNull(parseCmbRechargeNormalizedBounds("[0.05,0.7,0.01,0.08]")) + assertNull(parseCmbRechargeNormalizedBounds("[0.05,0.7,0.9,0.005]")) + } +}